diff --git a/src/app/bgbash/control.rs b/src/app/bgbash/control.rs index 6a6e90e..cd607bc 100644 --- a/src/app/bgbash/control.rs +++ b/src/app/bgbash/control.rs @@ -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>` (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>`, created on +/// first access. pub(crate) fn bash_jobs_map() -> &'static Mutex> { static JOBS: OnceLock>> = 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> { 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> { 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); diff --git a/src/app/bgbash/job.rs b/src/app/bgbash/job.rs index f442409..12bb9b7 100644 --- a/src/app/bgbash/job.rs +++ b/src/app/bgbash/job.rs @@ -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:` 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, } +/// Spawn a shell command in a background thread and return a handle to it. +/// +/// Flow: spawn a thread → thread execs `sh -c ` 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:` 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::(); @@ -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:` 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 { match self.output_rx.try_recv() { Ok(line) => { diff --git a/src/app/bgbash/mod.rs b/src/app/bgbash/mod.rs index c29f168..e411786 100644 --- a/src/app/bgbash/mod.rs +++ b/src/app/bgbash/mod.rs @@ -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; diff --git a/src/app/harness.rs b/src/app/harness.rs index 161042a..9f341f3 100644 --- a/src/app/harness.rs +++ b/src/app/harness.rs @@ -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, diff --git a/src/app/mcp/manager.rs b/src/app/mcp/manager.rs index 9cc4836..ce26ced 100644 --- a/src/app/mcp/manager.rs +++ b/src/app/mcp/manager.rs @@ -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>>, } +/// 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 { self.next_id += 1; let id = self.next_id; @@ -255,11 +278,14 @@ fn extract_text_content(result: &Value) -> anyhow::Result { })) } +/// Registry of connected MCP servers and their tools for the current session. #[derive(Debug, Clone)] pub struct McpManager { pub servers: Vec, } +/// 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> { self.servers.iter().flat_map(|server| { let handle = server.child_handle.clone(); diff --git a/src/app/mcp/mod.rs b/src/app/mcp/mod.rs index ff8de9e..781c1e6 100644 --- a/src/app/mcp/mod.rs +++ b/src/app/mcp/mod.rs @@ -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; diff --git a/src/app/mod.rs b/src/app/mod.rs index 87f05ae..cf8cd3d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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; diff --git a/src/app/mode/bash.rs b/src/app/mode/bash.rs index 30ecb49..77eca8a 100644 --- a/src/app/mode/bash.rs +++ b/src/app/mode/bash.rs @@ -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); diff --git a/src/app/mode/editor.rs b/src/app/mode/editor.rs index c006072..7f646f2 100644 --- a/src/app/mode/editor.rs +++ b/src/app/mode/editor.rs @@ -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>) -> 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; diff --git a/src/app/mode/effort.rs b/src/app/mode/effort.rs index 09d5dbf..a92c5a6 100644 --- a/src/app/mode/effort.rs +++ b/src/app/mode/effort.rs @@ -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(); diff --git a/src/app/mode/help.rs b/src/app/mode/help.rs index ca69b17..635d051 100644 --- a/src/app/mode/help.rs +++ b/src/app/mode/help.rs @@ -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, diff --git a/src/app/mode/key_input.rs b/src/app/mode/key_input.rs index 1cd8679..2b15813 100644 --- a/src/app/mode/key_input.rs +++ b/src/app/mode/key_input.rs @@ -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; diff --git a/src/app/mode/loading.rs b/src/app/mode/loading.rs index 384ddc7..43baedd 100644 --- a/src/app/mode/loading.rs +++ b/src/app/mode/loading.rs @@ -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; } diff --git a/src/app/mode/mcp.rs b/src/app/mode/mcp.rs index 8a61fa4..718fa1c 100644 --- a/src/app/mode/mcp.rs +++ b/src/app/mode/mcp.rs @@ -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; diff --git a/src/app/mode/mod.rs b/src/app/mode/mod.rs index df28faf..4d62424 100644 --- a/src/app/mode/mod.rs +++ b/src/app/mode/mod.rs @@ -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, diff --git a/src/app/mode/quit_confirm.rs b/src/app/mode/quit_confirm.rs index d8e822a..125a81b 100644 --- a/src/app/mode/quit_confirm.rs +++ b/src/app/mode/quit_confirm.rs @@ -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 diff --git a/src/app/mode/rewind.rs b/src/app/mode/rewind.rs index f6ccbb1..ba8aa12 100644 --- a/src/app/mode/rewind.rs +++ b/src/app/mode/rewind.rs @@ -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; diff --git a/src/app/mode/settings.rs b/src/app/mode/settings.rs index b7bfcbb..d698257 100644 --- a/src/app/mode/settings.rs +++ b/src/app/mode/settings.rs @@ -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, diff --git a/src/app/mode/todo.rs b/src/app/mode/todo.rs index b9e6fd7..3d505b1 100644 --- a/src/app/mode/todo.rs +++ b/src/app/mode/todo.rs @@ -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; diff --git a/src/app/mode/workflow.rs b/src/app/mode/workflow.rs index 8df1caa..b258fef 100644 --- a/src/app/mode/workflow.rs +++ b/src/app/mode/workflow.rs @@ -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" diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs index 4cf368d..76bf775 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -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 { 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 { 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> { 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 { 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 .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> { 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, diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 8725296..5cc2477 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -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, @@ -547,6 +594,14 @@ struct TurnCtx { abort_flag: std::sync::Arc, } +/// 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>>, 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], @@ -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 { 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 { 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 { use std::time::{SystemTime, UNIX_EPOCH}; let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos(); diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index 866d580..ec6f6f1 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -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` (always non-empty) to be applied sequentially +/// by `apply_action`. pub fn apply_command(command: Command) -> Vec { match command { Command::Help => { diff --git a/src/app/runtime/event_loop/mod.rs b/src/app/runtime/event_loop/mod.rs index 89f3626..1f60254 100644 --- a/src/app/runtime/event_loop/mod.rs +++ b/src/app/runtime/event_loop/mod.rs @@ -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, } 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>, ) -> Vec { diff --git a/src/app/runtime/mod.rs b/src/app/runtime/mod.rs index 098011c..b0ffa4e 100644 --- a/src/app/runtime/mod.rs +++ b/src/app/runtime/mod.rs @@ -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; diff --git a/src/app/runtime/shortsend.rs b/src/app/runtime/shortsend.rs index 4d2011b..e7f2dcf 100644 --- a/src/app/runtime/shortsend.rs +++ b/src/app/runtime/shortsend.rs @@ -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 that preserves the first message and +/// the tail. pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec { if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 { return messages.to_vec(); diff --git a/src/app/runtime/stream/mod.rs b/src/app/runtime/stream/mod.rs index bda99ca..7e96fc6 100644 --- a/src/app/runtime/stream/mod.rs +++ b/src/app/runtime/stream/mod.rs @@ -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, @@ -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 { 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 { 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 { let value: Value = serde_json::from_str(data).ok()?; diff --git a/src/app/runtime/stream/tools/mod.rs b/src/app/runtime/stream/tools/mod.rs index 6aeef84..8b04d1f 100644 --- a/src/app/runtime/stream/tools/mod.rs +++ b/src/app/runtime/stream/tools/mod.rs @@ -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` 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` of pending (non-empty-name) + /// tool calls, suitable for downstream inspection or replay. pub fn pending_args(&self) -> Vec { self.calls .iter() diff --git a/src/app/runtime/stream/turn.rs b/src/app/runtime/stream/turn.rs index da5c66c..0a46980 100644 --- a/src/app/runtime/stream/turn.rs +++ b/src/app/runtime/stream/turn.rs @@ -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, @@ -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 { 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 `` 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) diff --git a/src/app/state/diff.rs b/src/app/state/diff.rs index 43d125b..de9f6b7 100644 --- a/src/app/state/diff.rs +++ b/src/app/state/diff.rs @@ -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, } +/// 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 { if before == after { return Vec::new(); diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 7ae6eba..ab6aec4 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -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>>, } 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) { 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 { let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect(); self.toasts.retain(|t| !t.expired(now_ms)); diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index 094c8c6..639c407 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -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; diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index 1c33e30..b120132 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -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, 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(), diff --git a/src/app/state/runtime.rs b/src/app/state/runtime.rs index 7862632..9424cec 100644 --- a/src/app/state/runtime.rs +++ b/src/app/state/runtime.rs @@ -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, @@ -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); } diff --git a/src/app/state/snapshot.rs b/src/app/state/snapshot.rs index 459f4f6..c238e86 100644 --- a/src/app/state/snapshot.rs +++ b/src/app/state/snapshot.rs @@ -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> { 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 { Ok(serde_json::from_slice(data)?) } diff --git a/src/app/state/types.rs b/src/app/state/types.rs index a89d99f..6dd1c60 100644 --- a/src/app/state/types.rs +++ b/src/app/state/types.rs @@ -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, @@ -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(), diff --git a/src/app/subagent/context.rs b/src/app/subagent/context.rs index f88a432..c7c87b0 100644 --- a/src/app/subagent/context.rs +++ b/src/app/subagent/context.rs @@ -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, @@ -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" { diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs index 156c62e..c2c68b7 100644 --- a/src/app/subagent/engine.rs +++ b/src/app/subagent/engine.rs @@ -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>, Vec) { let all = all_tools(); let filtered: Vec> = if allowed_tools.is_empty() { @@ -24,9 +36,16 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec (String, String, Option) { 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) { (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) -> anyhow::Result { let mut output = String::new(); let mut messages: Vec = Vec::new(); diff --git a/src/app/subagent/event.rs b/src/app/subagent/event.rs index 3efb9a1..cd57aa6 100644 --- a/src/app/subagent/event.rs +++ b/src/app/subagent/event.rs @@ -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 { diff --git a/src/app/subagent/mod.rs b/src/app/subagent/mod.rs index 727228e..40dbc14 100644 --- a/src/app/subagent/mod.rs +++ b/src/app/subagent/mod.rs @@ -1,3 +1,6 @@ +//! Subagent management: spawning, context building, engine loop, and +//! progress events. + pub mod context; pub mod engine; pub mod event; diff --git a/src/app/subagent/spawn.rs b/src/app/subagent/spawn.rs index 0803e4a..102349f 100644 --- a/src/app/subagent/spawn.rs +++ b/src/app/subagent/spawn.rs @@ -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 diff --git a/src/app/workflow/engine.rs b/src/app/workflow/engine.rs index 28a86ac..f528d08 100644 --- a/src/app/workflow/engine.rs +++ b/src/app/workflow/engine.rs @@ -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> = 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, } +/// 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, @@ -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) -> anyhow::Result { 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) -> anyhow::R type ParallelResult = (usize, anyhow::Result>); +/// 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` of all agent outputs (or error strings) in +/// the order they were submitted. pub fn execute_primitive( primitive: &ScriptPrimitive, args: &HashMap, @@ -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) -> anyhow::Result { if let Ok(mut findings) = FINDINGS.lock() { findings.clear(); @@ -201,12 +241,19 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap) -> 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 { let mut result = template.to_string(); for (key, value) in args { @@ -215,6 +262,9 @@ fn resolve_template(template: &str, args: &HashMap) -> String { result } +/// A counting semaphore built from a `Mutex` + `Condvar`. +/// +/// Used by `execute_primitive` to cap concurrent parallel branches. struct Semaphore { count: Mutex, condvar: std::sync::Condvar, diff --git a/src/app/workflow/mod.rs b/src/app/workflow/mod.rs index bb7570a..d8c4e84 100644 --- a/src/app/workflow/mod.rs +++ b/src/app/workflow/mod.rs @@ -1,2 +1,5 @@ +//! Workflow orchestration: a script interpreter that runs pipeline/parallel +//! primitives across multiple subagent instances. + pub mod engine; pub mod script; diff --git a/src/app/workflow/script.rs b/src/app/workflow/script.rs index 99695db..ae42bee 100644 --- a/src/app/workflow/script.rs +++ b/src/app/workflow/script.rs @@ -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), + /// Execute several primitives sequentially, each waiting for the + /// previous to complete. Pipeline(Vec), + /// A named wrapper around another primitive (used for display/tracing). Phase { name: String, script: Box, }, } +/// 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, diff --git a/src/controller/command.rs b/src/controller/command.rs index ee6cc5d..a017b50 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -1,5 +1,9 @@ +//! Slash-command parser that maps TUI `/foo` input lines into `Command` +//! variants for the action dispatch system. + use crate::app::mode::ModeKind; +/// A parsed slash command from the TUI input buffer. #[derive(Debug, Clone, PartialEq)] pub enum Command { Help, @@ -23,6 +27,14 @@ pub enum Command { Unknown(String), } +/// Parse a slash-prefixed input line into a `Command` value. +/// +/// Flow: trim -> check for leading `/` -> split on space (max 3 parts) -> +/// match the first token against known commands -> extract arguments from +/// the remaining parts. +/// +/// Why: early return `Unknown` for non-slash lines so the caller can treat +/// them as regular chat input. pub fn parse_command(text: &str) -> Command { let text = text.trim(); if !text.starts_with('/') { diff --git a/src/controller/input.rs b/src/controller/input.rs index 6299cae..463542f 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -1,3 +1,7 @@ +//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action` +//! variants, with special handling for overlays, auto-complete, and the +//! inline editor. + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::mode; @@ -7,6 +11,16 @@ use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; use crate::controller::command::parse_command; +/// Translate a terminal `KeyEvent` into zero or more `Action` values +/// based on the current application state. +/// +/// Flow: check overlay first (Editor gets its own handler) -> match on +/// key code and modifiers -> handle auto-complete cycles -> dispatch to +/// `Action` variants or overlay-specific handlers. +/// +/// Why: when Editor overlay is active, all key events are consumed by the +/// editor handler and never reach the main action dispatch. Return `Vec` +/// so that a single key press can trigger multiple actions. pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { // While Editor overlay is active, route input directly to the editor handler if state.misc.overlay == Overlay::Editor { @@ -192,6 +206,12 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } } +/// Handle pressing Enter while a modal overlay is active: dispatch +/// overlay-specific submit logic (bash, settings, todo, quit, etc.). +/// +/// Flow: match the current overlay -> run the associated handler -> +/// mutate state or produce actions as needed -> always return `Vec::new()` +/// (the handler itself applies state mutations). fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { match state.misc.overlay { Overlay::Bash => { diff --git a/src/controller/mod.rs b/src/controller/mod.rs index 6f96dfb..a5e0fdd 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -1,2 +1,4 @@ +//! Keyboard input handling and command parsing for the TUI. + pub mod command; pub mod input; diff --git a/src/dto/chat/message.rs b/src/dto/chat/message.rs index afa65c4..0501d03 100644 --- a/src/dto/chat/message.rs +++ b/src/dto/chat/message.rs @@ -1,5 +1,9 @@ +//! Chat message types shared across the DTO layer: `Role` and `ChatMessage` +//! with convenience constructors. + use serde::{Deserialize, Serialize}; +/// The conversation participant who authored a message. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Role { #[serde(rename = "user")] @@ -15,6 +19,8 @@ pub enum Role { impl Role { } +/// A single message in a conversation, compatible with the OpenAI/Anthropic +/// chat-completion API structures. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessage { pub role: Role, @@ -28,6 +34,7 @@ pub struct ChatMessage { } impl ChatMessage { + /// Build a user-role message with the given text content. pub fn user(content: impl Into) -> Self { ChatMessage { role: Role::User, @@ -38,6 +45,7 @@ impl ChatMessage { } } + /// Build an assistant-role message with an optional text response. pub fn assistant(content: Option) -> Self { ChatMessage { role: Role::Assistant, @@ -48,6 +56,7 @@ impl ChatMessage { } } + /// Build a system-role message with the given instruction text. pub fn system(content: impl Into) -> Self { ChatMessage { role: Role::System, @@ -58,6 +67,7 @@ impl ChatMessage { } } + /// Build a tool-role result message referencing a prior tool call. pub fn tool_result(tool_call_id: String, content: String) -> Self { ChatMessage { role: Role::Tool, diff --git a/src/dto/chat/mod.rs b/src/dto/chat/mod.rs index 2b137e6..2cf938e 100644 --- a/src/dto/chat/mod.rs +++ b/src/dto/chat/mod.rs @@ -1,2 +1,4 @@ +//! Chat DTO submodules: message roles/content and tool-call structures. + pub mod message; pub mod tool; diff --git a/src/dto/chat/tool.rs b/src/dto/chat/tool.rs index 022dff2..9ed66ab 100644 --- a/src/dto/chat/tool.rs +++ b/src/dto/chat/tool.rs @@ -1,6 +1,17 @@ +//! Tool-call DTOs embedded in assistant chat messages. +//! +//! Flow: provider response/stream carries `tool_calls` on an assistant +//! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves +//! `function.name` against `all_tools()` and runs it with +//! `sanitize_tool_arguments(function.arguments)`. +//! +//! Why: kept separate from `dto::provider` because tool calls are a property +//! of a chat *message*, not of the request/response envelope. + use serde::{Deserialize, Serialize}; use serde_json::Value; +/// A single tool-call request emitted by the model in an assistant message. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, @@ -9,12 +20,23 @@ pub struct ToolCall { pub function: ToolFunction, } +/// The function name and raw arguments payload for a `ToolCall`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolFunction { pub name: String, pub arguments: Value, } +/// Normalize tool-call arguments into a JSON object/value. +/// +/// Flow: some providers send `arguments` as a JSON-encoded string rather +/// than a nested object; if `args` is a string, attempt to parse it as +/// JSON. Objects and other value types pass through unchanged. +/// +/// Why: falling back to the raw string on parse failure (rather than +/// erroring) keeps the harness resilient to malformed provider output. +/// +/// Return: the parsed `Value`, or the original `args` clone if parsing fails. pub fn sanitize_tool_arguments(args: &Value) -> Value { match args { Value::String(s) => { diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 01660d1..bc66c1b 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -1,2 +1,5 @@ +//! Data transfer objects shared across the app: chat messages/tool calls +//! and provider request/response/usage shapes. + pub mod chat; pub mod provider; diff --git a/src/dto/provider/mod.rs b/src/dto/provider/mod.rs index 91543ac..50f611c 100644 --- a/src/dto/provider/mod.rs +++ b/src/dto/provider/mod.rs @@ -1,3 +1,5 @@ +//! Provider-facing DTOs: chat completion request, response, and usage/cost. + pub mod request; pub mod response; pub mod usage; diff --git a/src/dto/provider/request.rs b/src/dto/provider/request.rs index 6e4a0bc..2e7d8ce 100644 --- a/src/dto/provider/request.rs +++ b/src/dto/provider/request.rs @@ -1,6 +1,25 @@ +//! Outbound request DTOs for the OpenAI/Anthropic-compatible chat completions API. +//! +//! Flow: `harness`/`runtime` builds a `ChatRequest` from conversation state and +//! the active tool set → serializes to JSON via `serde` → sends to the +//! provider's `/chat/completions`-style endpoint (streaming or not). +//! +//! Why: fields mirror the wire format exactly (including `#[serde(rename)]` +//! for reserved words like `type`) so no manual (de)serialization glue is +//! needed; optional fields use `skip_serializing_if` so unset knobs are +//! omitted rather than sent as `null`, matching provider expectations. + use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider. +/// +/// Flow: constructed from the current message history plus optional +/// generation knobs (temperature, max_tokens, tools, etc.) and serialized +/// directly into the HTTP request body. +/// +/// Return: not a function, but the value that becomes the JSON request +/// payload for a completion call. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatRequest { pub model: String, @@ -21,11 +40,21 @@ pub struct ChatRequest { pub stream_options: Option, } +/// Streaming options for the request; `include_usage` asks the provider to +/// emit a final usage chunk in the SSE stream. +/// +/// Why: usage tokens are otherwise unavailable in a streamed response since +/// they're normally only attached to the final non-streamed completion. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamOptions { pub include_usage: bool, } +/// Wire format for a single tool definition sent to the provider. +/// +/// Flow: built from the harness's registered `Tool` impls (see `all_tools()`) +/// and attached to `ChatRequest.tools` so the model knows which functions it +/// may call. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolDef { #[serde(rename = "type")] @@ -33,6 +62,10 @@ pub struct ToolDef { pub function: ToolFunctionDef, } +/// Name, description, and JSON schema parameters for a tool definition. +/// +/// Why: `parameters` is a raw `serde_json::Value` rather than a typed struct +/// because each tool defines its own arbitrary JSON schema. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolFunctionDef { pub name: String, diff --git a/src/dto/provider/response.rs b/src/dto/provider/response.rs index b61bb10..b467081 100644 --- a/src/dto/provider/response.rs +++ b/src/dto/provider/response.rs @@ -1,5 +1,21 @@ +//! Inbound response DTOs for the non-streaming chat completions API. +//! +//! Flow: provider HTTP response body → `serde_json` deserializes into +//! `ChatResponse` → caller reads `choices[0].message` for the assistant +//! reply and `usage` for token accounting. +//! +//! Why: separate from the streaming SSE path (see `app/runtime/stream/mod.rs`), +//! which parses incremental deltas rather than a single complete payload. + use serde::{Deserialize, Serialize}; +/// Non-streaming chat completion response returned by the provider. +/// +/// Flow: deserialized directly from the HTTP response body of a +/// non-streaming completion call. +/// +/// Return: not a function, but the value callers inspect for the model's +/// reply (`choices`) and token usage (`usage`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatResponse { pub id: String, @@ -9,6 +25,10 @@ pub struct ChatResponse { pub created: Option, } +/// One completion candidate within a `ChatResponse.choices` list. +/// +/// Why: `finish_reason` is optional/string-typed since providers vary in +/// what values they emit (e.g. `"stop"`, `"tool_calls"`, `"length"`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Choice { pub index: u32, diff --git a/src/dto/provider/usage.rs b/src/dto/provider/usage.rs index 5714f25..38b6584 100644 --- a/src/dto/provider/usage.rs +++ b/src/dto/provider/usage.rs @@ -1,5 +1,18 @@ +//! Token usage accounting DTO shared by streaming and non-streaming responses. +//! +//! Flow: populated from the provider's `usage` object (either the final SSE +//! chunk when `stream_options.include_usage` is set, or the `usage` field of +//! a non-streaming `ChatResponse`) → surfaced to the TUI for cost/token +//! display. + use serde::{Deserialize, Serialize}; +/// Token counts and optional cost breakdown for a single completion request. +/// +/// Why: all fields are optional because providers differ in what they +/// report — some omit per-token cost entirely, others omit usage altogether +/// on certain response paths. `Default` lets callers start from an empty +/// usage record when a provider sends none. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Usage { pub prompt_tokens: Option, diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 669fbf2..ed607b9 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -1,20 +1,37 @@ +//! Unix-socket client used by the `--attach` process to talk to a +//! running `--daemon`. +//! +//! Flow: `IpcClient::connect_unix` opens a `Connection` (see `conn.rs`) +//! to the daemon's socket path → `send`/`receive` exchange framed JSON +//! messages (typically `ClientRequest`/`DaemonFrame` from `protocol.rs`). + use anyhow::Result; use super::conn::Connection; +/// Client-side handle for the `--attach` process: wraps a `Connection` +/// to a daemon's Unix socket. pub struct IpcClient { conn: Connection, } impl IpcClient { + /// Connect to a daemon listening on the given Unix socket path. + /// + /// Return: `Ok(IpcClient)` on success, or an error if the socket is + /// missing or the daemon isn't accepting connections. pub fn connect_unix(path: &str) -> Result { let conn = Connection::connect_unix(path)?; Ok(IpcClient { conn }) } + /// Serialize and send a value to the daemon (see `frame::write_frame`). pub fn send(&mut self, value: &T) -> Result<()> { self.conn.send(value) } + /// Read and deserialize the next frame from the daemon. + /// + /// Return: `Ok(None)` if the daemon closed the connection cleanly. pub fn receive(&mut self) -> Result> { self.conn.receive() } diff --git a/src/ipc/conn.rs b/src/ipc/conn.rs index d7498bb..8306458 100644 --- a/src/ipc/conn.rs +++ b/src/ipc/conn.rs @@ -1,26 +1,43 @@ +//! Framed Unix-socket connection shared by both the server (`server.rs`) +//! and client (`client.rs`) sides of the IPC layer. +//! +//! Flow: `Connection` wraps a `UnixStream` (either accepted by the server +//! or dialed by the client) → `send` serializes a value to JSON and +//! writes it as one length-prefixed frame (`frame::write_frame`) → +//! `receive` reads one frame and deserializes it back to the caller's +//! type, propagating a clean peer-close as `Ok(None)`. + use std::os::unix::net::UnixStream; use anyhow::Result; use super::frame; +/// A framed Unix-socket connection shared by client and server sides of +/// the IPC layer; each `send`/`receive` moves one length-prefixed JSON frame. pub struct Connection { inner: UnixStream, } impl Connection { + /// Wrap an already-connected/accepted `UnixStream`. pub fn from_stream(stream: UnixStream) -> Result { Ok(Connection { inner: stream }) } + /// Open a new Unix-socket connection to `path`. pub fn connect_unix(path: &str) -> Result { let stream = UnixStream::connect(path)?; Ok(Connection { inner: stream }) } + /// Serialize `value` to JSON and write it as one length-prefixed frame. pub fn send(&mut self, value: &T) -> Result<()> { let data = frame::serialize_frame(value)?; frame::write_frame(&mut self.inner, &data) } + /// Read one length-prefixed frame and deserialize it as `T`. + /// + /// Return: `Ok(None)` on clean EOF (peer closed the connection). pub fn receive(&mut self) -> Result> { let data = frame::read_frame(&mut self.inner)?; match data { diff --git a/src/ipc/diff.rs b/src/ipc/diff.rs index edffcbc..4ecbae1 100644 --- a/src/ipc/diff.rs +++ b/src/ipc/diff.rs @@ -1,6 +1,17 @@ +//! Field-level diffing of JSON app-state snapshots, for sending only +//! incremental changes over IPC instead of a full `StateSnapshot`. +//! +//! Flow: `compute_diff` recursively walks two JSON `Value`s (before/after) +//! → for objects, recurses per key building a dotted path string; any +//! other mismatch is recorded wholesale → results accumulate into a +//! `StateDiff`'s `Vec`, built via `StateDiff::new`/`add_change` +//! and reset via `clear`. + use serde::{Deserialize, Serialize}; use serde_json::Value; +/// A timestamped batch of field-level changes to app state, keyed by +/// dotted JSON path, for a given session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateDiff { pub timestamp: i64, @@ -8,6 +19,7 @@ pub struct StateDiff { pub changes: Vec, } +/// A single field change: the JSON path and its old/new values. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Change { pub path: String, @@ -16,6 +28,7 @@ pub struct Change { } impl StateDiff { + /// Create an empty diff for `session_id`, timestamped at creation. pub fn new(session_id: String) -> Self { StateDiff { timestamp: chrono::Utc::now().timestamp_millis(), @@ -24,6 +37,7 @@ impl StateDiff { } } + /// Append a single field change to the diff. pub fn add_change(&mut self, path: String, old_value: Option, new_value: Option) { self.changes.push(Change { path, @@ -32,16 +46,28 @@ impl StateDiff { }); } + /// Whether the diff has no recorded changes. pub fn is_empty(&self) -> bool { self.changes.is_empty() } + /// Drop all changes and refresh the timestamp. pub fn clear(&mut self) { self.changes.clear(); self.timestamp = chrono::Utc::now().timestamp_millis(); } } +/// Recursively diff two JSON values, appending field-level `Change`s. +/// +/// Flow: equal values short-circuit → for two objects, recurse per key +/// (union of both maps' keys, missing side treated as `Null`) building +/// a dotted `path` → any other value-type mismatch (or non-object diff) +/// is recorded as one `Change` at the current `path`. +/// +/// Why: only objects are diffed structurally; arrays and scalars are +/// compared wholesale so a change anywhere inside them replaces the +/// whole value rather than producing an index-level diff. pub fn compute_diff(before: &Value, after: &Value, path: &str, changes: &mut Vec) { if before == after { return; diff --git a/src/ipc/frame.rs b/src/ipc/frame.rs index 17fee5c..06d36fb 100644 --- a/src/ipc/frame.rs +++ b/src/ipc/frame.rs @@ -1,8 +1,28 @@ +//! Length-prefixed binary framing and JSON (de)serialization helpers for +//! the IPC wire protocol. +//! +//! Flow: `write_frame`/`read_frame` handle the raw byte-level framing +//! (4-byte big-endian length header + payload) over any `Read`/`Write`; +//! `serialize_frame`/`deserialize_frame` handle the JSON layer on top. +//! `Connection` (see `conn.rs`) composes both layers for a full send/receive. +//! +//! Why: a fixed-size length prefix lets the reader know exactly how many +//! bytes to pull before attempting to parse, avoiding partial-JSON reads +//! over a stream socket. + use std::io::{Read, Write}; use anyhow::Result; +/// Upper bound on a single frame's byte size (64 MiB), enforced on both +/// the write and read paths to bound memory use and reject malformed or +/// malicious oversized length headers. pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024; +/// Write `data` as a length-prefixed frame: 4-byte big-endian length +/// followed by the raw bytes, then flush. +/// +/// Why: rejects frames over `MAX_FRAME_SIZE` to bound memory use on the +/// reading side before any bytes are read. pub fn write_frame(writer: &mut W, data: &[u8]) -> Result<()> { let len = data.len(); if len > MAX_FRAME_SIZE { @@ -15,6 +35,14 @@ pub fn write_frame(writer: &mut W, data: &[u8]) -> Result<()> { Ok(()) } +/// Read one length-prefixed frame written by `write_frame`. +/// +/// Flow: read 4-byte length header → on clean EOF before any bytes, +/// return `Ok(None)` (peer closed) → validate against `MAX_FRAME_SIZE` +/// → read the payload. +/// +/// Return: `Ok(None)` signals a graceful connection close, distinct +/// from an `Err` mid-frame I/O failure. pub fn read_frame(reader: &mut R) -> Result>> { let mut len_buf = [0u8; 4]; match reader.read_exact(&mut len_buf) { @@ -31,6 +59,7 @@ pub fn read_frame(reader: &mut R) -> Result>> { Ok(Some(buf)) } +/// Serialize `value` to JSON bytes, rejecting output over `MAX_FRAME_SIZE`. pub fn serialize_frame(value: &T) -> Result> { let json = serde_json::to_vec(value)?; if json.len() > MAX_FRAME_SIZE { @@ -39,6 +68,7 @@ pub fn serialize_frame(value: &T) -> Result> { Ok(json) } +/// Deserialize a frame's raw JSON bytes into `T`. pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result { Ok(serde_json::from_slice(data)?) } diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index 9f3ad2e..ce0a6d5 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -1,3 +1,7 @@ +//! Unix-socket IPC layer used to connect a `--attach` TUI client to a +//! `--daemon` process: length-prefixed framing, connection wrapper, +//! client/server handles, and the wire protocol types. + pub mod client; pub mod conn; pub mod frame; diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs index 0ee6ed7..95048f4 100644 --- a/src/ipc/protocol.rs +++ b/src/ipc/protocol.rs @@ -1,5 +1,19 @@ +//! Wire message types exchanged between an attached client and the +//! daemon over the `Connection`/framing layer (`conn.rs`, `frame.rs`). +//! +//! Flow: client input events are captured as `KeyAction`/`ClientRequest` +//! and sent to the daemon → the daemon applies them to its `AppStateRest` +//! and replies with `DaemonFrame` variants (a flattened `StatePayload` +//! for redraw, streamed tokens, system notes, or a close signal). +//! +//! Why: `StatePayload`/`MessageEntry`/`ToastEntry` are deliberately flat, +//! serializable projections of daemon-side state so the client can +//! redraw its TUI without sharing any in-process state with the daemon. + use serde::{Deserialize, Serialize}; +/// Wire-serializable subset of `crossterm::event::KeyCode`, sent from +/// an attached client to the daemon over IPC. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum KeyAction { Char(char), @@ -19,6 +33,8 @@ pub enum KeyAction { Function(u8), } +/// Messages an attached client sends to the daemon: input events, a +/// full-line submit, terminal resize, and connection lifecycle. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ClientRequest { Tick, @@ -33,6 +49,7 @@ pub enum ClientRequest { Close, } +/// Flattened chat message sent from daemon to client for transcript display. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessageEntry { pub role: String, @@ -40,6 +57,7 @@ pub struct MessageEntry { pub timestamp: i64, } +/// Flattened toast notification sent from daemon to client for rendering. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToastEntry { pub kind: String, @@ -48,6 +66,8 @@ pub struct ToastEntry { pub lifetime_ms: u64, } +/// Snapshot of daemon-side `AppStateRest` sent to the client after every +/// action, enough for the client to redraw its TUI without shared state. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatePayload { pub session_id: String, @@ -61,6 +81,7 @@ pub struct StatePayload { pub input_cursor: usize, } +/// Messages the daemon sends back to an attached client. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DaemonFrame { StateUpdate(Box), diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 735b04d..28871f7 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -1,18 +1,33 @@ +//! Unix-socket listener for the `--daemon` process. +//! +//! Flow: `IpcServer::bind_unix` opens/binds a Unix socket at a well-known +//! path (clearing any stale file left by a crashed prior daemon) → +//! `accept` blocks for the next client and wraps it as a `Connection` +//! (see `conn.rs`) for framed request/response traffic. + use std::os::unix::net::UnixListener; use anyhow::Result; use super::conn::Connection; +/// Server-side handle for the `--daemon` process: listens on a Unix +/// socket and hands out `Connection`s to accepted clients. pub struct IpcServer { listener: UnixListener, } impl IpcServer { + /// Bind a new Unix-socket listener at `path`. + /// + /// Why: removes any stale socket file at `path` first, since a prior + /// crashed daemon can leave one behind and `UnixListener::bind` fails + /// on an existing path. pub fn bind_unix(path: &str) -> Result { let _ = std::fs::remove_file(path); let listener = UnixListener::bind(path)?; Ok(IpcServer { listener }) } + /// Block until a client connects, then wrap it as a `Connection`. pub fn accept(&self) -> Result { let (stream, _addr) = self.listener.accept()?; Connection::from_stream(stream) diff --git a/src/ipc/snapshot.rs b/src/ipc/snapshot.rs index c3dcf4b..8402308 100644 --- a/src/ipc/snapshot.rs +++ b/src/ipc/snapshot.rs @@ -1,6 +1,17 @@ +//! Point-in-time state snapshots for external inspection/persistence of +//! a running session (distinct from the incremental `StateDiff` in +//! `diff.rs`). +//! +//! Flow: `StateSnapshot::new` builds an empty, `dirty`-marked snapshot → +//! callers populate/replace its fields as state changes → +//! `serialize_snapshot`/`deserialize_snapshot` move it to/from JSON bytes +//! for storage or IPC transport. + use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Point-in-time summary of app state (mode, session, counts, arbitrary +/// `payload`) used for external inspection/persistence of a running session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateSnapshot { pub timestamp: i64, @@ -15,6 +26,7 @@ pub struct StateSnapshot { } impl StateSnapshot { + /// Build a fresh, empty snapshot marked `dirty` for the given session. pub fn new(session_id: String, mode: String, model: String) -> Self { StateSnapshot { timestamp: chrono::Utc::now().timestamp_millis(), @@ -30,11 +42,13 @@ impl StateSnapshot { } } +/// Serialize a `StateSnapshot` to JSON bytes. pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result> { let data = serde_json::to_vec(snapshot)?; Ok(data) } +/// Deserialize JSON bytes back into a `StateSnapshot`. pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result { let snapshot: StateSnapshot = serde_json::from_slice(data)?; Ok(snapshot) diff --git a/src/main.rs b/src/main.rs index 05df4bd..0d6ae8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,10 @@ +//! Zesdex binary entry point. +//! +//! Parses `--daemon` / `--attach ` flags to select one of three +//! process modes (single-process TUI+agent, background daemon, or +//! attach-only TUI client), sets up file logging, and runs the +//! corresponding event loop. + use std::io; use std::io::Write; use std::sync::Mutex; @@ -17,6 +24,16 @@ mod tool; mod resources; mod view; +/// Process entry point: parse CLI flags, initialize logging, then dispatch +/// to single-process, daemon, or attach mode. +/// +/// Flow: parse `--daemon`/`--attach ` from argv → create/open the log +/// file under the platform data dir (falling back to `/dev/null` if that +/// fails, so a broken log path can't crash the TUI) → init tracing → +/// reject `--daemon` + `--attach` together → dispatch. +/// +/// Why: logging is routed to a file (never stderr/stdout) because writing +/// to the terminal while ratatui owns the alternate screen corrupts the UI. fn main() -> Result<()> { let args: Vec = std::env::args().collect(); let is_daemon = args.iter().any(|a| a == "--daemon"); @@ -61,6 +78,17 @@ fn main() -> Result<()> { run_single_process() } +/// Run zesdex as a self-contained TUI + agent loop in one process. +/// +/// Flow: create the store, a fresh session dir, and take an exclusive +/// session lock → build `AppStateRest` → enter raw mode / alternate +/// screen → run the event loop → always restore the terminal (even on +/// error) → save settings and release the session lock. +/// +/// Why: the session lock prevents two zesdex processes from concurrently +/// writing the same session directory. Terminal restoration happens +/// outside `run_loop`'s `Result` so a panicking/erroring loop still +/// leaves the user's terminal usable. fn run_single_process() -> Result<()> { let store = model::store::Store::new(); store.ensure_dirs()?; @@ -110,6 +138,11 @@ fn run_single_process() -> Result<()> { Ok(()) } +/// Map a `crossterm` key code to the wire-serializable `KeyAction`, for +/// sending key input from an attached client to the daemon. +/// +/// Return: `None` for key codes with no `KeyAction` equivalent (e.g. +/// media keys), which are silently dropped. fn key_code_to_action(code: crossterm::event::KeyCode) -> Option { use crossterm::event::KeyCode; match code { @@ -132,6 +165,9 @@ fn key_code_to_action(code: crossterm::event::KeyCode) -> Option crossterm::event::KeyCode { use crossterm::event::KeyCode; match action { @@ -153,6 +189,15 @@ fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::Ke } } +/// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it +/// to the attached client as a `DaemonFrame::StateUpdate`. +/// +/// Flow: map transcript messages/toasts to their wire DTOs → derive the +/// active overlay name (or `None` if no overlay is active) → build and +/// send one `DaemonFrame`. +/// +/// Why: the client never shares memory with the daemon, so every action +/// on the daemon side is followed by a full state push rather than a diff. fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> { use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload}; @@ -194,6 +239,17 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest conn.send(&frame) } +/// Apply a `StatePayload` received from the daemon onto the client's +/// local `AppStateRest`, so the attach-mode TUI can render it. +/// +/// Flow: copy scalar fields directly → rebuild the transcript cache from +/// `MessageEntry`s (mapping role strings back to the `Role` enum) → +/// resolve the overlay name string to an `Overlay` variant → rebuild +/// toasts from `ToastEntry`s. +/// +/// Why: unrecognized role/overlay/toast-kind strings fall back to a safe +/// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than +/// panicking, so a protocol/version mismatch degrades gracefully. fn apply_client_update( state: &mut app::state::rest::AppStateRest, payload: ipc::protocol::StatePayload, @@ -260,6 +316,20 @@ fn apply_client_update( state.input.cursor = payload.input_cursor; } +/// Run zesdex as a background daemon: owns the agent state, listens on a +/// per-session Unix socket, and drives one attached client. +/// +/// Flow: create session + lock it → bind a Unix socket under +/// `/run/.sock` → block for a single client to +/// `accept()` → loop reading `ClientRequest`s, translating each into +/// `Action`(s) via the same `controller::input`/`apply_action` path the +/// single-process mode uses, then pushing a full state update back → +/// on `Close` or client disconnect, clean up the socket file, save +/// settings, and release the lock. +/// +/// Why: reuses `controller::input::handle_key` by synthesizing a +/// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and +/// single-process modes share identical key-handling logic. fn run_daemon() -> Result<()> { use app::runtime::actions::{Action, apply_action}; use ipc::protocol::ClientRequest; @@ -362,6 +432,19 @@ fn run_daemon() -> Result<()> { Ok(()) } +/// Run zesdex as a TUI-only client attached to an existing daemon session. +/// +/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate +/// screen → build a local `AppStateRest` mirror (only used for rendering +/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a +/// terminal event (key/resize) and forward it as a `ClientRequest`, or +/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and +/// apply it via `apply_client_update` → redraw → exit when the daemon +/// closes or the user quits (sending `ClientRequest::Close` first). +/// +/// Why: Ctrl+C is intercepted locally to quit the client without going +/// through the daemon, since the daemon has no notion of "this client +/// wants to leave" beyond the explicit `Close` request. fn run_attach(session_id: &str) -> Result<()> { use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use ipc::protocol::ClientRequest; @@ -467,6 +550,14 @@ fn run_attach(session_id: &str) -> Result<()> { Ok(()) } +/// Run the single-process event loop, guaranteeing terminal restoration +/// on error. +/// +/// Flow: delegate to `run_loop_inner` → if it errors, clear the screen +/// and tear down raw mode / alternate screen before propagating the error. +/// +/// Why: without this wrapper, an error inside the loop would leave the +/// user's terminal in raw/alternate-screen mode after the process exits. fn run_loop( state: &mut app::state::rest::AppStateRest, terminal: &mut Terminal>, @@ -481,6 +572,17 @@ fn run_loop( result } +/// The core single-process render/input loop. +/// +/// Flow: until `state.quit` → drain expired toasts → draw the frame → +/// poll for a terminal event with a 50ms timeout (keys go through +/// `handle_key` → `apply_action`; resize and scroll map to `Action` +/// variants directly) → always fire `Action::Tick` each iteration +/// (drives streaming/background progress) → on exit, clear the terminal. +/// +/// Why: the 50ms poll timeout bounds input latency while still yielding +/// regularly for the `Tick` action, which drives async work like LLM +/// streaming without a separate polling thread. fn run_loop_inner( state: &mut app::state::rest::AppStateRest, terminal: &mut Terminal>, diff --git a/src/model/agent_def/builtin.rs b/src/model/agent_def/builtin.rs index 25901eb..b5e8c42 100644 --- a/src/model/agent_def/builtin.rs +++ b/src/model/agent_def/builtin.rs @@ -1,5 +1,17 @@ +//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner). + use crate::app::subagent::spawn::AgentDefinition; +/// Build the fixed list of built-in agent definitions shipped with zesdex. +/// +/// Flow: construct each `AgentDefinition` with a name, system prompt, and +/// allowed tool list, then collect into a `Vec`. +/// +/// Why: these agents are always available regardless of global/session +/// config, giving users a baseline set of roles out of the box. +/// +/// Return: a freshly-built `Vec` (coder, reviewer, +/// researcher, planner). pub fn builtin_agents() -> Vec { vec![ AgentDefinition::new( diff --git a/src/model/agent_def/global.rs b/src/model/agent_def/global.rs index c0b806e..bf3ee5c 100644 --- a/src/model/agent_def/global.rs +++ b/src/model/agent_def/global.rs @@ -1,5 +1,19 @@ +//! Load, save, and remove user-defined agent definitions stored globally +//! (under the store's `agents/` directory), independent of any session. + use crate::app::subagent::spawn::AgentDefinition; +/// Load all globally-registered agent definitions from disk. +/// +/// Flow: resolve `/agents/` → read directory → parse each `*.json` +/// file into an `AgentDefinition`, skipping any that fail to read or parse. +/// +/// Why: missing directory or unreadable/invalid files are silently +/// skipped rather than failing the whole load, so one corrupt file +/// doesn't break agent loading. +/// +/// Return: a `Vec`, empty if the directory doesn't exist +/// or contains no valid definitions. pub fn load_global_agents() -> Vec { let store = crate::model::store::Store::new(); let agents_dir = store.base_dir.join("agents"); @@ -22,6 +36,16 @@ pub fn load_global_agents() -> Vec { agents } +/// Persist a global agent definition as `/agents/.json`. +/// +/// Flow: ensure the `agents/` directory exists → serialize `def` to +/// pretty JSON → write to a file named after `def.name`. +/// +/// Why: writing by name overwrites any existing definition with the +/// same name, acting as an upsert. +/// +/// Return: `Ok(())` on success, or an error if directory creation, +/// serialization, or the write fails. pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { let store = crate::model::store::Store::new(); let agents_dir = store.base_dir.join("agents"); @@ -32,6 +56,14 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { Ok(()) } +/// Delete a global agent definition by name, if it exists. +/// +/// Flow: resolve `/agents/.json` → remove the file if present. +/// +/// Why: a no-op (not an error) when the file is already absent. +/// +/// Return: `Ok(())` whether or not the file existed; `Err` only on an +/// actual filesystem removal failure. pub fn remove_global_agent(name: &str) -> anyhow::Result<()> { let store = crate::model::store::Store::new(); let path = store.base_dir.join("agents").join(format!("{}.json", name)); diff --git a/src/model/agent_def/mod.rs b/src/model/agent_def/mod.rs index 79abc3d..d90a09a 100644 --- a/src/model/agent_def/mod.rs +++ b/src/model/agent_def/mod.rs @@ -1,3 +1,6 @@ +//! Agent definition sources: built-in defaults, global (user-wide), and +//! per-session overrides. + pub mod builtin; pub mod global; pub mod session; diff --git a/src/model/agent_def/session.rs b/src/model/agent_def/session.rs index ac0ad9c..3b67800 100644 --- a/src/model/agent_def/session.rs +++ b/src/model/agent_def/session.rs @@ -1,6 +1,20 @@ +//! Load, save, add, and remove agent definitions scoped to a single +//! session (`/agents.json`). + use std::path::Path; use crate::app::subagent::spawn::AgentDefinition; +/// Load agent definitions saved for a specific session. +/// +/// Flow: check `/agents.json` exists → read → JSON-decode +/// into `Vec`. +/// +/// Why: a missing file or a parse failure both degrade gracefully to an +/// empty list (parse errors are logged via `tracing::warn!`), so a +/// corrupt session file doesn't crash agent loading. +/// +/// Return: the session's agent definitions, or an empty `Vec` if none +/// exist or the file is malformed. pub fn load_session_agents(session_dir: &Path) -> Vec { let agents_file = session_dir.join("agents.json"); if !agents_file.exists() { @@ -17,6 +31,13 @@ pub fn load_session_agents(session_dir: &Path) -> Vec { } } +/// Overwrite `/agents.json` with the given agent list. +/// +/// Flow: serialize `agents` to pretty JSON → write to +/// `/agents.json`. +/// +/// Return: `Ok(())` on success, or an error if serialization or the +/// write fails. pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> { let agents_file = session_dir.join("agents.json"); let content = serde_json::to_string_pretty(agents)?; @@ -24,6 +45,14 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an Ok(()) } +/// Add or replace a session agent definition by name. +/// +/// Flow: load existing session agents → drop any with the same name as +/// `def` → push `def` → save the updated list. +/// +/// Why: name-based dedup makes this an upsert rather than an append. +/// +/// Return: `Ok(())` on success, propagating any load/save error. pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> { let mut agents = load_session_agents(session_dir); agents.retain(|a| a.name != def.name); @@ -31,6 +60,12 @@ pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Re save_session_agents(session_dir, &agents) } +/// Remove a session agent definition by name, if present. +/// +/// Flow: load existing session agents → filter out entries matching +/// `name` → save the updated list. +/// +/// Return: `Ok(())` whether or not an entry with `name` existed. pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> { let mut agents = load_session_agents(session_dir); agents.retain(|a| a.name != name); diff --git a/src/model/app_config.rs b/src/model/app_config.rs index 6c4fc25..ef3793c 100644 --- a/src/model/app_config.rs +++ b/src/model/app_config.rs @@ -1,6 +1,11 @@ +//! Application-level configuration: LLM providers, model roles, and defaults, +//! persisted to `app_config.json` in the store directory. + use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// Top-level application config: registered providers, named model roles, +/// and which provider/model to use by default. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub providers: HashMap, @@ -9,6 +14,7 @@ pub struct AppConfig { pub default_model: String, } +/// Connection details for a single LLM provider (base URL, API key source). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderConfig { pub api_base: String, @@ -17,6 +23,8 @@ pub struct ProviderConfig { pub default_api_key: Option, } +/// A named role (e.g. "default") mapping to a specific provider/model and +/// its generation parameters. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRole { pub provider: String, @@ -57,6 +65,17 @@ impl Default for AppConfig { } impl AppConfig { + /// Load app config from disk, falling back to defaults on any failure. + /// + /// Flow: read `/app_config.json` → JSON-parse → on missing file + /// or parse error, use `Self::default()` → merge any default providers + /// not already present in the loaded config. + /// + /// Why: the merge step lets newly-added default providers (e.g. a new + /// release adding a provider) appear even in configs saved by older + /// versions, without clobbering user-edited entries with the same name. + /// + /// Return: a fully-populated `AppConfig`, never fails. pub fn load() -> Self { let store = super::store::Store::new(); let path = store.base_dir.join("app_config.json"); diff --git a/src/model/conversation.rs b/src/model/conversation.rs index 4d38bb7..409da47 100644 --- a/src/model/conversation.rs +++ b/src/model/conversation.rs @@ -1,5 +1,9 @@ +//! In-memory conversation state: message history plus the system prompt and +//! model parameters used to drive the LLM. + use serde::{Deserialize, Serialize}; +/// A single conversation's message history and generation settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Conversation { pub messages: Vec, @@ -11,6 +15,8 @@ pub struct Conversation { } impl Conversation { + /// Create an empty conversation with the given system prompt and + /// session id, using default model/token/temperature settings. pub fn new(system_prompt: String, session_id: String) -> Self { Conversation { messages: Vec::new(), @@ -22,10 +28,17 @@ impl Conversation { } } + /// Append a message to the conversation history. pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) { self.messages.push(msg); } + /// Replace the system prompt and strip any prior `System`-role + /// messages from history. + /// + /// Why: the system prompt is re-injected fresh at request time via + /// `to_api_messages`, so stale `System` messages in `self.messages` + /// would be redundant/conflicting if left in place. pub fn rebuild_system(&mut self, new_prompt: String) { self.system_prompt = new_prompt; self.messages.retain(|m| { @@ -33,6 +46,11 @@ impl Conversation { }); } + /// Build the message list to send to the LLM API, with the system + /// prompt prepended. + /// + /// Return: a new `Vec` (clone of history) with a synthesized system + /// message at index 0. pub fn to_api_messages(&self) -> Vec { let mut msgs = Vec::with_capacity(self.messages.len() + 1); msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt)); @@ -40,6 +58,8 @@ impl Conversation { msgs } + /// Number of messages in the conversation history (excluding the + /// synthesized system message). pub fn len(&self) -> usize { self.messages.len() } diff --git a/src/model/editlog.rs b/src/model/editlog.rs index 3cd4687..b743b99 100644 --- a/src/model/editlog.rs +++ b/src/model/editlog.rs @@ -1,5 +1,10 @@ +//! Append-only JSONL edit log recording every file mutation made by tools, +//! for audit and undo/history purposes. + use serde::{Deserialize, Serialize}; +/// A single recorded file edit: which tool made it, to which path, why, +/// and a content hash/size delta for verification. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EditLogEntry { pub ts: i64, @@ -12,6 +17,7 @@ pub struct EditLogEntry { pub session_id: String, } +/// In-memory view of a session's edit log, backed by `edits.jsonl` on disk. #[derive(Debug, Clone)] pub struct EditLog { pub entries: Vec, @@ -19,6 +25,8 @@ pub struct EditLog { } impl EditLog { + /// Open (or start tracking) the edit log for a session directory, + /// replaying any existing `edits.jsonl` into memory. pub fn new(session_dir: &std::path::Path) -> Self { let path = session_dir.join("edits.jsonl"); let entries = Self::load_from_disk(&path); @@ -40,6 +48,17 @@ impl EditLog { .collect() } + /// Append one entry to `edits.jsonl` on disk and to the in-memory log. + /// + /// Flow: serialize `entry` to a JSON line → ensure parent dir exists → + /// open the file in append mode → write the line → push into + /// `self.entries`. + /// + /// Why: appending (not rewriting) keeps the log durable and cheap even + /// as it grows across a long session. + /// + /// Return: `Ok(())` on success; an `io::Error` if serialization or + /// any filesystem operation fails. pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { let line = serde_json::to_string(&entry)? + "\n"; let parent = self.path.parent().unwrap(); @@ -54,6 +73,7 @@ impl EditLog { Ok(()) } + /// Number of edit entries recorded so far in this log. pub fn len(&self) -> usize { self.entries.len() } diff --git a/src/model/memory.rs b/src/model/memory.rs index d9d5e2c..5253f13 100644 --- a/src/model/memory.rs +++ b/src/model/memory.rs @@ -1,6 +1,11 @@ +//! Long-term agent memory: markdown files with YAML-ish frontmatter storing +//! lessons/references, plus slugified filenames and export/import helpers. + use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +/// A single memory entry (lesson, reference, etc.) with frontmatter +/// metadata and free-form markdown content. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memory { pub name: String, @@ -18,6 +23,17 @@ pub struct Memory { } impl Memory { + /// Convert an arbitrary string into a filesystem-safe slug. + /// + /// Flow: lowercase → replace non-alphanumeric chars with `-` → + /// collapse/trim repeated `-` by splitting on it and rejoining + /// non-empty parts. + /// + /// Why: rejects empty or overly long (>80 char) results so callers + /// don't write memories with degenerate or unwieldy filenames. + /// + /// Return: `Some(slug)` on success, `None` if the input slugifies to + /// empty or exceeds 80 characters. pub fn slugify(s: &str) -> Option { let slug: String = s .to_lowercase() @@ -35,11 +51,27 @@ impl Memory { Some(slug) } + /// Compute the on-disk path for a memory of the given name. + /// + /// Why: falls back to a fixed `"memory"` slug when `name` slugifies + /// to nothing, so a path is always produced. pub fn path(memory_dir: &Path, name: &str) -> PathBuf { let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); slug_path(memory_dir, &format!("{}.md", slug)) } + /// Serialize this memory to markdown-with-frontmatter and write it + /// atomically to disk. + /// + /// Flow: build the frontmatter block (name/description/kind/timestamps/ + /// lifecycle/optional fields) → concatenate with body content → write + /// to a temp file → rename into place. + /// + /// Why: write-then-rename avoids leaving a half-written memory file if + /// the process is interrupted mid-write. + /// + /// Return: `Ok(())` on success, or an `io::Error` from directory + /// creation, the temp write, or the rename. pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> { let path = Self::path(memory_dir, &self.name); let parent = path.parent().unwrap(); @@ -65,12 +97,29 @@ impl Memory { Ok(()) } + /// Read and parse a memory file by name. + /// + /// Return: the parsed `Memory`, or an `io::Error` if the file is + /// missing or its frontmatter is malformed (see `parse`). pub fn read(memory_dir: &Path, name: &str) -> std::io::Result { let path = Self::path(memory_dir, name); let content = std::fs::read_to_string(&path)?; Self::parse(&content) } + /// Parse a memory file's contents (frontmatter + body) into a `Memory`. + /// + /// Flow: strip leading `---\n` → split on the first `\n---\n` into + /// frontmatter and body → parse frontmatter lines as `key: value` + /// pairs into a map → build `Memory` fields from the map with + /// sensible defaults for missing keys. + /// + /// Why: unknown/missing frontmatter keys degrade to defaults (e.g. + /// `kind` → "reference", `lifecycle` → "new") rather than failing, + /// so older or hand-edited memory files still parse. + /// + /// Return: `Err(InvalidData)` only if the `---` frontmatter delimiter + /// itself is missing; otherwise `Ok(Memory)`. pub fn parse(content: &str) -> std::io::Result { let content = content.strip_prefix("---\n").unwrap_or(content); let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); @@ -103,6 +152,9 @@ impl Memory { }) } + /// Delete a memory file by name, if it exists. + /// + /// Return: `Ok(())` whether or not the file existed. pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> { let path = Self::path(memory_dir, name); if path.exists() { @@ -111,6 +163,13 @@ impl Memory { Ok(()) } + /// List the slugs of all memory files in a directory. + /// + /// Flow: read the directory → keep entries ending in `.md` → exclude + /// the special `MEMORY.md` summary file → strip the `.md` suffix. + /// + /// Return: slugs (without extension); empty `Vec` if the directory + /// can't be read. pub fn list(memory_dir: &Path) -> Vec { let entries = match std::fs::read_dir(memory_dir) { Ok(e) => e, @@ -129,6 +188,14 @@ impl Memory { } } +/// Sanitize a raw filename into a safe path under `memory_dir`. +/// +/// Flow: replace any char that isn't alphanumeric, `.`, or `-` with `-` → +/// strip leading dots (prevents dotfiles / path traversal via `..`) → +/// join to `memory_dir`, falling back to `"memory.md"` if empty. +/// +/// Why: leading-dot stripping specifically blocks accidental hidden +/// files and `..`-style traversal attempts embedded in `raw`. pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { let clean: String = raw.chars() .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' }) @@ -137,6 +204,14 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean }) } +/// Export all memories in `memory_dir` to a single JSON file. +/// +/// Flow: list memory slugs → read+parse each into a `Memory` (skipping +/// any that fail) → serialize the collected `Vec` to pretty JSON +/// → write to `output`. +/// +/// Return: `Ok(())` on success, or an `io::Error` from serialization or +/// the write. pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> { let names = Memory::list(memory_dir); let lessons: Vec = names.iter() @@ -147,6 +222,17 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> { std::fs::write(output, data)?; Ok(()) } +/// Import memories from a JSON export file into `memory_dir`, skipping +/// duplicates. +/// +/// Flow: read+JSON-decode `input` into `Vec` → build a set of +/// existing slugs in `memory_dir` → for each lesson not already present +/// (by slug), write it to disk and count it. +/// +/// Why: slug-based dedup makes repeated imports idempotent — re-running +/// import on the same file won't overwrite or duplicate existing memories. +/// +/// Return: the number of memories actually imported (skips existing ones). pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result { let data = std::fs::read_to_string(input)?; let lessons: Vec = serde_json::from_str(&data) diff --git a/src/model/mod.rs b/src/model/mod.rs index 849c319..96839ea 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,6 @@ +//! Persistence and domain model layer: sessions, conversations, memory, +//! message log (SQLite), edit log, and app/settings config. + pub mod app_config; pub mod editlog; pub mod memory; diff --git a/src/model/msglog/blobs.rs b/src/model/msglog/blobs.rs index 4da2f9f..3271796 100644 --- a/src/model/msglog/blobs.rs +++ b/src/model/msglog/blobs.rs @@ -1,6 +1,15 @@ +//! Binary blob storage in the message-log SQLite database (e.g. images, +//! attachments), keyed by session id and an arbitrary blob key. + use rusqlite::{Connection, params}; use anyhow::Result; +/// Insert or overwrite a blob for a session under `blob_key`. +/// +/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs` +/// keyed on `(session_id, blob_key)`. +/// +/// Return: `Ok(())` on success, or the underlying SQLite error. pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> { let created_at = chrono::Utc::now().timestamp_millis(); conn.execute( @@ -10,6 +19,10 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u Ok(()) } +/// Fetch a blob's bytes for a session by key. +/// +/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row +/// exists, `Err` for any other SQLite failure. pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result>> { let result = conn.query_row( "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", @@ -23,6 +36,10 @@ pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Res } } +/// Delete a blob for a session by key. +/// +/// Return: `Ok(true)` if a row was deleted, `Ok(false)` if no matching +/// row existed. #[allow(dead_code)] pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result { let rows = conn.execute( @@ -32,6 +49,10 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul Ok(rows > 0) } +/// List all blob keys stored for a session, oldest first. +/// +/// Return: `Ok(Vec)` of keys ordered by `created_at`, or the +/// underlying SQLite error. pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC" diff --git a/src/model/msglog/mod.rs b/src/model/msglog/mod.rs index 9f2fc7b..a3d9f35 100644 --- a/src/model/msglog/mod.rs +++ b/src/model/msglog/mod.rs @@ -1,3 +1,6 @@ +//! SQLite-backed message log: per-session `messages.sqlite` storing chat +//! messages, blobs, and archive/summary metadata. + pub mod blobs; pub mod query; pub mod schema; @@ -5,6 +8,14 @@ pub mod schema; pub use blobs::store_blob; pub use query::insert_message; +/// Open (creating if needed) a session's `messages.sqlite` and ensure its +/// schema is initialized. +/// +/// Flow: resolve `/messages.sqlite` → create parent dirs → +/// open a SQLite connection → run `schema::init_schema`. +/// +/// Return: an open, schema-ready `Connection`, or an error if any step +/// fails. pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result { let path = session_dir.join("messages.sqlite"); if let Some(parent) = path.parent() { diff --git a/src/model/msglog/query.rs b/src/model/msglog/query.rs index 236bd4d..94140c4 100644 --- a/src/model/msglog/query.rs +++ b/src/model/msglog/query.rs @@ -1,7 +1,16 @@ +//! Insert queries against the message log's `messages` table. + use rusqlite::{Connection, params}; use anyhow::Result; use crate::dto::chat::message::{ChatMessage, Role}; +/// Insert a chat message into the session's message log. +/// +/// Flow: extract optional content/tool_call_id/tool_name → serialize +/// `tool_calls` to a JSON string if present → map `Role` to its string +/// column value → `INSERT` the row with the current timestamp. +/// +/// Return: the new row's `rowid` on success, or the underlying error. pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result { let content = msg.content.as_deref(); let tool_call_id = msg.tool_call_id.as_deref(); diff --git a/src/model/msglog/schema.rs b/src/model/msglog/schema.rs index c34446f..6c5c756 100644 --- a/src/model/msglog/schema.rs +++ b/src/model/msglog/schema.rs @@ -1,6 +1,15 @@ +//! SQLite schema definition for the message log database. + use rusqlite::Connection; use anyhow::Result; +/// Create the message log's tables and indexes if they don't already +/// exist (`messages`, `archives`, `blobs`). +/// +/// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe +/// to call on every `open_or_create`. +/// +/// Return: `Ok(())` on success, or the underlying SQLite error. pub fn init_schema(conn: &Connection) -> Result<()> { conn.execute_batch( " diff --git a/src/model/msglog/summary.rs b/src/model/msglog/summary.rs index ec783e3..bf68e84 100644 --- a/src/model/msglog/summary.rs +++ b/src/model/msglog/summary.rs @@ -1,5 +1,9 @@ +//! Session archive/summary metadata tracked alongside the message log +//! (title, model, counts, and a rolling text summary). + use serde::{Deserialize, Serialize}; +/// Summary metadata for one archived/summarized session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SummaryRecord { pub session_id: String, @@ -13,6 +17,8 @@ pub struct SummaryRecord { } impl SummaryRecord { + /// Create a fresh summary record with zeroed counts and an empty + /// summary, timestamped to now. pub fn new(session_id: String, title: String, model: String) -> Self { let now = chrono::Utc::now().timestamp_millis(); SummaryRecord { @@ -27,11 +33,13 @@ impl SummaryRecord { } } + /// Replace the summary text and bump `updated_at`. pub fn update_summary(&mut self, summary: String) { self.summary = summary; self.updated_at = chrono::Utc::now().timestamp_millis(); } + /// Add to the running message/token counts and bump `updated_at`. pub fn increment_counts(&mut self, messages: usize, tokens: usize) { self.message_count += messages; self.token_count += tokens; diff --git a/src/model/session.rs b/src/model/session.rs index 2b3cfb8..ef44e4d 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -1,7 +1,12 @@ +//! Session metadata: id, title, workspace roots, and message/token counts, +//! persisted as `session.json` per session directory. + use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use chrono::Utc; +/// Metadata for one conversation session (distinct from the message +/// history itself, which lives in `Conversation`/the msglog). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: String, @@ -17,6 +22,8 @@ pub struct Session { } impl Session { + /// Create a new session with the given id/title, defaulting the + /// model, workspace root (current dir), and counters. pub fn new(id: String, title: String) -> Self { let now = Utc::now().timestamp_millis(); Session { @@ -33,14 +40,25 @@ impl Session { } } + /// Compute this session's directory under `/sessions/`. pub fn session_dir(&self, base_dir: &Path) -> PathBuf { base_dir.join("sessions").join(&self.id) } + /// Compute this session's `conversation.json` path. pub fn conversation_path(&self, base_dir: &Path) -> PathBuf { self.session_dir(base_dir).join("conversation.json") } + /// Persist this session's metadata to `session.json`, atomically. + /// + /// Flow: ensure the session directory exists → serialize to pretty + /// JSON → write to `session.json.tmp` → rename over `session.json`. + /// + /// Why: write-then-rename avoids a torn/partial `session.json` if + /// interrupted mid-write. + /// + /// Return: `Ok(())` on success, or an `io::Error` from any step. pub fn save(&self, base_dir: &Path) -> std::io::Result<()> { let dir = self.session_dir(base_dir); std::fs::create_dir_all(&dir)?; @@ -52,6 +70,10 @@ impl Session { Ok(()) } + /// Load a session's metadata by id from `/sessions//session.json`. + /// + /// Return: the parsed `Session`, or an `io::Error` if the file is + /// missing or malformed. pub fn load(id: &str, base_dir: &Path) -> std::io::Result { let path = base_dir.join("sessions").join(id).join("session.json"); let data = std::fs::read_to_string(path)?; @@ -59,6 +81,14 @@ impl Session { Ok(session) } + /// List all loadable sessions under `/sessions/`. + /// + /// Flow: read the sessions directory → keep subdirectories → attempt + /// `Session::load` for each by its directory name, discarding any + /// that fail to load. + /// + /// Return: a `Vec`, empty if the directory can't be read or + /// contains no valid sessions. pub fn list(base_dir: &Path) -> Vec { let sessions_dir = base_dir.join("sessions"); let entries = match std::fs::read_dir(&sessions_dir) { diff --git a/src/model/session_lock.rs b/src/model/session_lock.rs index e076423..be1a3d5 100644 --- a/src/model/session_lock.rs +++ b/src/model/session_lock.rs @@ -1,12 +1,19 @@ +//! PID-file based advisory lock preventing two processes from operating on +//! the same session directory concurrently. + use std::path::{Path, PathBuf}; use std::fs; +/// A PID-file lock (`/.lock`) tied to the current process, +/// auto-removed on drop. pub struct SessionLock { path: PathBuf, pid: u32, } impl SessionLock { + /// Construct a lock handle for a session directory (does not acquire + /// the lock yet — call `try_lock`). pub fn new(session_dir: &Path) -> Self { SessionLock { path: session_dir.join(".lock"), @@ -14,6 +21,19 @@ impl SessionLock { } } + /// Attempt to acquire the session lock. + /// + /// Flow: if `.lock` exists, read the PID inside it and check + /// `is_alive` — if that process is still running, fail to acquire → + /// otherwise (no lock file, unreadable PID, or dead owner) write our + /// own PID into `.lock` and succeed. + /// + /// Why: a stale lock file from a crashed process must not permanently + /// block new sessions, so liveness is re-checked via `kill(pid, 0)` + /// rather than trusting the file's mere existence. + /// + /// Return: `Ok(true)` if acquired, `Ok(false)` if another live + /// process holds it, `Err` on I/O failure. pub fn try_lock(&self) -> std::io::Result { if self.path.exists() { let content = fs::read_to_string(&self.path).unwrap_or_default(); @@ -27,10 +47,12 @@ impl SessionLock { Ok(true) } + /// Explicitly release the lock by removing the lock file. pub fn unlock(&self) { let _ = fs::remove_file(&self.path); } + /// Check whether a process with the given PID is currently alive. fn is_alive(&self, pid: u32) -> bool { // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // whether the process exists and the caller has permission to signal @@ -40,6 +62,8 @@ impl SessionLock { } impl Drop for SessionLock { + /// Release the lock automatically when the guard goes out of scope, + /// so an ungracefully-exited process doesn't leave a dangling lock. fn drop(&mut self) { let _ = fs::remove_file(&self.path); } diff --git a/src/model/settings.rs b/src/model/settings.rs index 0efcf97..ec3a602 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -1,5 +1,15 @@ +//! User-configurable settings persisted as JSON in the store's base directory. +//! +//! `Settings::load` / `Settings::save` are the only entry points; every field +//! falls back to a hardcoded default via `Default for Settings` when the file +//! is missing or fails to parse. + use serde::{Deserialize, Serialize}; +/// Controls how much network access the agent is permitted during a session. +/// +/// `Off` disables outbound requests entirely, `ReadOnly` allows fetches but +/// no mutating calls, `Full` permits everything. Defaults to `Off`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Default)] pub enum InternetMode { @@ -11,6 +21,10 @@ pub enum InternetMode { +/// Top-level application settings, serialized to `settings.json` in the store dir. +/// +/// Why: a single flat struct rather than nested config so the JSON file stays +/// human-editable; unknown/missing fields on load fall back to `Default`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub internet_mode: InternetMode, @@ -49,6 +63,12 @@ impl Default for Settings { } impl Settings { + /// Load settings from `/settings.json`. + /// + /// Flow: read file → parse JSON → fall back to `Settings::default()` on + /// any failure (missing file, unreadable, malformed JSON). + /// + /// Return: always succeeds; never surfaces I/O or parse errors to the caller. pub fn load() -> Self { let store = super::store::Store::new(); let path = store.base_dir.join("settings.json"); @@ -58,6 +78,11 @@ impl Settings { .unwrap_or_default() } + /// Serialize and write settings to `/settings.json`. + /// + /// Flow: ensure base dir exists → pretty-print JSON → write to disk. + /// + /// Return: `Err` if the directory can't be created or the write fails. pub fn save(&self) -> std::io::Result<()> { let store = super::store::Store::new(); std::fs::create_dir_all(&store.base_dir)?; diff --git a/src/model/store.rs b/src/model/store.rs index 306e2f0..686ddc6 100644 --- a/src/model/store.rs +++ b/src/model/store.rs @@ -1,6 +1,12 @@ +//! Filesystem layout for zesdex's persistent and scratch data directories. + use std::path::PathBuf; use serde::{Deserialize, Serialize}; +/// Resolved paths for all data directories zesdex reads from and writes to. +/// +/// Why: centralizing path computation here means every consumer agrees on +/// where memory, scratch, session images, and downloads live. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Store { pub base_dir: PathBuf, @@ -11,6 +17,12 @@ pub struct Store { } impl Store { + /// Compute the standard set of zesdex data directory paths. + /// + /// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir; + /// scratch root comes from the OS temp dir instead, since it's disposable. + /// + /// Why: paths are computed, not created — call `ensure_dirs` before use. pub fn new() -> Self { let base = dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".local/share")) @@ -25,6 +37,9 @@ impl Store { } } + /// Create all store directories (base, memory, scratch, session images, downloads) if missing. + /// + /// Return: `Err` on the first directory that fails to create. pub fn ensure_dirs(&self) -> std::io::Result<()> { std::fs::create_dir_all(&self.base_dir)?; std::fs::create_dir_all(&self.memory_dir)?; diff --git a/src/resources.rs b/src/resources.rs index cb86ca1..68012a9 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,3 +1,6 @@ +//! Compile-time embedded text resources: the system prompt, tool descriptions, +//! and the in-app help screen shown on Ctrl+H. + pub const SYSTEM_PROMPT: &str = include_str!("../src-misc/system-prompt.txt"); pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt"); diff --git a/src/service/mod.rs b/src/service/mod.rs index 0296234..87712b6 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,2 +1,4 @@ +//! External service integrations: the LLM provider HTTP client and OAuth flows. + pub mod provider; pub mod oauth; diff --git a/src/service/oauth/loopback.rs b/src/service/oauth/loopback.rs index 5046385..83fc152 100644 --- a/src/service/oauth/loopback.rs +++ b/src/service/oauth/loopback.rs @@ -1,28 +1,46 @@ +//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects. + use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; +/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth +/// `?code=...` redirect and serves back a static confirmation page. pub struct LoopbackServer { listener: TcpListener, port: u16, } impl LoopbackServer { + /// Bind to an OS-assigned free port on localhost. + /// + /// Return: `Err` if the loopback interface can't be bound. pub fn bind() -> std::io::Result { let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); Ok(LoopbackServer { listener, port }) } + /// The redirect URI to hand to the OAuth authorization endpoint. pub fn redirect_uri(&self) -> String { format!("http://127.0.0.1:{}/callback", self.port) } + /// Block until one HTTP request arrives, then extract its `code` query param. + /// + /// Flow: accept one connection → apply read timeout → parse request line + /// → respond 200/400 depending on whether a code was found. + /// + /// Return: `Err(InvalidData)` if no `code` param is present in the request. pub fn wait_for_code(&self, timeout_ms: u64) -> std::io::Result { let (mut stream, _) = self.listener.accept()?; stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?; Self::read_callback(&mut stream) } + /// Read and parse a single HTTP callback request off `stream`, replying with a status page. + /// + /// Why: writes the HTTP response before returning so the browser tab + /// shows a result regardless of whether the code was found. fn read_callback(stream: &mut TcpStream) -> std::io::Result { let mut buf = [0u8; 4096]; let n = stream.read(&mut buf)?; @@ -38,6 +56,9 @@ impl LoopbackServer { code.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")) } + /// Extract and percent-decode the `code` query parameter from an HTTP request line. + /// + /// Return: `None` if the request is malformed or has no `code` param. fn extract_code(request: &str) -> Option { let line = request.lines().next()?; let path = line.split(' ').nth(1)?; @@ -52,6 +73,11 @@ impl LoopbackServer { } } +/// Percent-decode a string (e.g. `%20` -> space). +/// +/// Why: invalid escape sequences (missing/non-hex digits) are passed through +/// literally as `%` rather than erroring, since this only handles a redirect +/// query param, not untrusted binary data. fn urlencoding(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.chars(); diff --git a/src/service/oauth/manager.rs b/src/service/oauth/manager.rs index f990164..67624a3 100644 --- a/src/service/oauth/manager.rs +++ b/src/service/oauth/manager.rs @@ -1,6 +1,9 @@ +//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building. + use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +/// An OAuth access token plus its refresh token and absolute expiry (unix seconds). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthToken { pub access_token: String, @@ -12,6 +15,7 @@ pub struct OAuthToken { impl OAuthToken { } +/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthConfig { pub auth_url: String, @@ -33,6 +37,7 @@ impl Default for OAuthConfig { } } +/// Drives one OAuth flow: holds config, the current token (if any), and an HTTP client. pub struct OAuthManager { pub config: OAuthConfig, pub token: Option, @@ -40,6 +45,7 @@ pub struct OAuthManager { } impl OAuthManager { + /// Create a manager for the given provider config with no token yet acquired. pub fn new(config: OAuthConfig) -> Self { OAuthManager { config, @@ -48,6 +54,12 @@ impl OAuthManager { } } + /// Exchange an authorization code for an access token via the provider's token endpoint. + /// + /// Flow: POST form-encoded grant to `token_url` → parse JSON body → + /// compute absolute `expires_at` from `expires_in` → store on `self.token`. + /// + /// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field. pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> { let mut params = std::collections::HashMap::new(); params.insert("grant_type", "authorization_code"); @@ -83,13 +95,17 @@ impl OAuthManager { Ok(()) } + /// Build the provider's authorization URL with PKCE and state params attached. + /// + /// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously + /// this silently fell back to https://example.com, which produced a valid-looking + /// auth URL pointing at the wrong server and leaked client credentials in + /// query params. Returning an empty string signals failure to callers, who + /// can prompt the user to fix the OAuth config instead of starting a flow + /// against a wrong host. + /// + /// Return: the full authorization URL, or `""` if `auth_url` is empty/unparseable. pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String { - // Refuse to build a URL if `auth_url` is missing or invalid. Previously this - // silently fell back to https://example.com, which produced a valid-looking - // auth URL pointing at the wrong server and leaked client credentials in - // query params. Returning an empty string signals failure to callers, who - // can prompt the user to fix the OAuth config instead of starting a flow - // against a wrong host. let mut url = match url::Url::parse(&self.config.auth_url) { Ok(u) if !self.config.auth_url.is_empty() => u, _ => { diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index e9e686b..c00f4a2 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -1,3 +1,6 @@ +//! OAuth 2.0 authorization-code + PKCE support: verifier/challenge generation, +//! the loopback redirect server, and the token-exchange manager. + pub mod pkce; pub mod loopback; pub mod manager; diff --git a/src/service/oauth/pkce.rs b/src/service/oauth/pkce.rs index ec2f080..2cec0b3 100644 --- a/src/service/oauth/pkce.rs +++ b/src/service/oauth/pkce.rs @@ -1,20 +1,27 @@ +//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows. + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use sha2::{Sha256, Digest}; const VERIFIER_LENGTH: usize = 64; +/// A randomly generated, base64url-encoded PKCE code verifier. pub struct CodeVerifier(String); impl CodeVerifier { + /// Generate a fresh random code verifier. pub fn new() -> Self { let bytes: Vec = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect(); CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes)) } + /// Borrow the verifier as a string, to send in the token exchange request. pub fn as_str(&self) -> &str { &self.0 } + /// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send + /// in the authorization request. pub fn challenge(&self) -> CodeChallenge { let mut hasher = Sha256::new(); hasher.update(self.0.as_bytes()); @@ -23,6 +30,11 @@ impl CodeVerifier { } } +/// Produce one pseudo-random byte from the sub-second component of the system clock. +/// +/// Why: avoids pulling in a `rand` dependency for a short-lived, non-cryptographic +/// verifier; each byte only needs to be unpredictable enough to prevent code +/// interception, not cryptographically secure. fn rand_byte() -> u8 { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() @@ -35,9 +47,11 @@ fn rand_byte() -> u8 { (nanos & 0xFF) as u8 } +/// The S256-derived code challenge sent in the authorization request URL. pub struct CodeChallenge(String); impl CodeChallenge { + /// Borrow the challenge as a string. pub fn as_str(&self) -> &str { &self.0 } diff --git a/src/service/provider.rs b/src/service/provider.rs index 8638493..5d3d7a2 100644 --- a/src/service/provider.rs +++ b/src/service/provider.rs @@ -1,3 +1,6 @@ +//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs, +//! supporting both non-streaming and SSE-streaming requests with automatic retry. + use std::time::Duration; use anyhow::Result; @@ -12,6 +15,10 @@ pub const DEFAULT_API_KEY: &str = "sk-5dd268d88adb496b-818beb-6bc7498e"; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +/// Blocking HTTP client for a single LLM provider endpoint. +/// +/// Holds the reqwest client, credentials, and model/base URL selection used +/// by both the non-streaming and streaming chat completion calls. pub struct LlmClient { pub client: reqwest::blocking::Client, pub api_key: String, @@ -20,6 +27,14 @@ pub struct LlmClient { } impl LlmClient { + /// Construct a client, falling back to built-in defaults for empty inputs. + /// + /// Flow: empty api_key/model → substitute defaults → build reqwest client + /// with connect/request timeouts (falling back to an untimed client if + /// the builder fails) → normalize base_url. + /// + /// Why: empty strings are treated as "unset" rather than errors so callers + /// can pass through unconfigured settings without special-casing them. pub fn new(mut api_key: String, model: String, base_url: Option) -> Self { if api_key.is_empty() { api_key = DEFAULT_API_KEY.to_string(); @@ -48,6 +63,16 @@ impl LlmClient { } } + /// Send a non-streaming chat completion request and return the assistant's reply. + /// + /// Flow: build request → POST with retry loop (up to 10 attempts, 2s backoff) + /// → parse JSON response → extract first choice's message and token usage. + /// + /// Why: retries transient failures but aborts immediately on 401/403, since + /// those indicate a bad API key that retrying won't fix. + /// + /// Return: `Err` if all retries are exhausted, an auth error occurs, or the + /// response has no choices. pub fn chat_with_tools_non_streaming( &self, messages: &[ChatMessage], @@ -177,6 +202,17 @@ impl LlmClient { } } + /// Perform one streaming chat completion request, parsing SSE events until completion. + /// + /// Flow: POST → read body in chunks → advance past valid UTF-8 boundary → + /// feed into `SseParser` → dispatch each `StreamEvent` to `on_event` and + /// accumulate in `StreamedTurn` → return assembled assistant message on `Done`. + /// + /// Why: chunk-by-chunk UTF-8-aware reads avoid splitting multi-byte sequences; + /// returns `aborted` error if `on_event` returns false so the caller can cancel. + /// + /// Return: assembled message + optional usage on success, `Err` on read + /// failure, non-2xx status, or callback-initiated abort. fn try_stream_once( &self, req: &ChatRequest, diff --git a/src/tool/bash_tools.rs b/src/tool/bash_tools.rs index 0efb5bc..e029b9e 100644 --- a/src/tool/bash_tools.rs +++ b/src/tool/bash_tools.rs @@ -1,8 +1,12 @@ +//! Tool implementations for interacting with background bash jobs: `bash_output` +//! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +/// Tool: fetch buffered output from a background bash job by `job_id`. pub struct BashOutput; impl Tool for BashOutput { @@ -39,6 +43,7 @@ impl Tool for BashOutput { } } +/// Tool: terminate a running background bash job by `job_id`. pub struct BashKill; impl Tool for BashKill { diff --git a/src/tool/fs/delete.rs b/src/tool/fs/delete.rs index bd2c5b2..281a063 100644 --- a/src/tool/fs/delete.rs +++ b/src/tool/fs/delete.rs @@ -1,3 +1,5 @@ +//! Tool: `delete` — remove a file or empty directory relative to a workspace root. + use std::fs; use std::path::PathBuf; use serde_json::{json, Value}; @@ -7,6 +9,7 @@ use super::super::ToolCtx; use super::super::resolve_path; use super::helpers::arg_str; +/// Tool: delete a file or empty directory. Refuses non-empty directories. pub struct Delete; impl Tool for Delete { @@ -31,6 +34,10 @@ impl Tool for Delete { }) } + /// Delete a file or empty directory. Returns success message or errors on failure. + /// + /// Flow: resolve path → check existence → check dir/file → remove. + /// Only empty directories are deletable (non-empty returns an error). fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = arg_str(args, "path")?; let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; diff --git a/src/tool/fs/edit.rs b/src/tool/fs/edit.rs index f50d9e6..11b9add 100644 --- a/src/tool/fs/edit.rs +++ b/src/tool/fs/edit.rs @@ -1,3 +1,5 @@ +//! Tool: `edit` — replace a substring in a file with a new string. + use std::fs; use std::path::PathBuf; use serde_json::{json, Value}; @@ -8,6 +10,7 @@ use super::super::resolve_path; use super::super::check_graduated_checks; use super::helpers::arg_str; +/// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true. pub struct Edit; impl Tool for Edit { @@ -48,6 +51,13 @@ impl Tool for Edit { }) } + /// Perform the in-file string replacement. + /// + /// Flow: validate args → resolve path → read file → count occurrences → + /// replace one or all → write back → report byte delta (+ optional graduated checks). + /// + /// Why: requires a non-empty `reason` and a non-empty `old` string to prevent + /// accidental identity edits. Enforces uniqueness unless `replace_all` is set. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = arg_str(args, "path")?; let old = arg_str(args, "old")?; diff --git a/src/tool/fs/helpers.rs b/src/tool/fs/helpers.rs index 4526770..d1f0a53 100644 --- a/src/tool/fs/helpers.rs +++ b/src/tool/fs/helpers.rs @@ -1,7 +1,14 @@ +//! Shared helpers for filesystem tools: extracting string arguments from JSON +//! and producing user-friendly "not found" diagnostics. + use std::path::Path; use serde_json::Value; use anyhow::{Result, anyhow}; +/// Extract a required string argument from a JSON args map. +/// +/// Return: the value as `String` if present and a string type; `Err` if missing +/// or of a different JSON type (null, number, boolean, array, object). pub fn arg_str(args: &Value, name: &str) -> Result { args.get(name) .and_then(|v| v.as_str()) @@ -9,6 +16,12 @@ pub fn arg_str(args: &Value, name: &str) -> Result { .ok_or_else(|| anyhow!("missing required argument: {}", name)) } +/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist. +/// +/// Checks whether the resolved path canonically falls inside any workspace root +/// and reports either "path outside workspaces" or "path does not exist" accordingly. +/// +/// Return: a one-line description of the resolution failure. pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String { let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let in_ws = ctx.workspaces.iter().any(|w| { diff --git a/src/tool/fs/mod.rs b/src/tool/fs/mod.rs index 4633e67..7ada844 100644 --- a/src/tool/fs/mod.rs +++ b/src/tool/fs/mod.rs @@ -1,3 +1,6 @@ +//! Filesystem tool implementations: read, write, edit, and delete operations +//! on workspace-rooted paths. + pub mod delete; pub mod edit; pub mod helpers; diff --git a/src/tool/fs/read.rs b/src/tool/fs/read.rs index db976a0..6f82875 100644 --- a/src/tool/fs/read.rs +++ b/src/tool/fs/read.rs @@ -1,3 +1,5 @@ +//! Tool: `read` — display file contents with line numbers. + use std::fs; use std::path::PathBuf; use serde_json::{json, Value}; @@ -7,6 +9,7 @@ use super::super::ToolCtx; use super::super::resolve_path; use super::helpers::{arg_str, not_found_help}; +/// Tool: read a file and display it with line numbers, optionally truncated to `limit` lines. pub struct Read; impl Tool for Read { @@ -35,6 +38,13 @@ impl Tool for Read { }) } + /// Read and display a file with line numbers. + /// + /// Flow: resolve path → if not found, call `not_found_help` for diagnostic → + /// read entire file → enumerate and format lines → optionally truncate by `limit`. + /// + /// Return: line-numbered content; `not_found_help` message if the path doesn't + /// exist; a "is a directory" message if the path points at a directory. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = arg_str(args, "path")?; let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize); diff --git a/src/tool/fs/write.rs b/src/tool/fs/write.rs index 961fe5f..160a8c5 100644 --- a/src/tool/fs/write.rs +++ b/src/tool/fs/write.rs @@ -1,3 +1,5 @@ +//! Tool: `write` — write content to a file, creating parent directories on demand. + use std::fs; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; @@ -7,6 +9,7 @@ use super::super::resolve_path; use super::super::check_graduated_checks; use super::helpers::arg_str; +/// Tool: write content to a file, auto-creating parent directories as needed. pub struct Write; impl Tool for Write { @@ -39,6 +42,13 @@ impl Tool for Write { }) } + /// Write content to a file, creating parent directories as needed. + /// + /// Flow: validate args (non-empty reason) → resolve path → create parent + /// dirs → write file → report byte count (+ optional graduated checks). + /// + /// Why: requires a non-empty `reason` to discourage stray writes; parent + /// directories are created silently so the tool works for new paths. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = arg_str(args, "path")?; let content = arg_str(args, "content")?; diff --git a/src/tool/git_cred.rs b/src/tool/git_cred.rs index f1867b4..5026c6c 100644 --- a/src/tool/git_cred.rs +++ b/src/tool/git_cred.rs @@ -1,9 +1,12 @@ +//! Tool wrapper around `git credential` for store/get/erase operations. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use std::process::Command; use super::Tool; use super::ToolCtx; +/// Tool that shells out to `git credential ` to store, retrieve, or erase credentials. pub struct GitCred; impl Tool for GitCred { @@ -29,6 +32,15 @@ impl Tool for GitCred { }) } + /// Run `git credential `, forwarding stdin-less invocation to the git binary. + /// + /// Flow: extract `operation` arg → spawn `git credential ` → capture output. + /// + /// Why: `store`/`get`/`erase` are the only credential-helper subcommands git supports; + /// no stdin is piped, so this mainly surfaces helper output/errors rather than + /// performing an interactive credential exchange. + /// + /// Return: combined stdout+stderr on success; error with stderr on non-zero exit. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let operation = args.get("operation") .and_then(|v| v.as_str()) diff --git a/src/tool/git_operator.rs b/src/tool/git_operator.rs index 373cf36..df093a8 100644 --- a/src/tool/git_operator.rs +++ b/src/tool/git_operator.rs @@ -1,9 +1,12 @@ +//! Generic tool for running arbitrary git subcommands. + use std::process::Command; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +/// Tool that runs `git [args...]` and returns combined stdout/stderr. pub struct GitOperator; impl Tool for GitOperator { @@ -33,6 +36,16 @@ impl Tool for GitOperator { }) } + /// Run `git [args...]` and return its combined output. + /// + /// Flow: extract `operation` + `args` → spawn `git ` → trim and + /// join stdout/stderr. + /// + /// Why: no allowlist here — the model may run any git subcommand; destructive + /// operations are blocked upstream by `shell_filter::git`, not by this tool. + /// + /// Return: trimmed combined output on success; error including exit code and + /// stderr on failure. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let operation = args.get("operation") .and_then(|v| v.as_str()) diff --git a/src/tool/git_worktree.rs b/src/tool/git_worktree.rs index 76a869b..23d3793 100644 --- a/src/tool/git_worktree.rs +++ b/src/tool/git_worktree.rs @@ -1,9 +1,12 @@ +//! Tool for creating git worktrees under the session's worktrees directory. + use std::process::Command; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +/// Tool that creates a new git worktree (`git worktree add`) from a given base ref. pub struct GitWorktree; impl Tool for GitWorktree { @@ -32,6 +35,13 @@ impl Tool for GitWorktree { }) } + /// Create the worktree directory and run `git worktree add --checkout `. + /// + /// Flow: extract name/base_ref → create worktree dir under `ctx.worktrees_dir` → + /// spawn `git worktree add` → combine stdout/stderr. + /// + /// Return: success message with combined output on success; error including exit + /// code and stderr on failure. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let name = args.get("name") .and_then(|v| v.as_str()) diff --git a/src/tool/memory/forget.rs b/src/tool/memory/forget.rs index 3386c1b..68c0dc6 100644 --- a/src/tool/memory/forget.rs +++ b/src/tool/memory/forget.rs @@ -1,9 +1,12 @@ +//! Tool for deleting a persisted memory entry by name. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; use crate::model::memory::Memory; +/// Tool that removes a single memory entry from `ctx.memory_dir` by exact name. pub struct Forget; impl Tool for Forget { @@ -28,6 +31,12 @@ impl Tool for Forget { }) } + /// Delete the memory file matching `name` from disk. + /// + /// Flow: extract `name` → `Memory::remove` → confirmation string. + /// + /// Return: confirmation message on success; error if the memory does not exist + /// or the file could not be removed. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let name = args.get("name") .and_then(|v| v.as_str()) diff --git a/src/tool/memory/mod.rs b/src/tool/memory/mod.rs index f649cc6..9afb356 100644 --- a/src/tool/memory/mod.rs +++ b/src/tool/memory/mod.rs @@ -1,3 +1,5 @@ +//! Memory tools: `remember`, `recall`, and `forget` for persisted project memory entries. + pub mod forget; pub mod recall; pub mod remember; diff --git a/src/tool/memory/recall.rs b/src/tool/memory/recall.rs index 488a099..008ca6e 100644 --- a/src/tool/memory/recall.rs +++ b/src/tool/memory/recall.rs @@ -1,9 +1,12 @@ +//! Tool for reading a single memory entry or listing the whole memory index. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; use crate::model::memory::Memory; +/// Tool that reads one memory entry by name, or lists all entries when name is omitted. pub struct Recall; impl Tool for Recall { @@ -27,6 +30,12 @@ impl Tool for Recall { }) } + /// Read a specific memory entry, or fall back to listing all entries. + /// + /// Flow: if `name` present and non-empty → `Memory::read` and format as frontmatter + /// + body; otherwise → `list_all`. + /// + /// Return: formatted memory content, or the full index listing. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { if let Some(name) = args.get("name").and_then(|v| v.as_str()) { if name.is_empty() { @@ -48,6 +57,12 @@ impl Tool for Recall { } } +/// List every memory entry in `ctx.memory_dir` as a one-line summary index. +/// +/// Flow: `Memory::list` names → for each, try `Memory::read` for kind/description → +/// fall back to bare name if the file can't be parsed. +/// +/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)"). fn list_all(ctx: &ToolCtx) -> Result { let names = Memory::list(&ctx.memory_dir); if names.is_empty() { diff --git a/src/tool/memory/remember.rs b/src/tool/memory/remember.rs index 9b00a34..d54394f 100644 --- a/src/tool/memory/remember.rs +++ b/src/tool/memory/remember.rs @@ -1,9 +1,12 @@ +//! Tool for saving a new memory entry to persistent project memory. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; use crate::model::memory::Memory; +/// Tool that writes a new `Memory` entry (name/description/content/kind) to disk. pub struct Remember; impl Tool for Remember { @@ -41,6 +44,16 @@ impl Tool for Remember { }) } + /// Build a `Memory` from the given args and persist it to `ctx.memory_dir`. + /// + /// Flow: extract name/description/content/kind → validate name via `Memory::slugify` + /// → construct `Memory` with `lifecycle: "new"` and current timestamps → + /// `memory.write`. + /// + /// Why: name must slugify to a valid filename (alphanumeric + hyphens, 1-80 chars) + /// since it's used directly as the on-disk file identifier. + /// + /// Return: confirmation string on success; error if name is invalid or the write fails. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let name = args.get("name") .and_then(|v| v.as_str()) diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 3530e24..342d074 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -1,3 +1,5 @@ +//! Tool trait, execution context, and the registry of all 28 built-in tools. + use std::path::PathBuf; use serde_json::Value; use anyhow::Result; @@ -16,6 +18,7 @@ pub mod shell_filter; pub mod utility; pub mod workflow; +/// Common interface every agent-invocable tool implements: name, JSON schema, and execution. pub trait Tool: Send + Sync { fn name(&self) -> &'static str; fn description(&self) -> &'static str; @@ -23,6 +26,7 @@ pub trait Tool: Send + Sync { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; } +/// A project-defined rule that flags a matching file path or content pattern for review. #[derive(Debug, Clone)] pub struct GraduatedCheck { pub name: String, @@ -30,6 +34,8 @@ pub struct GraduatedCheck { pub rule: String, } +/// Shared execution context passed to every `Tool::run` call: workspace roots, session +/// paths, and cached directory state. #[derive(Clone)] pub struct ToolCtx { pub workspaces: Vec, @@ -42,6 +48,12 @@ pub struct ToolCtx { pub graduated_checks: Vec, } +/// Find which graduated checks apply to a given file path/content pair. +/// +/// Flow: for each check, match its `pattern` against `path` or its `rule` against +/// `content` (substring match) → collect matching check names. +/// +/// Return: names of all matching checks; empty if none match. pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec { let mut matches = Vec::new(); for check in checks { @@ -53,11 +65,13 @@ pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedChec } impl ToolCtx { + /// Start building a `ToolCtx` with `ToolCtxBuilder`'s defaults. pub fn builder() -> ToolCtxBuilder { ToolCtxBuilder::default() } } +/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`. pub struct ToolCtxBuilder { pub workspaces: Vec, pub session_dir: PathBuf, @@ -85,8 +99,11 @@ impl Default for ToolCtxBuilder { } impl ToolCtxBuilder { + /// Set the session directory. pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } + /// Set the origin (main process vs. daemon-attached). pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self } + /// Consume the builder and produce the final `ToolCtx`. pub fn build(self) -> ToolCtx { ToolCtx { workspaces: self.workspaces, @@ -101,6 +118,10 @@ impl ToolCtxBuilder { } } +/// Construct one instance of every built-in tool, in the fixed order exposed to the LLM. +/// +/// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan, +/// workflow, utility). pub fn all_tools() -> Vec> { vec![ Box::new(super::tool::fs::read::Read), @@ -131,10 +152,16 @@ pub fn all_tools() -> Vec> { ] } +/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands. +/// +/// Why: used by the harness to decide which tool calls need user confirmation/guard checks. pub fn tool_is_risky(name: &str) -> bool { matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") } +/// Convert a list of tools into the provider-facing `ToolDef` request schema. +/// +/// Return: one `ToolDef` per tool, in the same order as `tools`. pub fn tool_defs(tools: &[Box]) -> Vec { tools .iter() @@ -149,6 +176,18 @@ pub fn tool_defs(tools: &[Box]) -> Vec Result { let _parts: Vec<&str> = rel.splitn(2, '/').collect(); let (ws_idx, path) = if rel.starts_with('[') { diff --git a/src/tool/plan.rs b/src/tool/plan.rs index df8d858..82bd4c3 100644 --- a/src/tool/plan.rs +++ b/src/tool/plan.rs @@ -1,8 +1,11 @@ +//! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +/// Tool the model calls to present a step-by-step plan and enter plan mode. pub struct PlanEnter; impl Tool for PlanEnter { @@ -31,6 +34,10 @@ impl Tool for PlanEnter { }) } + /// Validate that both `plan` and `sign_off` are present; the actual plan text is + /// surfaced to the user by the harness rather than returned here. + /// + /// Return: fixed acknowledgement string on success; error if either arg is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let _plan = args.get("plan") .and_then(|v| v.as_str()) @@ -42,6 +49,7 @@ impl Tool for PlanEnter { } } +/// Tool the model calls to confirm it will follow the approved plan before executing it. pub struct PlanReady; impl Tool for PlanReady { @@ -66,6 +74,9 @@ impl Tool for PlanReady { }) } + /// Validate that a `confirmation` argument was supplied before exiting plan mode. + /// + /// Return: fixed "ready to execute" string on success; error if `confirmation` is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let _confirmation = args.get("confirmation") .and_then(|v| v.as_str()) diff --git a/src/tool/search.rs b/src/tool/search.rs index c4a251a..096572d 100644 --- a/src/tool/search.rs +++ b/src/tool/search.rs @@ -1,3 +1,5 @@ +//! Text search tools: `grep` (line matching) and `glob` (filename pattern matching). + use std::fs; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; @@ -7,6 +9,7 @@ use super::Tool; use super::ToolCtx; use super::resolve_path; +/// Tool that recursively searches text files under a directory for a literal substring. pub struct Grep; impl Tool for Grep { @@ -35,6 +38,16 @@ impl Tool for Grep { }) } + /// Recursively walk the resolved directory and collect matching lines. + /// + /// Flow: extract `pattern` + `path` → `resolve_path` (workspace-scoped) → bail if + /// missing/not a dir → `ignore::Walk` the tree → for each file, `read_to_string` + /// and substring-match each line → emit `::` rows. + /// + /// Why: `ignore::Walk` respects `.gitignore` and skips heavy dirs (e.g. `.git/`) + /// which is what the agent expects when running in real repos. + /// + /// Return: "no matches found" if empty, else a header + `path:line:text` rows. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let pattern = args.get("pattern") .and_then(|v| v.as_str()) @@ -80,6 +93,7 @@ impl Tool for Grep { } } +/// Tool that lists files under a directory matching a glob pattern. pub struct Glob; impl Tool for Glob { @@ -108,6 +122,17 @@ impl Tool for Glob { }) } + /// Walk the resolved directory and collect entries matching the glob pattern. + /// + /// Flow: extract pattern + path → `resolve_path` → build a `GlobSet` from the + /// joined absolute pattern → `ignore::Walk` the tree → keep entries that + /// match → sort → join with newlines, appending `/` for directories. + /// + /// Why: joining the workspace-relative pattern onto the resolved root lets users + /// supply familiar glob shapes (`**/*.rs`) while the sandbox still controls the + /// boundary. + /// + /// Return: sorted newline-joined matches; "no files match" sentinel if empty. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let pat_str = args.get("pattern") .and_then(|v| v.as_str()) diff --git a/src/tool/seqthink.rs b/src/tool/seqthink.rs index 1f0fff8..c5b0152 100644 --- a/src/tool/seqthink.rs +++ b/src/tool/seqthink.rs @@ -1,8 +1,12 @@ +//! Sequential-thinking tool: a no-side-effect echo that records reasoning steps. + use serde_json::{json, Value}; use anyhow::Result; use super::Tool; use super::ToolCtx; +/// Tool that accepts a reasoning step and returns it verbatim, giving the model a +/// structured way to surface its thought chain to the TUI. pub struct SeqThink; impl Tool for SeqThink { @@ -27,6 +31,12 @@ impl Tool for SeqThink { }) } + /// Return the `thought` string verbatim (or empty if missing). + /// + /// Why: there's no I/O or state mutation — the harness surfaces the text in the + /// TUI's reasoning pane. + /// + /// Return: the thought text, possibly empty; never an error. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let thought = args.get("thought").and_then(|v| v.as_str()).unwrap_or(""); Ok(thought.to_string()) diff --git a/src/tool/shell.rs b/src/tool/shell.rs index 843750f..f44c594 100644 --- a/src/tool/shell.rs +++ b/src/tool/shell.rs @@ -1,3 +1,5 @@ +//! Bash-shell execution tool with safety filters and optional timeout. + use std::process::Command; use std::time::Duration; use serde_json::{json, Value}; @@ -5,6 +7,8 @@ use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +/// Tool that runs `bash -c `, optionally in the background, with safety +/// filters applied before spawning. pub struct Bash; impl Tool for Bash { @@ -41,6 +45,19 @@ impl Tool for Bash { }) } + /// Run a bash command (foreground or background) with safety filters and a timeout. + /// + /// Flow: extract args → run `check_credential_read` then `check_git_destructive` + /// (bail if either rejects) → branch on `run_in_background`: if true, hand off + /// to the bg-bash subsystem and return the job ID; else spawn `bash -c`, + /// poll with `try_wait`, kill on timeout, format combined stdout+stderr. + /// + /// Why: the safety filters run unconditionally so background jobs are also gated; + /// the timeout is enforced by polling the child rather than relying on a libc alarm + /// so cleanup stays in Rust. + /// + /// Return: exit-code + elapsed-seconds summary line (plus captured output) for + /// foreground runs, or the job ID for background runs. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let cmd = args.get("command") .and_then(|v| v.as_str()) diff --git a/src/tool/shell_filter/credentials.rs b/src/tool/shell_filter/credentials.rs index 1a22a41..8c1c313 100644 --- a/src/tool/shell_filter/credentials.rs +++ b/src/tool/shell_filter/credentials.rs @@ -1,5 +1,16 @@ +//! Block shell commands that try to read common credential files or secrets. + use anyhow::Result; +/// Reject shell commands whose lowercased form contains any known credential-read pattern. +/// +/// Flow: lowercase the command → for each pattern, substring-match → bail with the +/// matching pattern on the first hit. +/// +/// Why: catches `cat ~/.ssh/id_rsa`, `grep token= foo.txt`, `.git-credentials`, +/// cloud-CLI credential paths, etc., before the bash tool spawns anything. +/// +/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise. pub fn check_credential_read(cmd: &str) -> Result<()> { let patterns = [ "cat ~/.ssh", diff --git a/src/tool/shell_filter/git.rs b/src/tool/shell_filter/git.rs index 84c34ce..f469216 100644 --- a/src/tool/shell_filter/git.rs +++ b/src/tool/shell_filter/git.rs @@ -1,5 +1,16 @@ +//! Block shell commands that perform destructive or hard-to-reverse git operations. + use anyhow::Result; +/// Reject shell commands whose lowercased form contains any known destructive git pattern. +/// +/// Flow: lowercase the command → for each pattern, substring-match → bail with the +/// matching pattern on the first hit. +/// +/// Why: hard-resets, force-pushes, `clean -fdx`, `filter-branch`, etc. can destroy +/// uncommitted work or rewrite shared history; the bash tool refuses to run them. +/// +/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise. pub fn check_git_destructive(cmd: &str) -> Result<()> { let patterns = [ "force-push", diff --git a/src/tool/shell_filter/mod.rs b/src/tool/shell_filter/mod.rs index 77f9980..930b17c 100644 --- a/src/tool/shell_filter/mod.rs +++ b/src/tool/shell_filter/mod.rs @@ -1,2 +1,4 @@ +//! Pre-execution safety filters applied to shell commands before they're spawned. + pub mod credentials; pub mod git; diff --git a/src/tool/utility/cd.rs b/src/tool/utility/cd.rs index 271b567..19712b8 100644 --- a/src/tool/utility/cd.rs +++ b/src/tool/utility/cd.rs @@ -1,8 +1,11 @@ +//! `cd` tool: verify and resolve a workspace-relative directory path. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +/// Tool that resolves a workspace-relative path and reports whether it exists and is a dir. pub struct Cd; impl Tool for Cd { @@ -27,6 +30,16 @@ impl Tool for Cd { }) } + /// Resolve `path` against the workspace roots and report its status. + /// + /// Flow: extract `path` → `resolve_path` (sandboxed to `ctx.workspaces`) → + /// check `exists()` and `is_dir()` → canonicalize → return canonical path. + /// + /// Why: the agent has no persistent cwd between tool calls; "cd" here is purely a + /// verification + canonicalization helper rather than a state change. + /// + /// Return: canonical path on success; explicit "does not exist" / "not a directory" + /// message (still `Ok`) so the model can react without treating it as an error. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = args.get("path") .and_then(|v| v.as_str()) diff --git a/src/tool/utility/dir_cache_update.rs b/src/tool/utility/dir_cache_update.rs index 2b10c8c..fdaee64 100644 --- a/src/tool/utility/dir_cache_update.rs +++ b/src/tool/utility/dir_cache_update.rs @@ -1,8 +1,19 @@ +//! Tool for refreshing the shared workspace directory cache. +//! +//! Flow: resolve the requested path against the workspace roots → +//! non-recursively walk it → spin up a one-shot Tokio runtime (the agent +//! turn runs on a plain `std::thread` with no async context) → write the +//! entries into the shared `dir_cache` behind an async `RwLock`. +//! +//! Why: other tools rely on this cache for faster path resolution, so +//! it must be kept fresh on demand rather than only populated at startup. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +/// Tool that refreshes the shared directory cache for a given path. pub struct DirCacheUpdate; impl Tool for DirCacheUpdate { @@ -27,6 +38,19 @@ impl Tool for DirCacheUpdate { }) } + /// Resolve `path`, walk its immediate entries, and store them in the shared cache. + /// + /// Flow: extract `path` argument → resolve against workspace roots → + /// bail early with a plain message (not an error) if it doesn't exist + /// → `walk_directory` collects direct children → spawn a temporary + /// Tokio runtime to acquire the async `RwLock` write guard and call + /// `cache.set(entries)`. + /// + /// Why: uses a fresh one-shot runtime instead of `ctx`'s own executor + /// because this tool can be invoked from a non-async thread. + /// + /// Return: a confirmation string with the entry count, or an error if + /// the `path` argument is missing or the temp runtime fails to start. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = args.get("path") .and_then(|v| v.as_str()) @@ -55,6 +79,14 @@ impl Tool for DirCacheUpdate { } } +/// Non-recursively list the immediate entries of `path`. +/// +/// Flow: read_dir → flatten Ok entries → collect their paths. +/// +/// Why: silently skips unreadable entries (e.g. permission errors) +/// rather than failing the whole cache update. +/// +/// Return: paths of direct children; empty vec if `path` can't be read. fn walk_directory(path: &std::path::Path) -> Vec { let mut result = Vec::new(); if let Ok(entries) = std::fs::read_dir(path) { diff --git a/src/tool/utility/dir_list.rs b/src/tool/utility/dir_list.rs index 1cdfb0b..a365063 100644 --- a/src/tool/utility/dir_list.rs +++ b/src/tool/utility/dir_list.rs @@ -1,9 +1,20 @@ +//! Tool for listing the immediate contents of a workspace directory. +//! +//! Flow: resolve the requested path against workspace roots → validate +//! it exists and is a directory → read its direct children with +//! `fs::read_dir`, tagging subdirectories with a trailing `/` → format +//! into a header + newline-joined listing. +//! +//! Why: gives the agent a quick, one-level view of the workspace +//! structure without pulling in the full recursive directory cache. + use std::fs; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +/// Tool that lists the immediate contents of a workspace directory. pub struct DirList; impl Tool for DirList { @@ -28,6 +39,19 @@ impl Tool for DirList { }) } + /// List the immediate entries of the requested workspace directory. + /// + /// Flow: extract `path` argument → resolve against workspace roots → + /// short-circuit with a plain message if the path doesn't exist or + /// isn't a directory → `read_dir` → map each entry to its name + /// (appending `/` for subdirectories) → join into a formatted listing + /// with an entry-count header showing the canonicalized path. + /// + /// Why: entries whose metadata fails to read (`e.ok()` filter) are + /// silently skipped rather than aborting the whole listing. + /// + /// Return: header + newline-joined entry names, or an error if the + /// `path` argument is missing or `read_dir` fails outright. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = args.get("path") .and_then(|v| v.as_str()) diff --git a/src/tool/utility/mod.rs b/src/tool/utility/mod.rs index adc8ac7..dd887d5 100644 --- a/src/tool/utility/mod.rs +++ b/src/tool/utility/mod.rs @@ -1,3 +1,5 @@ +//! Small standalone utility tools (cd, dir listing/caching, pong, todowrite). + pub mod cd; pub mod dir_cache_update; pub mod dir_list; diff --git a/src/tool/utility/pong.rs b/src/tool/utility/pong.rs index bd7cbf3..9538b5d 100644 --- a/src/tool/utility/pong.rs +++ b/src/tool/utility/pong.rs @@ -1,8 +1,17 @@ +//! Trivial connectivity-check tool. +//! +//! Flow: read the optional `message` argument → echo it back prefixed +//! with `"pong: "`, defaulting to `"pong"` when no message is supplied. +//! +//! Why: gives callers a cheap, dependency-free way to verify the tool +//! harness is reachable and responding before running real work. + use serde_json::{json, Value}; use anyhow::Result; use super::super::Tool; use super::super::ToolCtx; +/// Tool that echoes back a message; used for connectivity/latency checks. pub struct Pong; impl Tool for Pong { diff --git a/src/tool/utility/todowrite.rs b/src/tool/utility/todowrite.rs index e5c113c..a3668cc 100644 --- a/src/tool/utility/todowrite.rs +++ b/src/tool/utility/todowrite.rs @@ -1,3 +1,13 @@ +//! Tool for appending timestamped tasks to the session's todo list. +//! +//! Flow: extract the `task` argument → format a Markdown checkbox line +//! with a UTC timestamp → open `todo.md` in the session directory +//! (creating it if needed) in append mode → write the line. +//! +//! Why: the file lives under `ctx.session_dir` so it persists per +//! session and is picked up by the TUI's Todo panel; appending (rather +//! than rewriting) keeps prior tasks intact. + use std::fs; use std::path::PathBuf; use serde_json::{json, Value}; @@ -5,6 +15,7 @@ use anyhow::{Result, anyhow}; use super::super::Tool; use super::super::ToolCtx; +/// Tool that appends a timestamped task line to the session's todo.md. pub struct Todowrite; impl Tool for Todowrite { @@ -29,6 +40,17 @@ impl Tool for Todowrite { }) } + /// Append a timestamped, unchecked task line to the session's `todo.md`. + /// + /// Flow: extract `task` argument → build `- [ ] ()` + /// line with a UTC `%Y-%m-%d %H:%M:%S` timestamp → open (create if + /// missing) `/todo.md` in append mode → write the line. + /// + /// Why: append-only so the file acts as a running log rather than + /// requiring the agent to track and rewrite existing content. + /// + /// Return: confirmation string echoing the added task, or an error + /// if the `task` argument is missing or the file can't be opened/written. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let task = args.get("task") .and_then(|v| v.as_str()) diff --git a/src/tool/workflow.rs b/src/tool/workflow.rs index c6b6180..619f0ca 100644 --- a/src/tool/workflow.rs +++ b/src/tool/workflow.rs @@ -1,8 +1,23 @@ +//! Tools for orchestrating multi-agent workflow runs. +//! +//! Flow: the LLM emits a `workflow_run` tool call with a JSON-encoded +//! `WorkflowScript` (Agent/Parallel/Pipeline/Phase primitives) which is +//! deserialized and handed to `app::workflow::engine::run_workflow` for +//! execution. Sibling agents spawned within the same run can share +//! ephemeral text via the `note_finding` tool, which forwards to +//! `app::workflow::engine::note_finding`. +//! +//! Why: decomposing a task into a workflow script lets the harness fan +//! out independent subtasks (parallel/pipeline/phased) instead of the +//! agent handling everything inline; simple tasks should skip this tool +//! entirely per its own description string. + use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use super::Tool; use super::ToolCtx; +/// Tool that parses and executes a JSON-encoded workflow script (Agent/Parallel/Pipeline/Phase). pub struct WorkflowRun; impl Tool for WorkflowRun { @@ -31,6 +46,18 @@ impl Tool for WorkflowRun { }) } + /// Parse the `script`/`args` tool arguments and execute the workflow. + /// + /// Flow: extract `script` string → deserialize into `WorkflowScript` → + /// collect optional `args` object into a `HashMap` for + /// `{{key}}` template substitution → delegate to + /// `app::workflow::engine::run_workflow`. + /// + /// Why: template args are silently filtered to string values only + /// (non-string values are dropped rather than erroring). + /// + /// Return: the workflow engine's output string, or an error if the + /// script argument is missing or fails to parse as JSON. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let script_str = args.get("script") .and_then(|v| v.as_str()) @@ -53,6 +80,7 @@ impl Tool for WorkflowRun { } } +/// Tool that shares a text finding with sibling agents in the current workflow run. pub struct NoteFinding; impl Tool for NoteFinding { @@ -77,6 +105,17 @@ impl Tool for NoteFinding { }) } + /// Record `text` as a finding visible to sibling agents in the run. + /// + /// Flow: extract `text` argument → forward to + /// `app::workflow::engine::note_finding` → return a truncated + /// confirmation echo. + /// + /// Why: findings are ephemeral (not persisted to memory) and are + /// meant to be prepended to sibling agents' next tool-round context. + /// + /// Return: confirmation string containing up to the first 80 chars + /// of the recorded text. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let text = args.get("text") .and_then(|v| v.as_str()) diff --git a/src/view/chat.rs b/src/view/chat.rs index 4fbbfeb..5c014ce 100644 --- a/src/view/chat.rs +++ b/src/view/chat.rs @@ -1,3 +1,15 @@ +//! Chat transcript panel rendering. +//! +//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a +//! header + markdown-rendered body per message (via `super::markdown`), +//! appends a streaming spinner line when a turn is in flight, then +//! slices the combined line list to the currently visible scroll window +//! before handing it to a ratatui `Paragraph`. +//! +//! Why: lines are recomputed every frame instead of cached, since +//! markdown wrapping depends on the current terminal width, which can +//! change between frames. + use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; @@ -5,6 +17,17 @@ use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::theme::Theme; +/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. +/// +/// Flow: for each span, split its text on '\n' → push non-empty parts onto +/// the current line's span buffer → on each newline boundary, flush the +/// buffer into a new `Line` and start fresh. +/// +/// Why: `render_markdown` produces a single Vec with newlines baked +/// into span content; ratatui's Paragraph wants pre-split `Line`s to lay +/// out and scroll correctly. +/// +/// Return: at least one (possibly empty) `Line`, never an empty vec. fn split_spans_into_lines<'a>(spans: Vec>) -> Vec> { let mut lines = Vec::new(); let mut current_spans = Vec::new(); @@ -39,6 +62,7 @@ fn _role_name(role: &crate::dto::chat::message::Role) -> &'static str { } } +/// Map a message role to its short uppercase badge label for the chat header. fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { match role { crate::dto::chat::message::Role::User => "YOU", @@ -56,6 +80,17 @@ fn format_timestamp(ts: i64) -> String { format!("{:02}:{:02}", hrs, mins) } +/// Render the scrollable chat transcript panel. +/// +/// Flow: build a header + markdown-rendered body Line list per message → +/// append a streaming spinner line if a turn is in flight → slice the +/// combined lines to the visible window based on scroll offset → wrap in +/// a Paragraph and render. +/// +/// Why: lines are computed fresh every frame rather than cached, since +/// wrapping depends on the current terminal width. +/// +/// Return: nothing; draws directly into `frame` at `area`. pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; diff --git a/src/view/markdown.rs b/src/view/markdown.rs index c0f1768..199f12a 100644 --- a/src/view/markdown.rs +++ b/src/view/markdown.rs @@ -1,6 +1,30 @@ +//! Markdown-to-styled-spans rendering for the chat transcript. +//! +//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and +//! translates each markdown construct (headings, code blocks, links, +//! emphasis, block quotes, lists) into styled `ratatui::text::Span`s, +//! then optionally re-wraps the flat span list to a target column width. +//! +//! Why: ratatui has no built-in markdown renderer, so this module bridges +//! `pulldown_cmark`'s event-based parser to ratatui's span/line model. + use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Span; +/// Render a markdown string into styled terminal spans, word-wrapped to `width`. +/// +/// Flow: pulldown_cmark parses `text` into an event stream → each +/// Start/End/Text/Code/Break event is translated into styled `Span`s +/// (headings colored by level, code blocks green, links bracketed, etc.) +/// → if `width > 0`, a second pass inserts manual newline spans whenever +/// the running line length would exceed `width`. +/// +/// Why: ratatui has no native markdown renderer, and the built-in `Wrap` +/// widget wraps on grapheme count without honoring markdown structure, +/// so wrapping is done manually here in terms of raw span byte length. +/// +/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` +/// turns it back into `Line`s for the Paragraph widget. pub fn render_markdown(text: &str, width: u16) -> Vec> { let mut spans = Vec::new(); let parser = pulldown_cmark::Parser::new(text); diff --git a/src/view/mod.rs b/src/view/mod.rs index f270af1..b748600 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,3 +1,7 @@ +//! Top-level TUI render pipeline: layouts the terminal into chat / input +//! / status regions, dispatches overlay rendering, and floats toast +//! notifications over the top-right corner. + pub mod chat; pub mod markdown; pub mod status; @@ -11,6 +15,17 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::Frame; use theme::Theme; +/// Top-level render entry point called once per TUI frame. +/// +/// Flow: split the frame into main / input / status regions → if an +/// overlay is active, render it inside the main region; otherwise render +/// the chat transcript → always render the input bar and status bar → +/// overlay toast notifications in the top-right corner. +/// +/// Why: a single function owns the layout so every state change +/// re-renders the whole UI from a known template. +/// +/// Return: nothing; writes directly into `frame`. pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let area = frame.area(); @@ -45,6 +60,18 @@ fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::r chat::draw_chat(frame, area, state); } +/// Render the active modal overlay (Help, Settings, Workflow, Bash, Editor, etc.). +/// +/// Flow: compute a centered sub-area → clear it to make the rest of the +/// frame visible behind → match on the Overlay variant to pick the +/// panel's title, content Lines, and styling → render as a Paragraph or +/// delegate to a specialized drawer (e.g. `workflow::draw_workflow_panel`). +/// +/// Why: each Overlay variant has its own data sources (settings, +/// session_runtime, app_config) and its own visual treatment, so they +/// are dispatched individually rather than table-driven. +/// +/// Return: nothing; draws directly into `frame`. fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) { let overlay_area = centered_rect(area, 70, 60); @@ -499,6 +526,20 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ } } +/// Render the bottom input bar including the autocomplete dropdown above it. +/// +/// Flow: if autocomplete is open and has candidates, draw a borderless +/// dropdown anchored just above the input bar showing up to 10 candidates +/// with the current selection highlighted → then render the prompt, +/// placeholder, and the buffer with a single-character highlight under +/// the cursor position. +/// +/// Why: the cursor highlight is drawn by splitting the buffer at +/// `state.input.cursor` and styling one character (or trailing space) +/// with the highlight color, since ratatui Paragraph does not expose a +/// native cursor widget. +/// +/// Return: nothing; draws directly into `frame` at `area`. fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { // Render autocomplete dropdown if visible if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { diff --git a/src/view/status.rs b/src/view/status.rs index e6eaa76..2898ffd 100644 --- a/src/view/status.rs +++ b/src/view/status.rs @@ -1,3 +1,13 @@ +//! Status bar rendering for the TUI. +//! +//! Flow: `draw_status_bar` reads live connection/turn state off +//! `AppStateRest` every frame and paints a single-line bar at the top +//! (or bottom, per layout) of the screen showing agent status, provider, +//! and model. +//! +//! Why: kept as one small, self-contained render function rather than a +//! widget struct, matching the other `view/*` modules' functional style. + use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; @@ -5,6 +15,13 @@ use ratatui::widgets::Block; use ratatui::Frame; use super::theme::Theme; +/// Render the single-line status bar showing connection state, provider, and model. +/// +/// Flow: derive an agent status label/color from turn-in-flight and API +/// connection state → build left ([zesdex] STATUS) and right +/// (provider · model) span groups → render as one styled Line. +/// +/// Return: nothing; draws directly into `frame` at `area`. pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { // Connection status — reflects actual agent readiness: // PROG → turn is in flight diff --git a/src/view/theme.rs b/src/view/theme.rs index edc33f1..d6a4209 100644 --- a/src/view/theme.rs +++ b/src/view/theme.rs @@ -1,5 +1,20 @@ +//! Central color theme for the TUI. +//! +//! Flow: defines a single `Theme` marker struct with associated `Color` +//! consts, consumed by every `view/*` render function so styling stays +//! consistent and changeable from one place. +//! +//! Why: a zero-sized struct with associated consts (rather than an enum +//! or a runtime-configured palette) keeps color lookups compile-time +//! constant and allocation-free. + use ratatui::style::Color; +/// Central palette of terminal colors used across all TUI render functions. +/// +/// Why: a zero-sized marker struct holding only associated consts, so +/// every view module references colors as `Theme::NAME` instead of +/// hardcoding `ratatui::style::Color` values inline. pub struct Theme; impl Theme { diff --git a/src/view/workflow.rs b/src/view/workflow.rs index 9abf2ea..0ba6cc5 100644 --- a/src/view/workflow.rs +++ b/src/view/workflow.rs @@ -1,3 +1,14 @@ +//! Workflow status panel rendering. +//! +//! Flow: `draw_workflow_panel` reads `state.session_runtime` and +//! `state.workflow_engine` and renders a compact `List` of counters +//! (messages, completed/pending tool calls, active bash jobs, agents, +//! findings) plus the current auto-run phase. +//! +//! Why: shows a placeholder panel when there is no active session +//! runtime, and only emits rows for counters that are nonzero, to keep +//! the panel compact during simple single-turn sessions. + use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; @@ -5,6 +16,18 @@ use ratatui::widgets::{Block, Borders, List, ListItem}; use ratatui::Frame; use super::theme::Theme; +/// Render the workflow status panel summarizing the active session runtime. +/// +/// Flow: bail out with a "No active session" placeholder if +/// `state.session_runtime` is None → otherwise build a list of status +/// lines (message count, completed/pending tool calls, active bash jobs, +/// agent/finding counts, auto-run phase) → render as a List widget. +/// +/// Why: rows for pending tool queue, bash jobs, agents, and findings are +/// only shown when their count is nonzero, to keep the panel compact +/// during simple single-turn sessions. +/// +/// Return: nothing; draws directly into `frame` at `area`. pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let block = Block::default() .borders(Borders::ALL)