Enhance tool documentation and add new features

- Added module-level documentation for memory tools (`remember`, `recall`, `forget`) to clarify their purpose.
- Improved documentation in `recall.rs` and `remember.rs` to describe the functionality and flow of memory entry operations.
- Updated `mod.rs` to include descriptions for the tool trait and execution context.
- Enhanced `plan.rs` with detailed comments on plan-mode signaling tools.
- Documented text search tools in `search.rs` to explain their functionality.
- Improved sequential-thinking tool documentation in `seqthink.rs`.
- Added safety filter documentation in `shell_filter` for credential and git operations.
- Enhanced utility tools documentation, including `cd`, `dir_cache_update`, and `todowrite`.
- Improved rendering documentation in view modules (`chat`, `markdown`, `status`, `workflow`) to clarify rendering flows and purposes.
This commit is contained in:
asepharyana
2026-07-12 11:28:39 +07:00
parent 7158d362fd
commit 2efd40ca88
124 changed files with 2379 additions and 19 deletions
+37
View File
@@ -1,14 +1,41 @@
//! Global registry of running background bash jobs, and control operations
//! (output polling, kill) exposed to the rest of the app.
//!
//! Flow: a process-wide `Mutex<HashMap<String, BashJob>>` (lazily built via
//! `OnceLock`) holds every job spawned via `bgbash::job::spawn_bash_job` →
//! `bash_output` drains new lines for a given job id → `bash_kill` removes
//! a job from the map and signals its child process.
//!
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
//! lets background jobs outlive the borrow of any particular state mutation
//! and be looked up by id from tool calls issued at arbitrary points.
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::OnceLock;
use super::job::BashJob;
/// Lazily-initialised, process-wide registry of background bash jobs keyed
/// by job id.
///
/// Return: a reference to the static `Mutex<HashMap<...>>`, created on
/// first access.
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
JOBS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Drain any newly available output lines from a background bash job.
///
/// Flow: look up the job by id → repeatedly call `try_read_line()` until it
/// returns `None` → collect into a Vec.
///
/// Why: non-blocking; a job that hasn't produced new output yields no lines
/// rather than blocking the caller.
///
/// Return: `Some(lines)` if at least one new line was read, `None` if the
/// job doesn't exist, the lock is poisoned, or there was nothing new to read.
pub fn bash_output(id: &str) -> Option<Vec<String>> {
let mut map = bash_jobs_map().lock().ok()?;
let job = map.get_mut(id)?;
@@ -19,6 +46,16 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
if lines.is_empty() { None } else { Some(lines) }
}
/// Terminate a running background bash job and remove it from the registry.
///
/// Flow: remove the job from the map → if it has a valid child PID, send
/// `SIGTERM` to it (unix only) → return.
///
/// Why: removing from the map first means a concurrent lookup can no longer
/// see the job even if the signal delivery is delayed.
///
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
/// with that id exists.
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
let job = map.remove(id);
+40
View File
@@ -1,8 +1,24 @@
//! Background bash job spawning and non-blocking output polling.
//!
//! Flow: `spawn_bash_job` forks a detached OS thread that execs the command
//! via `sh -c`, streams stdout lines back over an `mpsc` channel, and sends
//! an `__exit:<code>` sentinel when the child terminates → callers poll the
//! returned `BashJob` with `try_read_line()` to drain output without
//! blocking the TUI event loop.
//!
//! Why: running bash commands on a detached thread with a channel (rather
//! than synchronously) lets the TUI stay responsive while long-running
//! shell commands execute in the background.
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::io::BufRead;
/// Handle to a bash command running in a detached background thread.
///
/// Why: output is streamed over an mpsc channel rather than buffered
/// synchronously, so the TUI can poll for new lines without blocking.
pub struct BashJob {
pub id: String,
pub child_pid: u32,
@@ -10,6 +26,21 @@ pub struct BashJob {
pub exit_code: Option<i32>,
}
/// Spawn a shell command in a background thread and return a handle to it.
///
/// Flow: spawn a thread → thread execs `sh -c <command>` with piped
/// stdout/stderr → thread sends the child PID back over a channel →
/// thread streams stdout lines to `output_tx` → on exit, sends an
/// `__exit:<code>` sentinel line.
///
/// Why: the PID is sent back before the command finishes so `bash_kill` can
/// terminate it mid-run; sentinel-prefixed strings (`__error:`, `__exit:`)
/// let `try_read_line` distinguish control messages from real output on the
/// same channel without a separate enum.
///
/// Return: a `BashJob` with a freshly generated id, the child PID (0 if the
/// spawn failed before the PID was sent), and the receiving end of the
/// output channel.
pub fn spawn_bash_job(command: String) -> BashJob {
let id = uuid::Uuid::new_v4().to_string();
let (output_tx, output_rx) = mpsc::channel::<String>();
@@ -57,6 +88,15 @@ pub fn spawn_bash_job(command: String) -> BashJob {
}
impl BashJob {
/// Non-blocking poll for the next output line from the job's channel.
///
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel,
/// record `exit_code` and return `None` instead of surfacing it as
/// output → otherwise return the line.
///
/// Return: `Some(line)` for real output, `None` if there's nothing
/// available yet or the job just finished (exit code recorded as a
/// side effect).
pub fn try_read_line(&mut self) -> Option<String> {
match self.output_rx.try_recv() {
Ok(line) => {
+3
View File
@@ -1,2 +1,5 @@
//! Background bash: run shell commands off the main thread, poll their
//! output non-blockingly, and terminate them on demand.
pub mod control;
pub mod job;
+18
View File
@@ -1,3 +1,10 @@
//! Tool-call gating: decides whether a risky tool call is allowed to run
//! before it executes.
/// Outcome of gating a tool call: whether it's allowed to run.
///
/// Why: `Block` carries a reason string for surfacing to the user/log, even
/// though nothing currently produces `Block` (classify() always allows).
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
Allow,
@@ -5,9 +12,20 @@ pub enum Verdict {
Block(String),
}
/// Gatekeeper that decides whether a tool call may proceed before execution.
pub struct Harness;
impl Harness {
/// Decide whether a tool call is allowed to execute.
///
/// Flow: if the tool isn't flagged risky, allow immediately → otherwise
/// defer to `classify`.
///
/// Why: `_args` and `_workspace_roots` are accepted for a future
/// content-aware classifier but currently unused — `classify` is a
/// stub that always allows.
///
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
pub fn gate_tool_call(
tool_name: &str,
_args: &serde_json::Value,
+36
View File
@@ -1,3 +1,7 @@
//! MCP server connection management: spawning/talking to stdio child
//! processes and HTTP endpoints, and adapting their advertised tools to
//! the crate's `Tool` trait.
use serde_json::{json, Value};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
@@ -21,6 +25,8 @@ fn mcp_static_str(s: &str) -> &'static str {
leaked
}
/// How an MCP server is reached: a spawned child process talking
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum McpTransport {
Stdio {
@@ -32,6 +38,7 @@ pub enum McpTransport {
},
}
/// A single tool advertised by an MCP server, as returned by `tools/list`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolInfo {
pub name: String,
@@ -39,6 +46,8 @@ pub struct McpToolInfo {
pub input_schema: Value,
}
/// A connected MCP server: its transport, advertised tools, and (for stdio)
/// a live handle to the child process.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub name: String,
@@ -51,6 +60,8 @@ pub struct McpServer {
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
}
/// Live handle to an MCP server child process communicating over stdio
/// via newline-delimited JSON-RPC 2.0.
#[derive(Debug)]
pub struct StdioChild {
stdin: std::process::ChildStdin,
@@ -59,6 +70,18 @@ pub struct StdioChild {
}
impl StdioChild {
/// Send a JSON-RPC request to the child and block for its matching response.
///
/// Flow: assign the next request id → write request + newline to stdin →
/// loop reading lines from stdout until one has a matching `id` or the
/// timeout elapses → return its `result` (or error out on an `error` field).
///
/// Why: the child may interleave unrelated/malformed lines, so blank
/// lines are skipped and non-matching ids are ignored rather than
/// treated as a protocol violation.
///
/// Return: the `result` value of the matching response, or `Err` on
/// timeout, EOF, JSON-RPC error, or I/O failure.
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
self.next_id += 1;
let id = self.next_id;
@@ -255,11 +278,14 @@ fn extract_text_content(result: &Value) -> anyhow::Result<String> {
}))
}
/// Registry of connected MCP servers and their tools for the current session.
#[derive(Debug, Clone)]
pub struct McpManager {
pub servers: Vec<McpServer>,
}
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
/// be dispatched through the same execution path as built-in tools.
pub struct McpToolAdapter {
pub tool_name: String,
pub server_name: String,
@@ -296,12 +322,22 @@ impl crate::tool::Tool for McpToolAdapter {
}
impl McpManager {
/// Create an empty manager with no connected servers.
pub fn new() -> Self {
McpManager {
servers: Vec::new(),
}
}
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
///
/// Flow: for each server, clone its child handle → wrap each of its
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
///
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
/// stdio server reuses the same persistent child process/connection.
///
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
self.servers.iter().flat_map(|server| {
let handle = server.child_handle.clone();
+3
View File
@@ -1 +1,4 @@
//! Model Context Protocol (MCP) client: connects to external MCP servers
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
pub mod manager;
+2
View File
@@ -1,3 +1,5 @@
//! Top-level application module: harness, modes, runtime loop, state,
//! workflows, subagents, review, background bash, and MCP integration.
pub mod harness;
pub mod mode;
pub mod runtime;
+11
View File
@@ -1,5 +1,16 @@
//! Bash mode: handles submitting a shell command from the bash input panel.
use crate::app::state::rest::AppStateRest;
/// Launch a background bash job for the submitted command.
///
/// Flow: ignore empty input → spawn the job (fire-and-forget, the job's
/// output is polled elsewhere via `bgbash::control`) → mark state dirty
/// so the TUI re-renders.
///
/// Why: the returned `BashJob` handle is intentionally dropped — this
/// function only needs to kick the job off; the job registers itself in
/// the shared jobs map for later polling.
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
if !command.is_empty() {
let _ = crate::app::bgbash::job::spawn_bash_job(command);
+30
View File
@@ -1,6 +1,11 @@
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
//! with bounded undo history.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// State for the built-in line editor overlay: buffer contents, cursor
/// position, and a bounded undo stack.
#[derive(Debug, Clone)]
pub struct EditorState {
pub path: String,
@@ -23,6 +28,8 @@ impl Default for EditorState {
}
impl EditorState {
/// Create a fresh editor state for `path`, seeded with existing content
/// (or a single empty line for a new file).
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
EditorState {
@@ -32,12 +39,20 @@ impl EditorState {
}
}
/// Insert a new empty line immediately after the cursor line.
///
/// Why: snapshots content to the undo stack first, matching every other
/// mutating method here.
pub fn insert_line_after(&mut self) {
self.save_undo();
let pos = (self.cursor_line + 1).min(self.content.len());
self.content.insert(pos, String::new());
}
/// Push a snapshot of the current content onto the undo stack, capped at 50 entries.
///
/// Why: `remove(0)` on overflow bounds memory use at the cost of O(n)
/// shifting; the cap (50) keeps that cost negligible in practice.
fn save_undo(&mut self) {
self.undo_stack.push(self.content.clone());
if self.undo_stack.len() > 50 {
@@ -45,6 +60,7 @@ impl EditorState {
}
}
/// Move the cursor down one line, clamping the column to the new line's length.
pub fn cursor_down(&mut self) {
if self.cursor_line + 1 < self.content.len() {
self.cursor_line += 1;
@@ -54,6 +70,7 @@ impl EditorState {
);
}
/// Insert a character at the cursor and advance the cursor past it.
pub fn insert_char(&mut self, c: char) {
self.save_undo();
if let Some(line) = self.content.get_mut(self.cursor_line) {
@@ -62,6 +79,11 @@ impl EditorState {
}
}
/// Delete the character before the cursor (backspace).
///
/// Flow: if not at column 0, remove the preceding char on this line →
/// otherwise (start of line, not the first line) merge this line into
/// the previous one, joining at the old line's end.
pub fn delete_left(&mut self) {
self.save_undo();
if let Some(line) = self.content.get_mut(self.cursor_line) {
@@ -78,11 +100,18 @@ impl EditorState {
}
}
/// Join all lines with `\n` into the full file contents, for saving.
pub fn as_string(&self) -> String {
self.content.join("\n")
}
}
/// Feed a chunk of typed text into the active editor, translating newlines
/// and tabs into editor operations.
///
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
/// line and moves down, `\t` inserts two spaces, everything else inserts
/// the char directly → mark state dirty.
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
let editor = &mut state.misc.editor;
if editor.is_none() {
@@ -108,6 +137,7 @@ pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
state.dirty = true;
}
/// Close the editor overlay without saving, clearing editor state.
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
state.misc.editor = None;
state.misc.overlay = Overlay::None;
+12
View File
@@ -1,3 +1,6 @@
//! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and max_tokens for subsequent turns.
use crate::app::state::rest::AppStateRest;
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
@@ -17,15 +20,24 @@ pub fn generation_params(level: usize, base_max_tokens: u32) -> (f32, u32) {
(temperature, max_tokens.max(256))
}
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
///
/// Why: clamping guards against a stale/out-of-range value in loaded state
/// (e.g. after `EFFORT_LEVELS` shrinks between versions).
pub fn current_effort(state: &AppStateRest) -> usize {
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
}
/// Return the current effort level's display name (e.g. "medium").
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
let idx = current_effort(state);
EFFORT_LEVELS[idx]
}
/// Advance to the next effort level, wrapping around, and toast the new value.
///
/// Flow: compute `(current + 1) % len` → store it → push an info toast with
/// the new level's label → mark state dirty.
pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
+8
View File
@@ -1,3 +1,5 @@
//! Help mode: static help text and the action that opens/closes the help overlay.
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
@@ -27,6 +29,12 @@ Slash commands:
/lesson import Import lessons
/clear Clear transcript";
/// Route an incoming action while the help overlay is open.
///
/// Flow: `CloseOverlay` passes through unchanged; any other action is
/// treated as "open help" (idempotent — re-opens the overlay it's already on).
///
/// Return: the `Action` to actually dispatch.
pub fn handle_help_action(action: &Action) -> Action {
match action {
Action::CloseOverlay => Action::CloseOverlay,
+3
View File
@@ -1,5 +1,8 @@
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
use crate::app::state::rest::AppStateRest;
/// Replace the input buffer with the given text and mark state dirty.
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
state.input.buffer = text;
state.dirty = true;
+3
View File
@@ -1,3 +1,5 @@
//! Loading mode: transient overlay shown while waiting on an async operation.
use crate::app::state::rest::AppStateRest;
pub const LOADING_MESSAGES: &[&str] = &[
@@ -7,6 +9,7 @@ pub const LOADING_MESSAGES: &[&str] = &[
"almost done...",
];
/// Mark state dirty to force a re-render (e.g. to advance the loading spinner/message).
pub fn resolve_loading(state: &mut AppStateRest) {
state.dirty = true;
}
+6
View File
@@ -1,5 +1,11 @@
//! MCP mode: overlay for connecting to a configured MCP server.
use crate::app::state::rest::AppStateRest;
/// Placeholder entry point for connecting to an MCP server by name.
///
/// Why: not yet wired to `McpManager::connect_stdio` — currently just
/// marks state dirty so the overlay re-renders.
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
let _ = server_name;
state.dirty = true;
+5
View File
@@ -1,3 +1,6 @@
//! TUI mode definitions and per-mode input/action handlers, one submodule
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
use serde::{Deserialize, Serialize};
pub mod bash;
@@ -11,6 +14,8 @@ pub mod rewind;
pub mod settings;
pub mod todo;
/// Which input/overlay mode the TUI is currently in; drives both key
/// routing (`controller/input.rs`) and rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModeKind {
Chat,
+6
View File
@@ -1,5 +1,11 @@
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
use crate::app::runtime::actions::Action;
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
///
/// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
/// to dismiss the prompt without quitting.
pub fn handle_quit_confirm(yes: bool) -> Action {
if yes {
Action::ForceQuit
+3
View File
@@ -1,3 +1,6 @@
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's SQLite blob store.
use crate::app::state::rest::AppStateRest;
use sha2::Digest;
+14
View File
@@ -1,5 +1,19 @@
//! Settings-mode helper logic for the TUI settings overlay.
//!
//! Flow: exposes small mutation functions (currently just cycling the
//! internet access mode) invoked by keybindings while the settings overlay
//! is active.
use crate::model::settings::{Settings, InternetMode};
/// Advance the internet access mode to the next value in the cycle.
///
/// Flow: Off -> ReadOnly -> Full -> Off, wrapping around.
///
/// Why: used by a settings-toggle keybinding to step through modes
/// without needing a dropdown/menu.
///
/// Return: nothing; mutates `settings.internet_mode` in place.
pub fn cycle_internet_mode(settings: &mut Settings) {
settings.internet_mode = match settings.internet_mode {
InternetMode::Off => InternetMode::ReadOnly,
+13
View File
@@ -1,6 +1,19 @@
//! Todo-mode helper logic for the TUI todo-list overlay.
//!
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
//! the todo overlay.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// Toggle the todo-list overlay open or closed.
///
/// Flow: if the todo overlay is currently shown, hide it (set to `Overlay::None`);
/// otherwise show it.
///
/// Why: marks state dirty so the TUI re-renders on the next frame.
///
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
pub fn handle_todo_toggle(state: &mut AppStateRest) {
if state.misc.overlay == Overlay::Todo {
state.misc.overlay = Overlay::None;
+20
View File
@@ -1,6 +1,21 @@
//! Workflow-mode helper logic for the TUI workflow overlay.
//!
//! Flow: exposes a dismiss handler invoked by a keybinding to close the
//! workflow overlay, and a status query used elsewhere to check whether
//! it is currently showing.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// Close the workflow overlay if it is the currently active overlay.
///
/// Flow: check `state.misc.overlay == Overlay::Workflow`, reset to `Overlay::None`
/// if so, then mark state dirty regardless.
///
/// Why: no-ops safely if another overlay is showing, so it can be called
/// unconditionally from a dismiss keybinding.
///
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None;
@@ -8,6 +23,11 @@ pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
state.dirty = true;
}
/// Report whether the workflow overlay is currently displayed.
///
/// Flow: compare `state.misc.overlay` against `Overlay::Workflow`.
///
/// Return: `"active"` if the workflow overlay is shown, `"idle"` otherwise.
pub fn workflow_status(state: &AppStateRest) -> &str {
if state.misc.overlay == Overlay::Workflow {
"active"
+118
View File
@@ -1,3 +1,5 @@
//! Adaptive quality-review triggering, build/test probing, staleness
//! sweeps for stored lessons, and the pending-lesson approval workflow.
use std::process::Command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
@@ -7,6 +9,7 @@ use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
use serde::{Deserialize, Serialize};
/// How much trust a lesson's origin/verification warrants.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Confidence {
Human,
@@ -15,6 +18,7 @@ pub enum Confidence {
Auto,
}
/// Where a lesson sits in its life cycle, from freshly written to superseded.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonLifecycle {
New,
@@ -24,12 +28,14 @@ pub enum LessonLifecycle {
Superseded,
}
/// Whether a lesson applies to the current project only or globally.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonScope {
Project,
Global,
}
/// Records who/what produced a lesson and in which session/turn.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
pub session_turn: String,
@@ -37,6 +43,8 @@ pub struct Provenance {
pub reviewer: Origin,
}
/// A single learned fact/pattern surfaced by a review, prior to being
/// written to persistent memory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson {
pub name: String,
@@ -49,6 +57,18 @@ pub struct Lesson {
pub provenance: Provenance,
}
/// Decide whether an adaptive quality review should fire for this turn.
///
/// Flow: only `Origin::Main` turns are eligible → require review enabled
/// in settings → fire every 5th edit unconditionally → otherwise, once
/// `consecutive_empty_reviews` reaches `adaptive_review_max_skip` (min 2),
/// fire on an exponentially growing skip interval (2^n, capped at 2^10)
/// to avoid reviewing every single edit once reviews keep coming back empty.
///
/// Why: balances review usefulness against wasted subagent calls when
/// reviews consistently find nothing.
///
/// Return: `true` if a review should be triggered this turn.
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main {
@@ -75,6 +95,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
}
false
}
/// Outcome of running a build/test probe command against a workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub command: String,
@@ -82,6 +103,21 @@ pub struct ProbeResult {
pub output: String,
pub timed_out: bool,
}
/// Run a build/test verification command in the first workspace root and
/// capture its outcome, to back a review with a real pass/fail signal.
///
/// Flow: pick the first workspace → resolve the verify command (explicit
/// override or auto-detected via `resolve_verify_command`) → spawn it →
/// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses →
/// capture combined stdout+stderr (truncated) on completion.
///
/// Why: polling instead of a blocking wait lets the timeout be enforced
/// without spawning a watcher thread.
///
/// Return: `None` if no workspace exists, no command could be resolved,
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
/// describing pass/fail/timeout and truncated output.
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
@@ -131,6 +167,19 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
}
}
/// Determine the shell command to build/test a workspace, auto-detecting
/// the project type from marker files when no override is given.
///
/// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for
/// language/tool marker files (Cargo.toml, go.mod, package.json, etc.)
/// in priority order and return that ecosystem's conventional test/build
/// command.
///
/// Why: covers a broad set of ecosystems so review probing works without
/// per-project configuration in the common case.
///
/// Return: `Some(command)` if a command could be determined, `None` if
/// no marker files matched (e.g. plain Python project with no test dir).
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
@@ -227,6 +276,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
None
}
/// Truncate a string to at most `max` characters, appending a marker if cut.
///
/// Return: the original string if short enough, otherwise the first `max`
/// characters plus `"... (truncated)"`.
fn truncate_output(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
@@ -237,6 +290,22 @@ fn truncate_output(s: &str, max: usize) -> String {
}
}
/// Spawn a background quality-review subagent for the current session.
///
/// Flow: build a "quality-reviewer" subagent context → probe build/test
/// status via `probe_build_test` to give the reviewer a real pass/fail
/// signal → compose a system prompt embedding the probe result and lesson
/// tagging instructions → spawn a thread running `run_subagent` → on
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
/// line (or error) → push an "in progress" toast immediately.
///
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
/// async event loop; communicates its result back via `turn_events`
/// rather than a channel receiver (the `_rx` half is intentionally unused).
///
/// Return: `Ok(())` once the review has been kicked off; errors only
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let def = AgentDefinition::new(
"quality-reviewer".to_string(),
@@ -310,6 +379,14 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
const STALE_AFTER_DAYS: i64 = 60;
/// Flag memory entries as stale if they haven't been updated recently.
///
/// Flow: list all memory files → for each, read it → if `updated_at` is
/// older than `STALE_AFTER_DAYS` and it isn't already flagged, set
/// `lifecycle = "stale"` and write it back → collect flagged names.
///
/// Return: names of newly-flagged memories, or an I/O error from
/// `mem.write`.
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
let mut flagged = Vec::new();
let names = crate::model::memory::Memory::list(memory_dir);
@@ -327,6 +404,14 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
Ok(flagged)
}
/// Run the staleness sweep at most once every 10 minutes, notifying via toast.
///
/// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` →
/// otherwise update the timestamp and run `run_staleness_sweep`, pushing
/// an info toast listing flagged lessons if any were found.
///
/// Why: rate-limited so the sweep (a file read/write per memory) doesn't
/// run on every event-loop tick.
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis();
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
@@ -343,6 +428,8 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
}
}
/// A lesson awaiting confirmation before being committed to memory,
/// optionally auto-resolving after a grace period.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingLesson {
pub lesson: Lesson,
@@ -350,6 +437,10 @@ pub struct PendingLesson {
pub auto_resolve: bool,
}
/// Load the session's pending-lessons queue from disk.
///
/// Return: the parsed list, or an empty `Vec` if the file is missing or
/// fails to parse.
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
let path = session_dir.join("pending_lessons.json");
std::fs::read_to_string(&path)
@@ -358,12 +449,28 @@ pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson>
.unwrap_or_default()
}
/// Write the session's pending-lessons queue to disk as pretty JSON.
///
/// Return: `Ok(())`, or an I/O error from writing the file.
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
let path = session_dir.join("pending_lessons.json");
let data = serde_json::to_string_pretty(pending)?;
std::fs::write(&path, data)
}
/// Commit any auto-resolvable pending lessons whose grace period has
/// elapsed, and persist the remaining queue.
///
/// Flow: load pending lessons → partition into those eligible to commit
/// (`auto_resolve` and older than the 5s grace window) vs. still pending
/// → write eligible lessons as new `Memory` entries with `lifecycle:
/// "active"` → save the remaining (unresolved) queue back to disk.
///
/// Why: the grace window gives the user a brief window to reject an
/// auto-resolving lesson via `resolve_pending_lesson` before it commits.
///
/// Return: the still-pending lessons (post-commit), or an I/O error from
/// writing memory files or the queue.
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
@@ -399,6 +506,17 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
save_pending_lessons(session_dir, &remaining)?;
Ok(remaining)
}
/// Manually resolve a single pending lesson by name: commit it to memory
/// or discard it.
///
/// Flow: load the queue → find the lesson matching `lesson_name` →
/// if `keep` is true, write it as an active `Memory` entry; either way
/// remove it from the queue → save the remaining queue.
///
/// Why: lets the user (or UI action) override a pending lesson's fate
/// before/without waiting for the auto-resolve grace window.
///
/// Return: `Ok(())`, or an I/O error from writing the memory file or queue.
pub fn resolve_pending_lesson(
session_dir: &std::path::Path,
memory_dir: &std::path::Path,
+136 -5
View File
@@ -1,3 +1,21 @@
//! The `Action` enum and its single dispatcher, `apply_action` — the
//! chokepoint through which every key input, streaming event, and async
//! background-thread result mutates `AppStateRest`.
//!
//! Flow: controllers/subagent threads construct `Action` values → the event
//! loop calls `apply_action(&mut state, action)` → for turn-producing
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
//! execute tool calls via `Harness`, archive messages to SQLite, log edits)
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
//! usage counters).
//!
//! Why: keeping all state mutation behind one function means callers only
//! need to know how to *produce* actions, not how to update state safely;
//! running turns on plain OS threads (rather than blocking the main loop)
//! keeps the TUI responsive while the LLM streams.
use std::collections::VecDeque;
use crate::app::harness::Verdict;
@@ -9,11 +27,14 @@ use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{Origin, Overlay, Toast, ToastKind};
use crate::dto::chat::message::{ChatMessage, Role};
// Step bounds intentionally left unbounded (usize::MAX) so the agent can
// continue across as many turns as needed. Each iteration still honours
// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
// observable and cancellable from the UI.
/// A single, well-typed event in the app — produced by key input, the
/// streaming pipeline, or subagent threads — that mutates `AppStateRest`
/// when applied via `apply_action`.
///
/// Step bounds intentionally left unbounded (usize::MAX) so the agent can
/// continue across as many turns as needed. Each iteration still honours
/// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
/// observable and cancellable from the UI.
#[derive(Debug, Clone)]
pub enum Action {
ForceQuit,
@@ -63,6 +84,18 @@ pub enum Action {
AbortTurn,
}
/// Apply an `Action` to the application state.
///
/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll
/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) →
/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs
/// (staleness sweep, pending-lesson commit).
///
/// Why: the single chokepoint that turns every typed key and async event
/// into a state change, so callers (controllers, subagent threads) only
/// need to know how to *produce* actions.
///
/// Return: nothing; `state` is mutated in place.
pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action {
Action::ForceQuit => {
@@ -449,6 +482,19 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}
}
/// Spawn a background thread that runs one full LLM turn.
///
/// Flow: check that no turn is currently in-flight → bail if so →
/// collect messages and config from state → determine API key (from
/// settings, env var, or default) → resolve generation params from
/// the current effort level → collect all tools (built-in + MCP) →
/// build `TurnCtx` → spawn a thread running `run_agent_turn` →
/// on any error, push a `TurnEvent::Error` → clear the in-flight flag
/// when the thread exits.
///
/// Why: runs on a plain OS thread so the async event loop stays responsive.
///
/// Return: nothing; results flow through `state.turn_events`.
fn spawn_turn(state: &AppStateRest) {
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard
@@ -532,6 +578,7 @@ fn spawn_turn(state: &AppStateRest) {
});
}
/// Context bundle passed to `run_agent_turn` on its background thread.
struct TurnCtx {
client: crate::service::provider::LlmClient,
tdefs: Vec<crate::dto::provider::request::ToolDef>,
@@ -547,6 +594,14 @@ struct TurnCtx {
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
/// Build an ASCII tree of the workspace directory structure for the
/// system prompt, so the LLM can see the file layout.
///
/// Flow: for each root, walk using `ignore::WalkBuilder` (respecting
/// `.gitignore` and hidden files) → prefix `[DIR]` for directories →
/// truncate after 1000 entries.
///
/// Return: a formatted string with one entry per line.
fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let mut out = String::new();
out.push_str("Current Workspace Directory Structure:\n");
@@ -575,6 +630,11 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
out
}
/// Persist a `ChatMessage` to the SQLite message log, if a database
/// connection is available.
///
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
/// Errors are silently ignored.
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
if let Some(ref arc) = db {
if let Ok(conn) = arc.lock() {
@@ -583,6 +643,28 @@ fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connect
}
}
/// Execute one full agent turn: stream the conversation to the LLM,
/// handle tool calls, and loop until the LLM produces a non-tool response
/// or runs out of unfinished todo items.
///
/// Flow: build system prompt with workspace tree → optionally shape
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
/// and `Usage` events → on streaming success, handle tool calls (gated
/// through `Harness::gate_tool_call`) or unwrap the final assistant
/// message → check for unfinished todo.md tasks (auto-retry with a
/// system message if any remain) → finalise with `Done` and an `edits`
/// SystemNote.
///
/// On streaming failure: retry once with a non-streaming call → if that
/// also fails and there are unfinished tasks, sleep 5s and loop back;
/// otherwise return the error.
///
/// Why: non-streaming fallback handles flaky connections without aborting
/// the turn; todo.md polling lets the agent self-direct toward completeness.
///
/// Return: `Ok(())` on successful completion, or an error from the LLM
/// API after retries are exhausted.
fn run_agent_turn(
tc: TurnCtx,
messages: &[ChatMessage],
@@ -836,6 +918,20 @@ fn run_agent_turn(
Ok(())
}
/// Execute a single tool call: find the tool by name, snapshot the file
/// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for
/// write/edit, and return the output.
///
/// Flow: iterate tools → match by name → for write/edit, snapshot the
/// pre-existing file content into the blob store → call `tool.run()` →
/// for write/edit, compute SHA-256 of the new content and append an
/// `EditLogEntry` → return the tool output string.
///
/// Why: snapshots enable the rewind feature to restore previous content
/// after a write/edit.
///
/// Return: the tool's stdout string, or an error if no matching tool was
/// found or the tool run itself failed.
#[allow(clippy::too_many_arguments)]
fn execute_one_tool(
tools: &[Box<dyn crate::tool::Tool>],
@@ -910,6 +1006,15 @@ fn execute_one_tool(
anyhow::bail!("tool not found: {}", name)
}
/// Optionally push a review-available toast at the end of a turn that
/// performed edits.
///
/// Flow: skip if review is disabled → skip if `edit_count` is zero →
/// push an info toast listing the number of modified files.
///
/// Why: does not launch the review itself (that happens inside
/// `should_trigger_review` on `Tick`), only informs the user that
/// a review has material to examine.
fn maybe_trigger_review(state: &mut AppStateRest) {
if !state.settings.review_enabled {
return;
@@ -928,6 +1033,13 @@ fn maybe_trigger_review(state: &mut AppStateRest) {
));
}
/// Persist the current session metadata and conversation to disk.
///
/// Flow: build a `Session` object → save its metadata → write
/// `rt.messages` as JSON to the conversation file → errors are silently
/// ignored.
///
/// Why: called on `ForceQuit` so the session can be resumed later.
fn save_current_session(state: &AppStateRest) {
let base = state.store_base_dir();
let session = crate::model::session::Session::new(
@@ -943,6 +1055,20 @@ fn save_current_session(state: &AppStateRest) {
}
}
/// Run a browser-based OAuth PKCE flow for the given provider.
///
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
/// or a custom provider via env vars) → bind a loopback server → generate
/// a PKCE code verifier and challenge → build the authorisation URL →
/// wait for the redirect code on the loopback server (with a 120s timeout)
/// → exchange the code for a token → save the token to
/// `~/.config/zesdex/oauth_{provider}.json`.
///
/// Why: the `webbrowser::open` call is currently commented out; the user
/// must open the auth URL manually until that line is reinstated.
///
/// Return: a success message on completion, or an error if the flow fails
/// at any step.
fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
use crate::service::oauth::manager::{OAuthConfig, OAuthManager};
use crate::service::oauth::loopback::LoopbackServer;
@@ -1013,6 +1139,11 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
Ok(format!("Successfully authenticated with {}.", provider))
}
/// Generate `n` pseudo-random bytes from the current sub-second timestamp.
///
/// Why: avoids pulling in a full RNG crate for the OAuth state token;
/// sufficient for a nonce that only needs to be unpredictable over the
/// lifetime of a single OAuth flow.
fn rand_bytes(n: usize) -> Vec<u8> {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos();
+11
View File
@@ -1,7 +1,18 @@
//! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process.
use crate::controller::command::Command;
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
///
/// Flow: match each `Command` variant to its handler — most produce a
/// single `Action` (open an overlay, dispatch an OAuth flow, open the
/// editor, etc.); some produce an `Action::SystemNote` for errors or
/// informational responses.
///
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
/// by `apply_action`.
pub fn apply_command(command: Command) -> Vec<Action> {
match command {
Command::Help => {
+15
View File
@@ -1,3 +1,5 @@
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
//! after any activity, then slows down to conserve CPU.
use std::collections::VecDeque;
use std::time::{Duration, Instant};
@@ -7,12 +9,15 @@ const FAST_POLL_MS: u64 = 8;
const SLOW_POLL_MS: u64 = 100;
const IDLE_THRESHOLD_MS: u64 = 500;
/// Tracks whether the app has been active vs idle to adjust the TUI poll
/// rate, balancing responsiveness against CPU usage.
pub struct EventLoop {
last_activity: Instant,
fast_poll_until: Option<Instant>,
}
impl EventLoop {
/// Create an `EventLoop` with the current instant as the last activity.
pub fn new() -> Self {
EventLoop {
last_activity: Instant::now(),
@@ -20,6 +25,10 @@ impl EventLoop {
}
}
/// Return the appropriate polling delay based on activity state.
///
/// Flow: if `fast_poll_until` is set and the deadline hasn't expired,
/// return `FAST_POLL_MS`; otherwise return `SLOW_POLL_MS`.
pub fn poll_interval(&self) -> Duration {
if let Some(fast_until) = self.fast_poll_until {
if Instant::now() < fast_until {
@@ -29,15 +38,21 @@ impl EventLoop {
Duration::from_millis(SLOW_POLL_MS)
}
/// Mark the current time as the last activity and arm the fast-poll
/// window for the next `IDLE_THRESHOLD_MS`.
pub fn mark_active(&mut self) {
self.last_activity = Instant::now();
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS));
}
/// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`.
pub fn is_idle(&self) -> bool {
self.last_activity.elapsed().as_millis() as u64 > IDLE_THRESHOLD_MS
}
/// Drain all pending `TurnEvent`s from the shared mutex queue.
///
/// Return: a `Vec` of all events that were in the queue (may be empty).
pub fn drain_events(
events: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> Vec<TurnEvent> {
+2
View File
@@ -1,3 +1,5 @@
//! Runtime layer: action dispatch, slash commands, short-send handling,
//! and the LLM streaming pipeline.
pub mod actions;
pub mod commands;
pub mod shortsend;
+24
View File
@@ -1,9 +1,20 @@
//! Short-send / message shaping: compacts long conversation histories so
//! they fit within the provider's context window before being sent to the
//! LLM API.
use crate::dto::chat::message::ChatMessage;
const MAX_WIRE_TOKENS: usize = 2_000_000;
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
const ENGAGE_HYSTERESIS: usize = 5;
/// Decide whether the message list should be shaped (compacted) before
/// sending to the LLM.
///
/// Flow: skip shaping if fewer than `MIN_MESSAGES_BEFORE_SHAPE` messages
/// → once past that threshold, use hysteresis (require 5 more messages
/// before re-engaging if shaping is currently active) to avoid oscillation.
///
/// Return: `true` if shaping should be applied.
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
return false;
@@ -16,6 +27,19 @@ pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
total_messages >= threshold
}
/// Compact a long message list by dropping middle messages and inserting
/// a summary placeholder.
///
/// Flow: if the estimated token count is within budget, return messages
/// unchanged → otherwise keep the system message and the most recent
/// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior
/// conversation compacted]` system message in between.
///
/// Why: keeps context-size overhead roughly constant regardless of
/// session length.
///
/// Return: a new Vec<ChatMessage> that preserves the first message and
/// the tail.
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
return messages.to_vec();
+37
View File
@@ -1,9 +1,12 @@
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn;
pub mod tools;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One atomic event extracted from an LLM streaming response stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamEvent {
Token(String),
@@ -23,6 +26,8 @@ pub enum StreamEvent {
Error(String),
}
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser {
buffer: String,
event_type: Option<String>,
@@ -30,6 +35,7 @@ pub struct SseParser {
}
impl SseParser {
/// Create a new parser with an empty buffer.
pub fn new() -> Self {
SseParser {
buffer: String::new(),
@@ -38,6 +44,17 @@ impl SseParser {
}
}
/// Feed a raw SSE chunk and produce any completed events.
///
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
/// blank line, call `flush_event` to parse the accumulated data →
/// on `event:` line, store the event type → on `data:` line, append
/// to data accumulator → continue until buffer exhausted.
///
/// Edge case: a chunk may split mid-line; the remainder stays in the
/// buffer for the next `feed()` call.
///
/// Return: all `StreamEvent`s completed by this chunk.
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
self.buffer.push_str(chunk);
let mut events = Vec::new();
@@ -57,6 +74,19 @@ impl SseParser {
events
}
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
///
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
/// emit `Usage` if a usage object is present → else match `event_type`
/// ("message.stop", "message.delta", etc.) → extract content,
/// reasoning, tool-call deltas, or finish-reason from the delta
/// structure (supporting both Anthropic-style top-level delta and
/// OpenAI-style `choices` array).
///
/// Why: dual-format support in one method avoids a separate
/// provider-specific parsing layer.
///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n");
self.data_lines.clear();
@@ -179,6 +209,13 @@ impl SseParser {
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
///
/// Flow: parse `data` as JSON → extract first `choices[0].delta` →
/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based
/// on the fields present.
///
/// Return: `Some(StreamEvent)` if the chunk contained recognisable
/// content, `None` otherwise.
#[allow(dead_code)]
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
let value: Value = serde_json::from_str(data).ok()?;
+23
View File
@@ -1,3 +1,16 @@
//! Standalone accumulator for streamed tool-call deltas.
//!
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
//! every accumulated call has both a name and arguments.
//!
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
//! but as an independent, reusable type for callers that want to track
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
//! use case.
use super::turn::ParsedToolCall;
use serde_json::{json, Value};
@@ -11,10 +24,15 @@ pub struct ToolCallAccumulator {
#[allow(dead_code)]
impl ToolCallAccumulator {
/// Construct an empty accumulator with no tool calls tracked yet.
///
/// Return: a fresh `ToolCallAccumulator`.
pub fn new() -> Self {
ToolCallAccumulator { calls: Vec::new() }
}
/// Append a delta to the tool call at the given index, growing the
/// calls vector if needed.
pub fn add_delta(
&mut self,
index: usize,
@@ -44,18 +62,23 @@ impl ToolCallAccumulator {
tc.arguments.push_str(arguments_delta);
}
/// Borrow the accumulated tool calls.
pub fn calls(&self) -> &[ParsedToolCall] {
&self.calls
}
/// Return true once all tool calls have both a name and arguments.
pub fn is_complete(&self) -> bool {
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty())
}
/// Clear all accumulated calls (starting a fresh turn).
pub fn reset(&mut self) {
self.calls.clear();
}
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
/// tool calls, suitable for downstream inspection or replay.
pub fn pending_args(&self) -> Vec<Value> {
self.calls
.iter()
+26 -3
View File
@@ -1,9 +1,14 @@
//! Accumulates streaming LLM responses into complete message/tool-call
//! representation via `StreamedTurn`, and provides a standalone tool-call
//! accumulator in `tools::ToolCallAccumulator`.
use super::StreamEvent;
use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Accumulates a single streaming assistant turn into its final
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamedTurn {
pub messages: Vec<ChatMessage>,
@@ -13,6 +18,7 @@ pub struct StreamedTurn {
pub accumulated_reasoning: String,
}
/// A single tool call being built up from streaming deltas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedToolCall {
pub id: String,
@@ -22,9 +28,11 @@ pub struct ParsedToolCall {
}
impl ParsedToolCall {
/// Attempts to parse the accumulated argument string as JSON before the tool call is
/// marked complete — useful for callers that want a speculative preview mid-stream.
/// `build_assistant_message` does its own (lossy-fallback) parse for the final message.
/// Attempt to parse the accumulated argument string as JSON before
/// the tool call is marked complete — useful for a speculative preview.
///
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
/// if still partial.
#[allow(dead_code)]
pub fn try_parse(&self) -> Option<Value> {
serde_json::from_str(&self.arguments).ok()
@@ -32,6 +40,7 @@ impl ParsedToolCall {
}
impl StreamedTurn {
/// Create an empty turn accumulator.
pub fn new() -> Self {
StreamedTurn {
messages: Vec::new(),
@@ -42,6 +51,12 @@ impl StreamedTurn {
}
}
/// Apply a `StreamEvent` to the turn, updating accumulated content,
/// reasoning, and tool-call deltas.
///
/// Flow: match on variant — `Token` appends to `accumulated_content`,
/// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or
/// grows the `tool_calls` vector, `Done` sets `is_complete = true`.
pub fn apply_event(&mut self, event: &StreamEvent) {
match event {
StreamEvent::Token(token) => {
@@ -84,6 +99,14 @@ impl StreamedTurn {
}
}
/// Finalise the turn into a `ChatMessage`, combining accumulated
/// reasoning (wrapped in `<think>` tags) with content and tool calls.
///
/// Flow: if tool calls exist, build a `ChatMessage` with `tool_calls`
/// set; otherwise build a plain assistant message → set `content` to
/// the combined reasoning+content string (or `None` if empty).
///
/// Return: a complete `ChatMessage` with role `Assistant`.
pub fn build_assistant_message(&self) -> ChatMessage {
let mut msg = if self.tool_calls.is_empty() {
ChatMessage::assistant(None)
+19
View File
@@ -1,10 +1,16 @@
//! Shallow state diffing — records opaque "modified" markers so the TUI
//! knows to re-render without computing fine-grained deltas.
use serde::{Deserialize, Serialize};
/// A collection of changes tracking which parts of app state have been
/// modified since the last render sweep.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDiff {
changes: Vec<Change>,
}
/// A single named change — currently always carries a flat `"."` path
/// and `"modified"` kind because the system does not track granular diffs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change {
pub path: String,
@@ -12,23 +18,36 @@ pub struct Change {
}
impl StateDiff {
/// Create an empty diff.
pub fn new() -> Self {
StateDiff { changes: Vec::new() }
}
/// Record a change at `path` of the given `kind`.
pub fn add_change(&mut self, path: String, kind: String) {
self.changes.push(Change { path, kind });
}
/// Return true if no changes have been recorded.
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
/// Remove all recorded changes.
pub fn clear(&mut self) {
self.changes.clear();
}
}
/// Compute a shallow diff between two serialised state values.
///
/// Flow: compare with `==`, return an empty vec if equal, otherwise
/// return a single `Change { ".", "modified" }`.
///
/// Why: a placeholder — the current rendering model re-validates the
/// whole viewport every frame, so fine-grained diffs are unnecessary.
///
/// Return: the list of changes (always 0 or 1 entry).
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
if before == after {
return Vec::new();
+49
View File
@@ -1,26 +1,33 @@
//! Application-level "miscellaneous" state: scroll, input buffer,
//! overlay stack, toasts, editor, and autocomplete.
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::types::Overlay;
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
#[derive(Clone)]
pub struct DirCache {
entries: Arc<RwLock<Vec<PathBuf>>>,
}
impl DirCache {
/// Create an empty `DirCache`.
pub fn new() -> Self {
DirCache {
entries: Arc::new(RwLock::new(Vec::new())),
}
}
/// Replace the cached entries (async write).
pub async fn set(&self, paths: Vec<PathBuf>) {
let mut w = self.entries.write().await;
*w = paths;
}
}
/// Manages the viewport scroll offset.
#[derive(Debug, Clone)]
pub struct ScrollState {
pub offset: usize,
@@ -28,6 +35,7 @@ pub struct ScrollState {
}
impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self {
ScrollState {
offset: 0,
@@ -35,19 +43,25 @@ impl ScrollState {
}
}
/// Scroll the viewport up by `amount` lines (increasing the offset).
/// Scroll the viewport up by `amount` lines (increasing the offset).
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
}
/// Scroll the viewport down by `amount` lines (decreasing the offset).
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
/// Update the maximum number of visible lines.
pub fn set_max_visible(&mut self, max: usize) {
self.max_visible = max;
}
}
/// The user's input buffer, cursor position, history, and autocomplete
/// state for the chat prompt.
#[derive(Debug, Clone)]
pub struct InputState {
pub buffer: String,
@@ -81,6 +95,8 @@ const COMMANDS: &[&str] = &[
];
impl InputState {
/// Create an empty input state with no buffer, no history, and no
/// autocomplete.
pub fn new() -> Self {
InputState {
buffer: String::new(),
@@ -94,6 +110,7 @@ impl InputState {
}
}
/// Hide the autocomplete dropdown and clear its state.
pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
@@ -101,6 +118,12 @@ impl InputState {
self.autocomplete_idx = 0;
}
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`
/// against the current buffer prefix.
///
/// Flow: if buffer is empty or doesn't start with `/`, close and return
/// → filter `COMMANDS` by prefix match → store candidates → set
/// `autocomplete_visible` if any candidates found.
pub fn open_autocomplete(&mut self) {
let trimmed = self.buffer.trim().to_string();
if trimmed.is_empty() || !trimmed.starts_with('/') {
@@ -119,6 +142,8 @@ impl InputState {
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Move the autocomplete selection up (forward=false) or down (forward=true).
/// Wraps around at the boundaries.
pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len();
if n == 0 { return; }
@@ -129,6 +154,10 @@ impl InputState {
}
}
/// Accept the currently selected autocomplete candidate, placing it
/// in the buffer and closing the dropdown.
///
/// Return: `true` if a candidate was selected, `false` if none existed.
pub fn select_autocomplete(&mut self) -> bool {
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
self.buffer = candidate.clone();
@@ -140,6 +169,8 @@ impl InputState {
}
}
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
/// then cycles forward on subsequent presses.
pub fn tab_complete(&mut self) {
// Legacy inline tab-complete — used as a fallback when the dropdown
// isn't visible yet. Opens the dropdown on the first Tab press.
@@ -150,23 +181,27 @@ impl InputState {
}
}
/// Move the cursor left by one character (if not at the start).
pub fn char_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
/// Move the cursor right by one character (if not at the end).
pub fn char_right(&mut self) {
if self.cursor < self.buffer.len() {
self.cursor += 1;
}
}
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += 1;
}
/// Delete the character to the left of the cursor (backspace).
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
@@ -174,12 +209,17 @@ impl InputState {
}
}
/// Delete the character at the cursor position (forward delete).
pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
}
}
/// Submit the current buffer: push it to history, clear the buffer,
/// and return the submitted text.
///
/// Return: the text that was in the buffer before clearing.
pub fn submit(&mut self) -> String {
let result = self.buffer.clone();
if !result.is_empty() {
@@ -191,6 +231,7 @@ impl InputState {
result
}
/// Navigate backward through input history.
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
@@ -205,6 +246,7 @@ impl InputState {
self.cursor = self.buffer.len();
}
/// Navigate forward through input history (back toward the newest entry).
pub fn history_down(&mut self) {
match self.history_idx {
Some(i) if i < self.history.len() - 1 => {
@@ -223,6 +265,8 @@ impl InputState {
}
}
/// The "miscellaneous" slice of app state: which overlay is showing,
/// toasts, thinking/connected flags, effort level, editor state, and tick.
#[derive(Debug, Clone)]
pub struct MiscState {
pub overlay: Overlay,
@@ -237,6 +281,8 @@ pub struct MiscState {
}
impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts, and default
/// effort level 1.
pub fn new() -> Self {
MiscState {
overlay: Overlay::None,
@@ -255,6 +301,9 @@ impl MiscState {
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
///
/// Return: the expired toasts (after removal).
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
self.toasts.retain(|t| !t.expired(now_ms));
+2
View File
@@ -1,3 +1,5 @@
//! Application state: misc fields, the main `AppStateRest` struct,
//! runtime-only state, and shared types (overlays, toasts, origins).
pub mod misc;
pub mod rest;
pub mod runtime;
+39
View File
@@ -1,3 +1,9 @@
//! Top-level mutable application state (`AppStateRest`) and the transcript
//! display type it owns.
//!
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
//! `actions/mod.rs` and `controller/input.rs`; every other module reads it.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
@@ -12,6 +18,7 @@ use crate::model::app_config::AppConfig;
use crate::model::editlog::EditLog;
use crate::model::settings::Settings;
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
pub role: crate::dto::chat::message::Role,
@@ -20,6 +27,7 @@ pub struct ChatMessageDisplay {
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
ChatMessageDisplay {
role,
@@ -29,6 +37,11 @@ impl ChatMessageDisplay {
}
}
/// The single source-of-truth state struct for the entire application.
///
/// Mutated in-place from two locations: `actions/mod.rs` (`apply_action`)
/// and `controller/input.rs` (key event handlers). Read-only from every
/// other module.
#[derive(Clone)]
pub struct AppStateRest {
@@ -58,6 +71,15 @@ pub struct AppStateRest {
}
impl AppStateRest {
/// Construct the initial application state for a session.
///
/// Flow: load settings/config -> derive download/worktree dirs from
/// `memory_dir`'s parent -> derive `session_id` from the session dir's
/// file name -> build the sub-state structs.
///
/// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails.
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
let settings = Settings::load();
let app_config = AppConfig::load();
@@ -105,6 +127,10 @@ impl AppStateRest {
}
}
/// Whether an agent turn is currently running.
///
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
/// than propagating a panic.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned");
@@ -114,6 +140,8 @@ impl AppStateRest {
/// Append a message to the transcript, evicting the oldest entry once
/// `max_lines` is exceeded, and mark both the cache and the app dirty.
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
@@ -123,11 +151,19 @@ impl AppStateRest {
self.dirty = true;
}
/// Queue a toast notification for display and mark the app dirty.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.dirty = true;
}
/// Resolve the base directory that stores this session (grandparent of
/// `session_dir`, i.e. the sessions root, not the individual session
/// folder).
///
/// Why: falls back progressively -- grandparent, then parent, then
/// `session_dir` itself -- logging a warning at each step down, so this
/// never fails even on a shallow path.
pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir.parent()
.and_then(|p| p.parent())
@@ -143,10 +179,13 @@ impl AppStateRest {
})
}
/// Build a `ToolCtx` for tool calls originating from the main agent.
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
self.tool_ctx_for(Origin::Main)
}
/// Build a `ToolCtx` scoped to the given call origin (main, subagent,
/// reviewer), copying workspace/session/memory paths from state.
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
crate::tool::ToolCtx {
workspaces: self.workspace_roots.clone(),
+18
View File
@@ -1,6 +1,11 @@
//! Per-session runtime state: message history, pending tool queue,
//! background bash jobs, lesson/review counters, and the `TurnEvent`
//! stream emitted while an agent turn is in flight.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub tokens_in: u64,
@@ -10,6 +15,9 @@ pub struct UsageStats {
pub total_ms: u64,
}
/// Mutable, serializable state for one session: chat history, tool
/// results, pending tools, background jobs, and lesson/review counters
/// shown in the TUI status bar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRuntime {
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
@@ -36,6 +44,7 @@ pub struct SessionRuntime {
pub usage: UsageStats,
}
/// Record of one completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult {
pub tool_call_id: String,
@@ -45,6 +54,8 @@ pub struct ToolCallResult {
pub duration_ms: u64,
}
/// A tool call awaiting execution, along with which execution model
/// (inline, deferred, async) it should run under.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingTool {
pub tool_name: String,
@@ -52,6 +63,8 @@ pub struct PendingTool {
pub execution_model: crate::app::state::types::ExecutionModel,
}
/// Reference to a background bash job tracked in session state (the actual
/// process handle lives elsewhere; this is just the display/status record).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BashJobRef {
pub id: String,
@@ -60,6 +73,8 @@ pub struct BashJobRef {
pub running: bool,
}
/// Events emitted onto the turn-event queue while an agent turn runs,
/// consumed by the event loop to update state and drive re-renders.
#[derive(Debug, Clone)]
pub enum TurnEvent {
AssistantMessage(crate::dto::chat::message::ChatMessage),
@@ -86,6 +101,8 @@ pub enum TurnEvent {
}
impl SessionRuntime {
/// Create fresh runtime state for a session rooted at `session_dir`,
/// with all counters zeroed and `session_start` set to now.
pub fn new(session_dir: PathBuf) -> Self {
SessionRuntime {
messages: Vec::new(),
@@ -113,6 +130,7 @@ impl SessionRuntime {
}
}
/// Append a message to the session's conversation history.
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
self.messages.push(msg);
}
+11
View File
@@ -1,11 +1,16 @@
//! Opaque, serializable snapshot of application state used for
//! attach/daemon IPC transfer.
use serde::{Deserialize, Serialize};
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot {
pub snapshot: serde_json::Value,
}
impl StateSnapshot {
/// Create an empty snapshot (`{}`).
pub fn new() -> Self {
StateSnapshot {
snapshot: serde_json::json!({}),
@@ -13,10 +18,16 @@ impl StateSnapshot {
}
}
/// Serialize a snapshot to bytes for transport over the daemon socket.
///
/// Return: JSON-encoded bytes, or a serde error.
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(snapshot)?)
}
/// Parse a snapshot previously produced by `serialize_snapshot`.
///
/// Return: the decoded `StateSnapshot`, or a serde error.
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
Ok(serde_json::from_slice(data)?)
}
+16
View File
@@ -1,6 +1,10 @@
//! Shared small state types: toasts, overlays, the transcript cache,
//! tool execution model, and call origin tags.
use serde::{Deserialize, Serialize};
/// Severity/category of a toast notification, used to pick its color.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind {
Info,
@@ -10,6 +14,8 @@ pub enum ToastKind {
Lesson,
}
/// A transient status message shown in the TUI, auto-dismissed after
/// `lifetime_ms`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Toast {
pub kind: ToastKind,
@@ -19,6 +25,7 @@ pub struct Toast {
}
impl Toast {
/// Create a toast with a default 5-second lifetime, stamped with now.
pub fn new(kind: ToastKind, message: String) -> Self {
Toast {
kind,
@@ -28,11 +35,13 @@ impl Toast {
}
}
/// Whether this toast's lifetime has elapsed as of `now_ms`.
pub fn expired(&self, now_ms: i64) -> bool {
now_ms - self.created_at > self.lifetime_ms as i64
}
}
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
None,
@@ -56,11 +65,13 @@ pub enum Overlay {
}
impl Overlay {
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone, PartialEq)]
pub struct TranscriptCache {
pub messages: Vec<super::rest::ChatMessageDisplay>,
@@ -69,6 +80,7 @@ pub struct TranscriptCache {
}
impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self {
TranscriptCache {
messages: Vec::new(),
@@ -78,6 +90,7 @@ impl TranscriptCache {
}
}
/// How a pending tool call should be executed when the turn resumes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExecutionModel {
Inline,
@@ -85,6 +98,8 @@ pub enum ExecutionModel {
AsyncTokio,
}
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
/// invoking a tool, used to scope permissions and tag log/output paths.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum Origin {
Main,
@@ -93,6 +108,7 @@ pub enum Origin {
}
impl Origin {
/// Short string tag for this origin, used in filenames and logs.
pub fn tag(&self) -> String {
match self {
Origin::Main => "main".to_string(),
+14
View File
@@ -1,8 +1,14 @@
//! Construction of a `SubagentContext` from an `AgentDefinition`,
//! including the default read-only tool set for reviewer agents.
use std::path::PathBuf;
use super::spawn::AgentDefinition;
/// Default read-only tool names granted to `role == "reviewer"` agents.
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
/// Per-invocation configuration for a subagent: prompt, allowed tools,
/// step budget, and the session directory it should operate against.
pub struct SubagentContext {
pub system_prompt: String,
pub allowed_tools: Vec<String>,
@@ -10,6 +16,14 @@ pub struct SubagentContext {
pub session_dir: PathBuf,
}
/// Build a `SubagentContext` from an `AgentDefinition`.
///
/// Flow: copy optional `allowed_tools` from the def -> fall back to the
/// reviewer-allowlist when the def has none and the role is "reviewer" ->
/// fall back to an empty list (i.e. "all tools allowed") for other roles.
///
/// Return: a context with empty `system_prompt` and `session_dir`,
/// `max_steps = 25`, and the resolved allowed-tool list.
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
if def.role == "reviewer" {
+38 -5
View File
@@ -1,3 +1,7 @@
//! Subagent execution loop: drive an LLM conversation, gate tool calls
//! against the context's allowlist, run tools, and stream progress events
//! to the parent via an mpsc channel.
use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef;
@@ -5,12 +9,20 @@ use crate::tool::{all_tools, tool_defs, tool_is_risky};
use super::context::SubagentContext;
use super::event::SubagentEvent;
/// Upper bound on agent loop steps; effectively unbounded (`usize::MAX`).
#[allow(dead_code)]
pub const MAX_AGENT_STEPS: usize = usize::MAX;
/// Maps a subagent's allowed tool names to concrete Tool trait objects and
/// OpenAI-style tool definitions. When `allowed_tools` is empty every tool is
/// available; otherwise only explicitly allowed ones are included.
/// OpenAI-style tool definitions.
///
/// Flow: load `all_tools()` → if `allowed_tools` is empty, use all; else
/// filter by membership → derive `ToolDef`s for the LLM.
///
/// Why: an empty allowlist means "no restriction" (matches
/// `build_subagent_context`'s default for non-reviewer roles).
///
/// Return: `(tool impls, schema defs)` for the subagent to use.
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
@@ -24,9 +36,16 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::T
(filtered, defs)
}
/// Resolves the API key, model, and base URL from the persisted application
/// configuration rather than environment variables, matching how the main agent
/// resolves its credentials.
/// Resolve the API key, model, and base URL from persisted app config.
///
/// Flow: try the settings key for the active provider → fall back to the
/// provider's `api_key_env` env-var → fall back to the provider's
/// `default_api_key` → fall back to an empty string.
///
/// Why: matches the main agent's credential resolution exactly, so
/// subagents automatically inherit the same provider settings.
///
/// Return: `(api_key, model, optional_base_url)`.
fn resolve_provider_config() -> (String, String, Option<String>) {
let settings = crate::model::settings::Settings::load();
let app_config = crate::model::app_config::AppConfig::load();
@@ -54,6 +73,20 @@ fn resolve_provider_config() -> (String, String, Option<String>) {
(api_key, model, base_url)
}
/// Synchronous subagent entry point: run up to `ctx.max_steps` iterations
/// of the LLM tool loop.
///
/// Flow: inject system prompt → for each step: resolve provider config,
/// build an LLM client, call `chat_with_tools_non_streaming`, process tool
/// calls or collect text output → send `SubagentEvent`s on `tx` → break on
/// first text-only (non-empty) response.
///
/// Why: runs synchronously on a dedicated thread so the main async event
/// loop is not blocked. Tool gating prevents restricted or risky tools from
/// executing unless explicitly allowed.
///
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
/// call fails at any step.
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
let mut output = String::new();
let mut messages: Vec<ChatMessage> = Vec::new();
+5
View File
@@ -1,5 +1,10 @@
//! Event variants that a running subagent can emit to its parent via the
//! shared mpsc channel.
use serde_json::Value;
/// Progress and outcome events emitted by `run_subagent` as it processes
/// LLM responses and tool calls.
#[derive(Debug, Clone)]
pub enum SubagentEvent {
StepCompleted {
+3
View File
@@ -1,3 +1,6 @@
//! Subagent management: spawning, context building, engine loop, and
//! progress events.
pub mod context;
pub mod engine;
pub mod event;
+8
View File
@@ -1,5 +1,10 @@
//! AgentDefinition -- declarative specification for instantiating a
//! subagent from workflow scripts or programmatic calls.
use serde::{Deserialize, Serialize};
/// Declarative specification for instantiating a subagent: name, role,
/// optional system prompt, allowed tools, step budget, and temperature.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
pub name: String,
@@ -11,6 +16,8 @@ pub struct AgentDefinition {
}
impl AgentDefinition {
/// Create an agent definition with the required name and role; all
/// optional fields start as `None`.
pub fn new(name: String, role: String) -> Self {
AgentDefinition {
name,
@@ -22,6 +29,7 @@ impl AgentDefinition {
}
}
/// Builder method: limit this agent to at most `steps` LLM calls.
pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps);
self
+50
View File
@@ -1,3 +1,7 @@
//! Workflow engine: interprets `ScriptPrimitive` values (agent, parallel,
//! pipeline, phase) by spawning subagents, collecting results, and
//! managing concurrency.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
@@ -5,6 +9,7 @@ use super::script::{ScriptPrimitive, WorkflowScript};
static FINDINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The lifecycle state of an agent within a workflow run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentState {
Idle,
@@ -13,6 +18,7 @@ pub enum AgentState {
Failed,
}
/// Timestamped status of one workflow agent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatus {
pub state: AgentState,
@@ -21,6 +27,7 @@ pub struct AgentStatus {
pub error: Option<String>,
}
/// A single agent tracked within a workflow run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowAgent {
pub id: String,
@@ -28,6 +35,8 @@ pub struct WorkflowAgent {
pub status: AgentStatus,
}
/// Orchestrator for running workflow scripts: holds agent roster and a
/// shared finding accumulator visible to all pipeline stages.
#[derive(Debug, Clone)]
pub struct WorkflowEngine {
pub agents: Vec<WorkflowAgent>,
@@ -35,6 +44,7 @@ pub struct WorkflowEngine {
}
impl WorkflowEngine {
/// Create an empty workflow engine with no agents or findings.
pub fn new() -> Self {
WorkflowEngine {
agents: Vec::new(),
@@ -43,6 +53,14 @@ impl WorkflowEngine {
}
}
/// Spawn a single synchronous subagent with the given prompt, passing it
/// any findings from earlier sibling agents.
///
/// Flow: build an `AgentDefinition` -> build a `SubagentContext` ->
/// inject findings into the system prompt -> call `run_subagent` on a
/// dedicated mpsc channel.
///
/// Return: the agent's text output, or an error on failure.
fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::Result<String> {
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
@@ -74,6 +92,20 @@ fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::R
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
/// concurrency cap for parallel branches.
///
/// Flow: match the primitive ->
/// `Agent` -> `spawn_single_agent`
/// `Parallel` -> spawn threads up to `concurrency_cap`, join
/// `Pipeline` -> spawn threads sequentially, collect in order
/// `Phase` -> recurse (pass-through wrapper)
///
/// Why: parallelism is implemented with `std::thread::spawn` and a
/// counting semaphore so the main async event loop remains unblocked.
///
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted.
pub fn execute_primitive(
primitive: &ScriptPrimitive,
args: &HashMap<String, String>,
@@ -169,6 +201,14 @@ pub fn execute_primitive(
}
}
/// Run a `WorkflowScript` with the given template arguments and produce a
/// summary string.
///
/// Flow: clear the global finding store -> cap concurrency to 5 -> call
/// `execute_primitive` on the script's root primitive -> format results
/// into a one-line-per-agent summary.
///
/// Return: a human-readable summary string.
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
if let Ok(mut findings) = FINDINGS.lock() {
findings.clear();
@@ -201,12 +241,19 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
Ok(summary)
}
/// Add a finding text to the global workflow findings list, making it
/// visible to sibling agents spawned later in the same run.
pub fn note_finding(text: &str) {
if let Ok(mut findings) = FINDINGS.lock() {
findings.push(text.to_string());
}
}
/// Simple template engine: replace `{{key}}` placeholders with values
/// from `args`.
///
/// Why: a structed template engine is unnecessary for the limited
/// use-case; this is intentionally simple and safe.
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in args {
@@ -215,6 +262,9 @@ fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
result
}
/// A counting semaphore built from a `Mutex` + `Condvar`.
///
/// Used by `execute_primitive` to cap concurrent parallel branches.
struct Semaphore {
count: Mutex<usize>,
condvar: std::sync::Condvar,
+3
View File
@@ -1,2 +1,5 @@
//! Workflow orchestration: a script interpreter that runs pipeline/parallel
//! primitives across multiple subagent instances.
pub mod engine;
pub mod script;
+12
View File
@@ -1,16 +1,27 @@
//! Script primitives for the workflow engine: agent invocation, parallel
//! execution, pipelines, and phases.
use serde::{Deserialize, Serialize};
/// A workflow script primitive — can be a single agent, a parallel fan-out,
/// a sequential pipeline, or a named phase.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ScriptPrimitive {
/// Run a single agent with the given prompt template.
Agent(String),
/// Execute several primitives concurrently.
Parallel(Vec<ScriptPrimitive>),
/// Execute several primitives sequentially, each waiting for the
/// previous to complete.
Pipeline(Vec<ScriptPrimitive>),
/// A named wrapper around another primitive (used for display/tracing).
Phase {
name: String,
script: Box<ScriptPrimitive>,
},
}
/// Runtime options for a workflow execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptOptions {
pub max_concurrency: usize,
@@ -28,6 +39,7 @@ impl Default for ScriptOptions {
}
}
/// A named, versioned workflow script with its primitives and options.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowScript {
pub name: String,