Enhance tool documentation and add new features

- Added module-level documentation for memory tools (`remember`, `recall`, `forget`) to clarify their purpose.
- Improved documentation in `recall.rs` and `remember.rs` to describe the functionality and flow of memory entry operations.
- Updated `mod.rs` to include descriptions for the tool trait and execution context.
- Enhanced `plan.rs` with detailed comments on plan-mode signaling tools.
- Documented text search tools in `search.rs` to explain their functionality.
- Improved sequential-thinking tool documentation in `seqthink.rs`.
- Added safety filter documentation in `shell_filter` for credential and git operations.
- Enhanced utility tools documentation, including `cd`, `dir_cache_update`, and `todowrite`.
- Improved rendering documentation in view modules (`chat`, `markdown`, `status`, `workflow`) to clarify rendering flows and purposes.
This commit is contained in:
asepharyana
2026-07-12 11:28:39 +07:00
parent 7158d362fd
commit 2efd40ca88
124 changed files with 2379 additions and 19 deletions
+37
View File
@@ -1,14 +1,41 @@
//! Global registry of running background bash jobs, and control operations
//! (output polling, kill) exposed to the rest of the app.
//!
//! Flow: a process-wide `Mutex<HashMap<String, BashJob>>` (lazily built via
//! `OnceLock`) holds every job spawned via `bgbash::job::spawn_bash_job` →
//! `bash_output` drains new lines for a given job id → `bash_kill` removes
//! a job from the map and signals its child process.
//!
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
//! lets background jobs outlive the borrow of any particular state mutation
//! and be looked up by id from tool calls issued at arbitrary points.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock; use std::sync::OnceLock;
use super::job::BashJob; use super::job::BashJob;
/// Lazily-initialised, process-wide registry of background bash jobs keyed
/// by job id.
///
/// Return: a reference to the static `Mutex<HashMap<...>>`, created on
/// first access.
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> { pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new(); static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
JOBS.get_or_init(|| Mutex::new(HashMap::new())) JOBS.get_or_init(|| Mutex::new(HashMap::new()))
} }
/// Drain any newly available output lines from a background bash job.
///
/// Flow: look up the job by id → repeatedly call `try_read_line()` until it
/// returns `None` → collect into a Vec.
///
/// Why: non-blocking; a job that hasn't produced new output yields no lines
/// rather than blocking the caller.
///
/// Return: `Some(lines)` if at least one new line was read, `None` if the
/// job doesn't exist, the lock is poisoned, or there was nothing new to read.
pub fn bash_output(id: &str) -> Option<Vec<String>> { pub fn bash_output(id: &str) -> Option<Vec<String>> {
let mut map = bash_jobs_map().lock().ok()?; let mut map = bash_jobs_map().lock().ok()?;
let job = map.get_mut(id)?; let job = map.get_mut(id)?;
@@ -19,6 +46,16 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
if lines.is_empty() { None } else { Some(lines) } 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<()> { pub fn bash_kill(id: &str) -> anyhow::Result<()> {
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?; let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
let job = map.remove(id); let job = map.remove(id);
+40
View File
@@ -1,8 +1,24 @@
//! Background bash job spawning and non-blocking output polling.
//!
//! Flow: `spawn_bash_job` forks a detached OS thread that execs the command
//! via `sh -c`, streams stdout lines back over an `mpsc` channel, and sends
//! an `__exit:<code>` sentinel when the child terminates → callers poll the
//! returned `BashJob` with `try_read_line()` to drain output without
//! blocking the TUI event loop.
//!
//! Why: running bash commands on a detached thread with a channel (rather
//! than synchronously) lets the TUI stay responsive while long-running
//! shell commands execute in the background.
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use std::io::BufRead; 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 struct BashJob {
pub id: String, pub id: String,
pub child_pid: u32, pub child_pid: u32,
@@ -10,6 +26,21 @@ pub struct BashJob {
pub exit_code: Option<i32>, pub exit_code: Option<i32>,
} }
/// Spawn a shell command in a background thread and return a handle to it.
///
/// Flow: spawn a thread → thread execs `sh -c <command>` with piped
/// stdout/stderr → thread sends the child PID back over a channel →
/// thread streams stdout lines to `output_tx` → on exit, sends an
/// `__exit:<code>` sentinel line.
///
/// Why: the PID is sent back before the command finishes so `bash_kill` can
/// terminate it mid-run; sentinel-prefixed strings (`__error:`, `__exit:`)
/// let `try_read_line` distinguish control messages from real output on the
/// same channel without a separate enum.
///
/// Return: a `BashJob` with a freshly generated id, the child PID (0 if the
/// spawn failed before the PID was sent), and the receiving end of the
/// output channel.
pub fn spawn_bash_job(command: String) -> BashJob { pub fn spawn_bash_job(command: String) -> BashJob {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let (output_tx, output_rx) = mpsc::channel::<String>(); let (output_tx, output_rx) = mpsc::channel::<String>();
@@ -57,6 +88,15 @@ pub fn spawn_bash_job(command: String) -> BashJob {
} }
impl BashJob { impl BashJob {
/// Non-blocking poll for the next output line from the job's channel.
///
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel,
/// record `exit_code` and return `None` instead of surfacing it as
/// output → otherwise return the line.
///
/// Return: `Some(line)` for real output, `None` if there's nothing
/// available yet or the job just finished (exit code recorded as a
/// side effect).
pub fn try_read_line(&mut self) -> Option<String> { pub fn try_read_line(&mut self) -> Option<String> {
match self.output_rx.try_recv() { match self.output_rx.try_recv() {
Ok(line) => { Ok(line) => {
+3
View File
@@ -1,2 +1,5 @@
//! Background bash: run shell commands off the main thread, poll their
//! output non-blockingly, and terminate them on demand.
pub mod control; pub mod control;
pub mod job; pub mod job;
+18
View File
@@ -1,3 +1,10 @@
//! Tool-call gating: decides whether a risky tool call is allowed to run
//! before it executes.
/// Outcome of gating a tool call: whether it's allowed to run.
///
/// Why: `Block` carries a reason string for surfacing to the user/log, even
/// though nothing currently produces `Block` (classify() always allows).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Verdict { pub enum Verdict {
Allow, Allow,
@@ -5,9 +12,20 @@ pub enum Verdict {
Block(String), Block(String),
} }
/// Gatekeeper that decides whether a tool call may proceed before execution.
pub struct Harness; pub struct Harness;
impl 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( pub fn gate_tool_call(
tool_name: &str, tool_name: &str,
_args: &serde_json::Value, _args: &serde_json::Value,
+36
View File
@@ -1,3 +1,7 @@
//! MCP server connection management: spawning/talking to stdio child
//! processes and HTTP endpoints, and adapting their advertised tools to
//! the crate's `Tool` trait.
use serde_json::{json, Value}; use serde_json::{json, Value};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
@@ -21,6 +25,8 @@ fn mcp_static_str(s: &str) -> &'static str {
leaked 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum McpTransport { pub enum McpTransport {
Stdio { 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolInfo { pub struct McpToolInfo {
pub name: String, pub name: String,
@@ -39,6 +46,8 @@ pub struct McpToolInfo {
pub input_schema: Value, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer { pub struct McpServer {
pub name: String, pub name: String,
@@ -51,6 +60,8 @@ pub struct McpServer {
pub child_handle: Option<Arc<Mutex<StdioChild>>>, pub child_handle: Option<Arc<Mutex<StdioChild>>>,
} }
/// Live handle to an MCP server child process communicating over stdio
/// via newline-delimited JSON-RPC 2.0.
#[derive(Debug)] #[derive(Debug)]
pub struct StdioChild { pub struct StdioChild {
stdin: std::process::ChildStdin, stdin: std::process::ChildStdin,
@@ -59,6 +70,18 @@ pub struct StdioChild {
} }
impl StdioChild { impl StdioChild {
/// Send a JSON-RPC request to the child and block for its matching response.
///
/// Flow: assign the next request id → write request + newline to stdin →
/// loop reading lines from stdout until one has a matching `id` or the
/// timeout elapses → return its `result` (or error out on an `error` field).
///
/// Why: the child may interleave unrelated/malformed lines, so blank
/// lines are skipped and non-matching ids are ignored rather than
/// treated as a protocol violation.
///
/// Return: the `result` value of the matching response, or `Err` on
/// timeout, EOF, JSON-RPC error, or I/O failure.
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> { pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
self.next_id += 1; self.next_id += 1;
let id = self.next_id; let id = self.next_id;
@@ -255,11 +278,14 @@ fn extract_text_content(result: &Value) -> anyhow::Result<String> {
})) }))
} }
/// Registry of connected MCP servers and their tools for the current session.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct McpManager { pub struct McpManager {
pub servers: Vec<McpServer>, pub servers: Vec<McpServer>,
} }
/// Adapts a single MCP-advertised tool to the crate's `Tool` trait so it can
/// be dispatched through the same execution path as built-in tools.
pub struct McpToolAdapter { pub struct McpToolAdapter {
pub tool_name: String, pub tool_name: String,
pub server_name: String, pub server_name: String,
@@ -296,12 +322,22 @@ impl crate::tool::Tool for McpToolAdapter {
} }
impl McpManager { impl McpManager {
/// Create an empty manager with no connected servers.
pub fn new() -> Self { pub fn new() -> Self {
McpManager { McpManager {
servers: Vec::new(), servers: Vec::new(),
} }
} }
/// Flatten all connected servers' tools into a single list of `Tool` trait objects.
///
/// Flow: for each server, clone its child handle → wrap each of its
/// `McpToolInfo` entries in an `McpToolAdapter` sharing that handle.
///
/// Why: the handle is cloned (Arc) per tool so every adapter for a given
/// stdio server reuses the same persistent child process/connection.
///
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> { pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
self.servers.iter().flat_map(|server| { self.servers.iter().flat_map(|server| {
let handle = server.child_handle.clone(); let handle = server.child_handle.clone();
+3
View File
@@ -1 +1,4 @@
//! Model Context Protocol (MCP) client: connects to external MCP servers
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
pub mod manager; pub mod manager;
+2
View File
@@ -1,3 +1,5 @@
//! Top-level application module: harness, modes, runtime loop, state,
//! workflows, subagents, review, background bash, and MCP integration.
pub mod harness; pub mod harness;
pub mod mode; pub mod mode;
pub mod runtime; pub mod runtime;
+11
View File
@@ -1,5 +1,16 @@
//! Bash mode: handles submitting a shell command from the bash input panel.
use crate::app::state::rest::AppStateRest; 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) { pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
if !command.is_empty() { if !command.is_empty() {
let _ = crate::app::bgbash::job::spawn_bash_job(command); let _ = crate::app::bgbash::job::spawn_bash_job(command);
+30
View File
@@ -1,6 +1,11 @@
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
//! with bounded undo history.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; 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)] #[derive(Debug, Clone)]
pub struct EditorState { pub struct EditorState {
pub path: String, pub path: String,
@@ -23,6 +28,8 @@ impl Default for EditorState {
} }
impl EditorState { impl EditorState {
/// Create a fresh editor state for `path`, seeded with existing content
/// (or a single empty line for a new file).
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self { pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
let content = existing_content.unwrap_or_else(|| vec![String::new()]); let content = existing_content.unwrap_or_else(|| vec![String::new()]);
EditorState { 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) { pub fn insert_line_after(&mut self) {
self.save_undo(); self.save_undo();
let pos = (self.cursor_line + 1).min(self.content.len()); let pos = (self.cursor_line + 1).min(self.content.len());
self.content.insert(pos, String::new()); 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) { fn save_undo(&mut self) {
self.undo_stack.push(self.content.clone()); self.undo_stack.push(self.content.clone());
if self.undo_stack.len() > 50 { 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) { pub fn cursor_down(&mut self) {
if self.cursor_line + 1 < self.content.len() { if self.cursor_line + 1 < self.content.len() {
self.cursor_line += 1; 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) { pub fn insert_char(&mut self, c: char) {
self.save_undo(); self.save_undo();
if let Some(line) = self.content.get_mut(self.cursor_line) { 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) { pub fn delete_left(&mut self) {
self.save_undo(); self.save_undo();
if let Some(line) = self.content.get_mut(self.cursor_line) { 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 { pub fn as_string(&self) -> String {
self.content.join("\n") 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) { pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
let editor = &mut state.misc.editor; let editor = &mut state.misc.editor;
if editor.is_none() { if editor.is_none() {
@@ -108,6 +137,7 @@ pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
state.dirty = true; state.dirty = true;
} }
/// Close the editor overlay without saving, clearing editor state.
pub fn handle_editor_dismiss(state: &mut AppStateRest) { pub fn handle_editor_dismiss(state: &mut AppStateRest) {
state.misc.editor = None; state.misc.editor = None;
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
+12
View File
@@ -1,3 +1,6 @@
//! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and max_tokens for subsequent turns.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; 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)) (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 { pub fn current_effort(state: &AppStateRest) -> usize {
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1) 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 { pub fn current_effort_str(state: &AppStateRest) -> &'static str {
let idx = current_effort(state); let idx = current_effort(state);
EFFORT_LEVELS[idx] 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) { pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state); let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len(); state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
+8
View File
@@ -1,3 +1,5 @@
//! Help mode: static help text and the action that opens/closes the help overlay.
use crate::app::runtime::actions::Action; use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
@@ -27,6 +29,12 @@ Slash commands:
/lesson import Import lessons /lesson import Import lessons
/clear Clear transcript"; /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 { pub fn handle_help_action(action: &Action) -> Action {
match action { match action {
Action::CloseOverlay => Action::CloseOverlay, Action::CloseOverlay => Action::CloseOverlay,
+3
View File
@@ -1,5 +1,8 @@
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
use crate::app::state::rest::AppStateRest; 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) { pub fn handle_key_text(state: &mut AppStateRest, text: String) {
state.input.buffer = text; state.input.buffer = text;
state.dirty = true; state.dirty = true;
+3
View File
@@ -1,3 +1,5 @@
//! Loading mode: transient overlay shown while waiting on an async operation.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
pub const LOADING_MESSAGES: &[&str] = &[ pub const LOADING_MESSAGES: &[&str] = &[
@@ -7,6 +9,7 @@ pub const LOADING_MESSAGES: &[&str] = &[
"almost done...", "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) { pub fn resolve_loading(state: &mut AppStateRest) {
state.dirty = true; state.dirty = true;
} }
+6
View File
@@ -1,5 +1,11 @@
//! MCP mode: overlay for connecting to a configured MCP server.
use crate::app::state::rest::AppStateRest; 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) { pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
let _ = server_name; let _ = server_name;
state.dirty = true; state.dirty = true;
+5
View File
@@ -1,3 +1,6 @@
//! TUI mode definitions and per-mode input/action handlers, one submodule
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod bash; pub mod bash;
@@ -11,6 +14,8 @@ pub mod rewind;
pub mod settings; pub mod settings;
pub mod todo; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModeKind { pub enum ModeKind {
Chat, Chat,
+6
View File
@@ -1,5 +1,11 @@
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
use crate::app::runtime::actions::Action; 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 { pub fn handle_quit_confirm(yes: bool) -> Action {
if yes { if yes {
Action::ForceQuit Action::ForceQuit
+3
View File
@@ -1,3 +1,6 @@
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's SQLite blob store.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use sha2::Digest; use sha2::Digest;
+14
View File
@@ -1,5 +1,19 @@
//! Settings-mode helper logic for the TUI settings overlay.
//!
//! Flow: exposes small mutation functions (currently just cycling the
//! internet access mode) invoked by keybindings while the settings overlay
//! is active.
use crate::model::settings::{Settings, InternetMode}; 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) { pub fn cycle_internet_mode(settings: &mut Settings) {
settings.internet_mode = match settings.internet_mode { settings.internet_mode = match settings.internet_mode {
InternetMode::Off => InternetMode::ReadOnly, InternetMode::Off => InternetMode::ReadOnly,
+13
View File
@@ -1,6 +1,19 @@
//! Todo-mode helper logic for the TUI todo-list overlay.
//!
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
//! the todo overlay.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; 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) { pub fn handle_todo_toggle(state: &mut AppStateRest) {
if state.misc.overlay == Overlay::Todo { if state.misc.overlay == Overlay::Todo {
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
+20
View File
@@ -1,6 +1,21 @@
//! Workflow-mode helper logic for the TUI workflow overlay.
//!
//! Flow: exposes a dismiss handler invoked by a keybinding to close the
//! workflow overlay, and a status query used elsewhere to check whether
//! it is currently showing.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; 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) { pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
if state.misc.overlay == Overlay::Workflow { if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
@@ -8,6 +23,11 @@ pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
state.dirty = true; 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 { pub fn workflow_status(state: &AppStateRest) -> &str {
if state.misc.overlay == Overlay::Workflow { if state.misc.overlay == Overlay::Workflow {
"active" "active"
+118
View File
@@ -1,3 +1,5 @@
//! Adaptive quality-review triggering, build/test probing, staleness
//! sweeps for stored lessons, and the pending-lesson approval workflow.
use std::process::Command; use std::process::Command;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent; use crate::app::state::runtime::TurnEvent;
@@ -7,6 +9,7 @@ use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition; use crate::app::subagent::spawn::AgentDefinition;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// How much trust a lesson's origin/verification warrants.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Confidence { pub enum Confidence {
Human, Human,
@@ -15,6 +18,7 @@ pub enum Confidence {
Auto, Auto,
} }
/// Where a lesson sits in its life cycle, from freshly written to superseded.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonLifecycle { pub enum LessonLifecycle {
New, New,
@@ -24,12 +28,14 @@ pub enum LessonLifecycle {
Superseded, Superseded,
} }
/// Whether a lesson applies to the current project only or globally.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonScope { pub enum LessonScope {
Project, Project,
Global, Global,
} }
/// Records who/what produced a lesson and in which session/turn.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance { pub struct Provenance {
pub session_turn: String, pub session_turn: String,
@@ -37,6 +43,8 @@ pub struct Provenance {
pub reviewer: Origin, pub reviewer: Origin,
} }
/// A single learned fact/pattern surfaced by a review, prior to being
/// written to persistent memory.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson { pub struct Lesson {
pub name: String, pub name: String,
@@ -49,6 +57,18 @@ pub struct Lesson {
pub provenance: Provenance, 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 { pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main { if origin != Origin::Main {
@@ -75,6 +95,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
} }
false false
} }
/// Outcome of running a build/test probe command against a workspace.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult { pub struct ProbeResult {
pub command: String, pub command: String,
@@ -82,6 +103,21 @@ pub struct ProbeResult {
pub output: String, pub output: String,
pub timed_out: bool, pub timed_out: bool,
} }
/// Run a build/test verification command in the first workspace root and
/// capture its outcome, to back a review with a real pass/fail signal.
///
/// Flow: pick the first workspace → resolve the verify command (explicit
/// override or auto-detected via `resolve_verify_command`) → spawn it →
/// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses →
/// capture combined stdout+stderr (truncated) on completion.
///
/// Why: polling instead of a blocking wait lets the timeout be enforced
/// without spawning a watcher thread.
///
/// Return: `None` if no workspace exists, no command could be resolved,
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
/// describing pass/fail/timeout and truncated output.
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> { pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
let probe_dir = workspaces.first()?; let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?; let cmd = resolve_verify_command(probe_dir, verify_command)?;
@@ -131,6 +167,19 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
} }
} }
/// Determine the shell command to build/test a workspace, auto-detecting
/// the project type from marker files when no override is given.
///
/// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for
/// language/tool marker files (Cargo.toml, go.mod, package.json, etc.)
/// in priority order and return that ecosystem's conventional test/build
/// command.
///
/// Why: covers a broad set of ecosystems so review probing works without
/// per-project configuration in the common case.
///
/// Return: `Some(command)` if a command could be determined, `None` if
/// no marker files matched (e.g. plain Python project with no test dir).
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> { fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
if let Some(cmd) = override_cmd { if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() { if !cmd.trim().is_empty() {
@@ -227,6 +276,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
None 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 { fn truncate_output(s: &str, max: usize) -> String {
if s.len() <= max { if s.len() <= max {
s.to_string() 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<()> { pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let def = AgentDefinition::new( let def = AgentDefinition::new(
"quality-reviewer".to_string(), "quality-reviewer".to_string(),
@@ -310,6 +379,14 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
const STALE_AFTER_DAYS: i64 = 60; const STALE_AFTER_DAYS: i64 = 60;
/// Flag memory entries as stale if they haven't been updated recently.
///
/// Flow: list all memory files → for each, read it → if `updated_at` is
/// older than `STALE_AFTER_DAYS` and it isn't already flagged, set
/// `lifecycle = "stale"` and write it back → collect flagged names.
///
/// Return: names of newly-flagged memories, or an I/O error from
/// `mem.write`.
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> { pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
let mut flagged = Vec::new(); let mut flagged = Vec::new();
let names = crate::model::memory::Memory::list(memory_dir); let names = crate::model::memory::Memory::list(memory_dir);
@@ -327,6 +404,14 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
Ok(flagged) Ok(flagged)
} }
/// Run the staleness sweep at most once every 10 minutes, notifying via toast.
///
/// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` →
/// otherwise update the timestamp and run `run_staleness_sweep`, pushing
/// an info toast listing flagged lessons if any were found.
///
/// Why: rate-limited so the sweep (a file read/write per memory) doesn't
/// run on every event-loop tick.
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) { pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 { if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
@@ -343,6 +428,8 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
} }
} }
/// A lesson awaiting confirmation before being committed to memory,
/// optionally auto-resolving after a grace period.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingLesson { pub struct PendingLesson {
pub lesson: Lesson, pub lesson: Lesson,
@@ -350,6 +437,10 @@ pub struct PendingLesson {
pub auto_resolve: bool, pub auto_resolve: bool,
} }
/// Load the session's pending-lessons queue from disk.
///
/// Return: the parsed list, or an empty `Vec` if the file is missing or
/// fails to parse.
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> { pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
let path = session_dir.join("pending_lessons.json"); let path = session_dir.join("pending_lessons.json");
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
@@ -358,12 +449,28 @@ pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson>
.unwrap_or_default() .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<()> { pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
let path = session_dir.join("pending_lessons.json"); let path = session_dir.join("pending_lessons.json");
let data = serde_json::to_string_pretty(pending)?; let data = serde_json::to_string_pretty(pending)?;
std::fs::write(&path, data) std::fs::write(&path, data)
} }
/// Commit any auto-resolvable pending lessons whose grace period has
/// elapsed, and persist the remaining queue.
///
/// Flow: load pending lessons → partition into those eligible to commit
/// (`auto_resolve` and older than the 5s grace window) vs. still pending
/// → write eligible lessons as new `Memory` entries with `lifecycle:
/// "active"` → save the remaining (unresolved) queue back to disk.
///
/// Why: the grace window gives the user a brief window to reject an
/// auto-resolving lesson via `resolve_pending_lesson` before it commits.
///
/// Return: the still-pending lessons (post-commit), or an I/O error from
/// writing memory files or the queue.
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> { pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir); let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis(); 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)?; save_pending_lessons(session_dir, &remaining)?;
Ok(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( pub fn resolve_pending_lesson(
session_dir: &std::path::Path, session_dir: &std::path::Path,
memory_dir: &std::path::Path, memory_dir: &std::path::Path,
+136 -5
View File
@@ -1,3 +1,21 @@
//! The `Action` enum and its single dispatcher, `apply_action` — the
//! chokepoint through which every key input, streaming event, and async
//! background-thread result mutates `AppStateRest`.
//!
//! Flow: controllers/subagent threads construct `Action` values → the event
//! loop calls `apply_action(&mut state, action)` → for turn-producing
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
//! execute tool calls via `Harness`, archive messages to SQLite, log edits)
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
//! usage counters).
//!
//! Why: keeping all state mutation behind one function means callers only
//! need to know how to *produce* actions, not how to update state safely;
//! running turns on plain OS threads (rather than blocking the main loop)
//! keeps the TUI responsive while the LLM streams.
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::app::harness::Verdict; 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::app::state::types::{Origin, Overlay, Toast, ToastKind};
use crate::dto::chat::message::{ChatMessage, Role}; use crate::dto::chat::message::{ChatMessage, Role};
// Step bounds intentionally left unbounded (usize::MAX) so the agent can /// A single, well-typed event in the app — produced by key input, the
// continue across as many turns as needed. Each iteration still honours /// streaming pipeline, or subagent threads — that mutates `AppStateRest`
// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is /// when applied via `apply_action`.
// observable and cancellable from the UI. ///
/// 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)] #[derive(Debug, Clone)]
pub enum Action { pub enum Action {
ForceQuit, ForceQuit,
@@ -63,6 +84,18 @@ pub enum Action {
AbortTurn, 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) { pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action { match action {
Action::ForceQuit => { 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) { fn spawn_turn(state: &AppStateRest) {
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() { let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard *guard
@@ -532,6 +578,7 @@ fn spawn_turn(state: &AppStateRest) {
}); });
} }
/// Context bundle passed to `run_agent_turn` on its background thread.
struct TurnCtx { struct TurnCtx {
client: crate::service::provider::LlmClient, client: crate::service::provider::LlmClient,
tdefs: Vec<crate::dto::provider::request::ToolDef>, tdefs: Vec<crate::dto::provider::request::ToolDef>,
@@ -547,6 +594,14 @@ struct TurnCtx {
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>, abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
} }
/// Build an ASCII tree of the workspace directory structure for the
/// system prompt, so the LLM can see the file layout.
///
/// Flow: for each root, walk using `ignore::WalkBuilder` (respecting
/// `.gitignore` and hidden files) → prefix `[DIR]` for directories →
/// truncate after 1000 entries.
///
/// Return: a formatted string with one entry per line.
fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String { fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let mut out = String::new(); let mut out = String::new();
out.push_str("Current Workspace Directory Structure:\n"); out.push_str("Current Workspace Directory Structure:\n");
@@ -575,6 +630,11 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
out out
} }
/// Persist a `ChatMessage` to the SQLite message log, if a database
/// connection is available.
///
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
/// Errors are silently ignored.
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) { fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
if let Some(ref arc) = db { if let Some(ref arc) = db {
if let Ok(conn) = arc.lock() { if let Ok(conn) = arc.lock() {
@@ -583,6 +643,28 @@ fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connect
} }
} }
/// Execute one full agent turn: stream the conversation to the LLM,
/// handle tool calls, and loop until the LLM produces a non-tool response
/// or runs out of unfinished todo items.
///
/// Flow: build system prompt with workspace tree → optionally shape
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
/// and `Usage` events → on streaming success, handle tool calls (gated
/// through `Harness::gate_tool_call`) or unwrap the final assistant
/// message → check for unfinished todo.md tasks (auto-retry with a
/// system message if any remain) → finalise with `Done` and an `edits`
/// SystemNote.
///
/// On streaming failure: retry once with a non-streaming call → if that
/// also fails and there are unfinished tasks, sleep 5s and loop back;
/// otherwise return the error.
///
/// Why: non-streaming fallback handles flaky connections without aborting
/// the turn; todo.md polling lets the agent self-direct toward completeness.
///
/// Return: `Ok(())` on successful completion, or an error from the LLM
/// API after retries are exhausted.
fn run_agent_turn( fn run_agent_turn(
tc: TurnCtx, tc: TurnCtx,
messages: &[ChatMessage], messages: &[ChatMessage],
@@ -836,6 +918,20 @@ fn run_agent_turn(
Ok(()) Ok(())
} }
/// Execute a single tool call: find the tool by name, snapshot the file
/// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for
/// write/edit, and return the output.
///
/// Flow: iterate tools → match by name → for write/edit, snapshot the
/// pre-existing file content into the blob store → call `tool.run()` →
/// for write/edit, compute SHA-256 of the new content and append an
/// `EditLogEntry` → return the tool output string.
///
/// Why: snapshots enable the rewind feature to restore previous content
/// after a write/edit.
///
/// Return: the tool's stdout string, or an error if no matching tool was
/// found or the tool run itself failed.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn execute_one_tool( fn execute_one_tool(
tools: &[Box<dyn crate::tool::Tool>], tools: &[Box<dyn crate::tool::Tool>],
@@ -910,6 +1006,15 @@ fn execute_one_tool(
anyhow::bail!("tool not found: {}", name) 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) { fn maybe_trigger_review(state: &mut AppStateRest) {
if !state.settings.review_enabled { if !state.settings.review_enabled {
return; 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) { fn save_current_session(state: &AppStateRest) {
let base = state.store_base_dir(); let base = state.store_base_dir();
let session = crate::model::session::Session::new( let session = crate::model::session::Session::new(
@@ -943,6 +1055,20 @@ fn save_current_session(state: &AppStateRest) {
} }
} }
/// Run a browser-based OAuth PKCE flow for the given provider.
///
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
/// or a custom provider via env vars) → bind a loopback server → generate
/// a PKCE code verifier and challenge → build the authorisation URL →
/// wait for the redirect code on the loopback server (with a 120s timeout)
/// → exchange the code for a token → save the token to
/// `~/.config/zesdex/oauth_{provider}.json`.
///
/// Why: the `webbrowser::open` call is currently commented out; the user
/// must open the auth URL manually until that line is reinstated.
///
/// Return: a success message on completion, or an error if the flow fails
/// at any step.
fn run_oauth_flow(provider: &str) -> anyhow::Result<String> { fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
use crate::service::oauth::manager::{OAuthConfig, OAuthManager}; use crate::service::oauth::manager::{OAuthConfig, OAuthManager};
use crate::service::oauth::loopback::LoopbackServer; use crate::service::oauth::loopback::LoopbackServer;
@@ -1013,6 +1139,11 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
Ok(format!("Successfully authenticated with {}.", provider)) Ok(format!("Successfully authenticated with {}.", provider))
} }
/// Generate `n` pseudo-random bytes from the current sub-second timestamp.
///
/// Why: avoids pulling in a full RNG crate for the OAuth state token;
/// sufficient for a nonce that only needs to be unpredictable over the
/// lifetime of a single OAuth flow.
fn rand_bytes(n: usize) -> Vec<u8> { fn rand_bytes(n: usize) -> Vec<u8> {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos(); let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos();
+11
View File
@@ -1,7 +1,18 @@
//! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process.
use crate::controller::command::Command; use crate::controller::command::Command;
use crate::app::runtime::actions::Action; use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
///
/// Flow: match each `Command` variant to its handler — most produce a
/// single `Action` (open an overlay, dispatch an OAuth flow, open the
/// editor, etc.); some produce an `Action::SystemNote` for errors or
/// informational responses.
///
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
/// by `apply_action`.
pub fn apply_command(command: Command) -> Vec<Action> { pub fn apply_command(command: Command) -> Vec<Action> {
match command { match command {
Command::Help => { Command::Help => {
+15
View File
@@ -1,3 +1,5 @@
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
//! after any activity, then slows down to conserve CPU.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -7,12 +9,15 @@ const FAST_POLL_MS: u64 = 8;
const SLOW_POLL_MS: u64 = 100; const SLOW_POLL_MS: u64 = 100;
const IDLE_THRESHOLD_MS: u64 = 500; 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 { pub struct EventLoop {
last_activity: Instant, last_activity: Instant,
fast_poll_until: Option<Instant>, fast_poll_until: Option<Instant>,
} }
impl EventLoop { impl EventLoop {
/// Create an `EventLoop` with the current instant as the last activity.
pub fn new() -> Self { pub fn new() -> Self {
EventLoop { EventLoop {
last_activity: Instant::now(), 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 { pub fn poll_interval(&self) -> Duration {
if let Some(fast_until) = self.fast_poll_until { if let Some(fast_until) = self.fast_poll_until {
if Instant::now() < fast_until { if Instant::now() < fast_until {
@@ -29,15 +38,21 @@ impl EventLoop {
Duration::from_millis(SLOW_POLL_MS) 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) { pub fn mark_active(&mut self) {
self.last_activity = Instant::now(); self.last_activity = Instant::now();
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS)); 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 { pub fn is_idle(&self) -> bool {
self.last_activity.elapsed().as_millis() as u64 > IDLE_THRESHOLD_MS 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( pub fn drain_events(
events: &std::sync::Mutex<VecDeque<TurnEvent>>, events: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> Vec<TurnEvent> { ) -> Vec<TurnEvent> {
+2
View File
@@ -1,3 +1,5 @@
//! Runtime layer: action dispatch, slash commands, short-send handling,
//! and the LLM streaming pipeline.
pub mod actions; pub mod actions;
pub mod commands; pub mod commands;
pub mod shortsend; pub mod shortsend;
+24
View File
@@ -1,9 +1,20 @@
//! Short-send / message shaping: compacts long conversation histories so
//! they fit within the provider's context window before being sent to the
//! LLM API.
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
const MAX_WIRE_TOKENS: usize = 2_000_000; const MAX_WIRE_TOKENS: usize = 2_000_000;
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20; const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
const ENGAGE_HYSTERESIS: usize = 5; 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 { pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
if total_messages < MIN_MESSAGES_BEFORE_SHAPE { if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
return false; return false;
@@ -16,6 +27,19 @@ pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
total_messages >= threshold total_messages >= threshold
} }
/// Compact a long message list by dropping middle messages and inserting
/// a summary placeholder.
///
/// Flow: if the estimated token count is within budget, return messages
/// unchanged → otherwise keep the system message and the most recent
/// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior
/// conversation compacted]` system message in between.
///
/// Why: keeps context-size overhead roughly constant regardless of
/// session length.
///
/// Return: a new Vec<ChatMessage> that preserves the first message and
/// the tail.
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> { pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 { if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
return messages.to_vec(); return messages.to_vec();
+37
View File
@@ -1,9 +1,12 @@
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn; pub mod turn;
pub mod tools; pub mod tools;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
/// One atomic event extracted from an LLM streaming response stream.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamEvent { pub enum StreamEvent {
Token(String), Token(String),
@@ -23,6 +26,8 @@ pub enum StreamEvent {
Error(String), Error(String),
} }
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser { pub struct SseParser {
buffer: String, buffer: String,
event_type: Option<String>, event_type: Option<String>,
@@ -30,6 +35,7 @@ pub struct SseParser {
} }
impl SseParser { impl SseParser {
/// Create a new parser with an empty buffer.
pub fn new() -> Self { pub fn new() -> Self {
SseParser { SseParser {
buffer: String::new(), buffer: String::new(),
@@ -38,6 +44,17 @@ impl SseParser {
} }
} }
/// Feed a raw SSE chunk and produce any completed events.
///
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
/// blank line, call `flush_event` to parse the accumulated data →
/// on `event:` line, store the event type → on `data:` line, append
/// to data accumulator → continue until buffer exhausted.
///
/// Edge case: a chunk may split mid-line; the remainder stays in the
/// buffer for the next `feed()` call.
///
/// Return: all `StreamEvent`s completed by this chunk.
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> { pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
self.buffer.push_str(chunk); self.buffer.push_str(chunk);
let mut events = Vec::new(); let mut events = Vec::new();
@@ -57,6 +74,19 @@ impl SseParser {
events events
} }
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
///
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
/// emit `Usage` if a usage object is present → else match `event_type`
/// ("message.stop", "message.delta", etc.) → extract content,
/// reasoning, tool-call deltas, or finish-reason from the delta
/// structure (supporting both Anthropic-style top-level delta and
/// OpenAI-style `choices` array).
///
/// Why: dual-format support in one method avoids a separate
/// provider-specific parsing layer.
///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
fn flush_event(&mut self) -> Vec<StreamEvent> { fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n"); let data = self.data_lines.join("\n");
self.data_lines.clear(); self.data_lines.clear();
@@ -179,6 +209,13 @@ impl SseParser {
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed /// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles /// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
/// standard SSE framing directly), kept for providers/tests that feed raw chunks. /// 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)] #[allow(dead_code)]
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> { pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
let value: Value = serde_json::from_str(data).ok()?; let value: Value = serde_json::from_str(data).ok()?;
+23
View File
@@ -1,3 +1,16 @@
//! Standalone accumulator for streamed tool-call deltas.
//!
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
//! every accumulated call has both a name and arguments.
//!
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
//! but as an independent, reusable type for callers that want to track
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
//! use case.
use super::turn::ParsedToolCall; use super::turn::ParsedToolCall;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -11,10 +24,15 @@ pub struct ToolCallAccumulator {
#[allow(dead_code)] #[allow(dead_code)]
impl ToolCallAccumulator { impl ToolCallAccumulator {
/// Construct an empty accumulator with no tool calls tracked yet.
///
/// Return: a fresh `ToolCallAccumulator`.
pub fn new() -> Self { pub fn new() -> Self {
ToolCallAccumulator { calls: Vec::new() } 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( pub fn add_delta(
&mut self, &mut self,
index: usize, index: usize,
@@ -44,18 +62,23 @@ impl ToolCallAccumulator {
tc.arguments.push_str(arguments_delta); tc.arguments.push_str(arguments_delta);
} }
/// Borrow the accumulated tool calls.
pub fn calls(&self) -> &[ParsedToolCall] { pub fn calls(&self) -> &[ParsedToolCall] {
&self.calls &self.calls
} }
/// Return true once all tool calls have both a name and arguments.
pub fn is_complete(&self) -> bool { pub fn is_complete(&self) -> bool {
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty()) !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) { pub fn reset(&mut self) {
self.calls.clear(); self.calls.clear();
} }
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
/// tool calls, suitable for downstream inspection or replay.
pub fn pending_args(&self) -> Vec<Value> { pub fn pending_args(&self) -> Vec<Value> {
self.calls self.calls
.iter() .iter()
+26 -3
View File
@@ -1,9 +1,14 @@
//! Accumulates streaming LLM responses into complete message/tool-call
//! representation via `StreamedTurn`, and provides a standalone tool-call
//! accumulator in `tools::ToolCallAccumulator`.
use super::StreamEvent; use super::StreamEvent;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction}; use crate::dto::chat::tool::{ToolCall, ToolFunction};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamedTurn { pub struct StreamedTurn {
pub messages: Vec<ChatMessage>, pub messages: Vec<ChatMessage>,
@@ -13,6 +18,7 @@ pub struct StreamedTurn {
pub accumulated_reasoning: String, pub accumulated_reasoning: String,
} }
/// A single tool call being built up from streaming deltas.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedToolCall { pub struct ParsedToolCall {
pub id: String, pub id: String,
@@ -22,9 +28,11 @@ pub struct ParsedToolCall {
} }
impl ParsedToolCall { impl ParsedToolCall {
/// Attempts to parse the accumulated argument string as JSON before the tool call is /// Attempt to parse the accumulated argument string as JSON before
/// marked complete — useful for callers that want a speculative preview mid-stream. /// the tool call is marked complete — useful for a speculative preview.
/// `build_assistant_message` does its own (lossy-fallback) parse for the final message. ///
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
/// if still partial.
#[allow(dead_code)] #[allow(dead_code)]
pub fn try_parse(&self) -> Option<Value> { pub fn try_parse(&self) -> Option<Value> {
serde_json::from_str(&self.arguments).ok() serde_json::from_str(&self.arguments).ok()
@@ -32,6 +40,7 @@ impl ParsedToolCall {
} }
impl StreamedTurn { impl StreamedTurn {
/// Create an empty turn accumulator.
pub fn new() -> Self { pub fn new() -> Self {
StreamedTurn { StreamedTurn {
messages: Vec::new(), 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) { pub fn apply_event(&mut self, event: &StreamEvent) {
match event { match event {
StreamEvent::Token(token) => { StreamEvent::Token(token) => {
@@ -84,6 +99,14 @@ impl StreamedTurn {
} }
} }
/// Finalise the turn into a `ChatMessage`, combining accumulated
/// reasoning (wrapped in `<think>` tags) with content and tool calls.
///
/// Flow: if tool calls exist, build a `ChatMessage` with `tool_calls`
/// set; otherwise build a plain assistant message → set `content` to
/// the combined reasoning+content string (or `None` if empty).
///
/// Return: a complete `ChatMessage` with role `Assistant`.
pub fn build_assistant_message(&self) -> ChatMessage { pub fn build_assistant_message(&self) -> ChatMessage {
let mut msg = if self.tool_calls.is_empty() { let mut msg = if self.tool_calls.is_empty() {
ChatMessage::assistant(None) ChatMessage::assistant(None)
+19
View File
@@ -1,10 +1,16 @@
//! Shallow state diffing — records opaque "modified" markers so the TUI
//! knows to re-render without computing fine-grained deltas.
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDiff { pub struct StateDiff {
changes: Vec<Change>, changes: Vec<Change>,
} }
/// A single named change — currently always carries a flat `"."` path
/// and `"modified"` kind because the system does not track granular diffs.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change { pub struct Change {
pub path: String, pub path: String,
@@ -12,23 +18,36 @@ pub struct Change {
} }
impl StateDiff { impl StateDiff {
/// Create an empty diff.
pub fn new() -> Self { pub fn new() -> Self {
StateDiff { changes: Vec::new() } StateDiff { changes: Vec::new() }
} }
/// Record a change at `path` of the given `kind`.
pub fn add_change(&mut self, path: String, kind: String) { pub fn add_change(&mut self, path: String, kind: String) {
self.changes.push(Change { path, kind }); self.changes.push(Change { path, kind });
} }
/// Return true if no changes have been recorded.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.changes.is_empty() self.changes.is_empty()
} }
/// Remove all recorded changes.
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.changes.clear(); self.changes.clear();
} }
} }
/// Compute a shallow diff between two serialised state values.
///
/// Flow: compare with `==`, return an empty vec if equal, otherwise
/// return a single `Change { ".", "modified" }`.
///
/// Why: a placeholder — the current rendering model re-validates the
/// whole viewport every frame, so fine-grained diffs are unnecessary.
///
/// Return: the list of changes (always 0 or 1 entry).
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> { pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
if before == after { if before == after {
return Vec::new(); return Vec::new();
+49
View File
@@ -1,26 +1,33 @@
//! Application-level "miscellaneous" state: scroll, input buffer,
//! overlay stack, toasts, editor, and autocomplete.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use super::types::Overlay; use super::types::Overlay;
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
#[derive(Clone)] #[derive(Clone)]
pub struct DirCache { pub struct DirCache {
entries: Arc<RwLock<Vec<PathBuf>>>, entries: Arc<RwLock<Vec<PathBuf>>>,
} }
impl DirCache { impl DirCache {
/// Create an empty `DirCache`.
pub fn new() -> Self { pub fn new() -> Self {
DirCache { DirCache {
entries: Arc::new(RwLock::new(Vec::new())), entries: Arc::new(RwLock::new(Vec::new())),
} }
} }
/// Replace the cached entries (async write).
pub async fn set(&self, paths: Vec<PathBuf>) { pub async fn set(&self, paths: Vec<PathBuf>) {
let mut w = self.entries.write().await; let mut w = self.entries.write().await;
*w = paths; *w = paths;
} }
} }
/// Manages the viewport scroll offset.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ScrollState { pub struct ScrollState {
pub offset: usize, pub offset: usize,
@@ -28,6 +35,7 @@ pub struct ScrollState {
} }
impl ScrollState { impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self { pub fn new() -> Self {
ScrollState { ScrollState {
offset: 0, 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) { pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount); 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) { pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount); self.offset = self.offset.saturating_sub(amount);
} }
/// Update the maximum number of visible lines.
pub fn set_max_visible(&mut self, max: usize) { pub fn set_max_visible(&mut self, max: usize) {
self.max_visible = max; self.max_visible = max;
} }
} }
/// The user's input buffer, cursor position, history, and autocomplete
/// state for the chat prompt.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct InputState { pub struct InputState {
pub buffer: String, pub buffer: String,
@@ -81,6 +95,8 @@ const COMMANDS: &[&str] = &[
]; ];
impl InputState { impl InputState {
/// Create an empty input state with no buffer, no history, and no
/// autocomplete.
pub fn new() -> Self { pub fn new() -> Self {
InputState { InputState {
buffer: String::new(), buffer: String::new(),
@@ -94,6 +110,7 @@ impl InputState {
} }
} }
/// Hide the autocomplete dropdown and clear its state.
pub fn close_autocomplete(&mut self) { pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false; self.autocomplete_visible = false;
self.autocomplete_candidates.clear(); self.autocomplete_candidates.clear();
@@ -101,6 +118,12 @@ impl InputState {
self.autocomplete_idx = 0; 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) { pub fn open_autocomplete(&mut self) {
let trimmed = self.buffer.trim().to_string(); let trimmed = self.buffer.trim().to_string();
if trimmed.is_empty() || !trimmed.starts_with('/') { if trimmed.is_empty() || !trimmed.starts_with('/') {
@@ -119,6 +142,8 @@ impl InputState {
self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); 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) { pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len(); let n = self.autocomplete_candidates.len();
if n == 0 { return; } 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 { pub fn select_autocomplete(&mut self) -> bool {
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) { if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
self.buffer = candidate.clone(); 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) { pub fn tab_complete(&mut self) {
// Legacy inline tab-complete — used as a fallback when the dropdown // Legacy inline tab-complete — used as a fallback when the dropdown
// isn't visible yet. Opens the dropdown on the first Tab press. // 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) { pub fn char_left(&mut self) {
if self.cursor > 0 { if self.cursor > 0 {
self.cursor -= 1; self.cursor -= 1;
} }
} }
/// Move the cursor right by one character (if not at the end).
pub fn char_right(&mut self) { pub fn char_right(&mut self) {
if self.cursor < self.buffer.len() { if self.cursor < self.buffer.len() {
self.cursor += 1; self.cursor += 1;
} }
} }
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) { pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c); self.buffer.insert(self.cursor, c);
self.cursor += 1; self.cursor += 1;
} }
/// Delete the character to the left of the cursor (backspace).
pub fn delete_left(&mut self) { pub fn delete_left(&mut self) {
if self.cursor > 0 { if self.cursor > 0 {
self.cursor -= 1; self.cursor -= 1;
@@ -174,12 +209,17 @@ impl InputState {
} }
} }
/// Delete the character at the cursor position (forward delete).
pub fn delete_right(&mut self) { pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() { if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor); 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 { pub fn submit(&mut self) -> String {
let result = self.buffer.clone(); let result = self.buffer.clone();
if !result.is_empty() { if !result.is_empty() {
@@ -191,6 +231,7 @@ impl InputState {
result result
} }
/// Navigate backward through input history.
pub fn history_up(&mut self) { pub fn history_up(&mut self) {
if self.history.is_empty() { if self.history.is_empty() {
return; return;
@@ -205,6 +246,7 @@ impl InputState {
self.cursor = self.buffer.len(); self.cursor = self.buffer.len();
} }
/// Navigate forward through input history (back toward the newest entry).
pub fn history_down(&mut self) { pub fn history_down(&mut self) {
match self.history_idx { match self.history_idx {
Some(i) if i < self.history.len() - 1 => { 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)] #[derive(Debug, Clone)]
pub struct MiscState { pub struct MiscState {
pub overlay: Overlay, pub overlay: Overlay,
@@ -237,6 +281,8 @@ pub struct MiscState {
} }
impl MiscState { impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts, and default
/// effort level 1.
pub fn new() -> Self { pub fn new() -> Self {
MiscState { MiscState {
overlay: Overlay::None, overlay: Overlay::None,
@@ -255,6 +301,9 @@ impl MiscState {
self.toasts.push(toast); self.toasts.push(toast);
} }
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
///
/// Return: the expired toasts (after removal).
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> { pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect(); let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
self.toasts.retain(|t| !t.expired(now_ms)); self.toasts.retain(|t| !t.expired(now_ms));
+2
View File
@@ -1,3 +1,5 @@
//! Application state: misc fields, the main `AppStateRest` struct,
//! runtime-only state, and shared types (overlays, toasts, origins).
pub mod misc; pub mod misc;
pub mod rest; pub mod rest;
pub mod runtime; pub mod runtime;
+39
View File
@@ -1,3 +1,9 @@
//! Top-level mutable application state (`AppStateRest`) and the transcript
//! display type it owns.
//!
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
//! `actions/mod.rs` and `controller/input.rs`; every other module reads it.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -12,6 +18,7 @@ use crate::model::app_config::AppConfig;
use crate::model::editlog::EditLog; use crate::model::editlog::EditLog;
use crate::model::settings::Settings; use crate::model::settings::Settings;
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay { pub struct ChatMessageDisplay {
pub role: crate::dto::chat::message::Role, pub role: crate::dto::chat::message::Role,
@@ -20,6 +27,7 @@ pub struct ChatMessageDisplay {
} }
impl 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 { pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
ChatMessageDisplay { ChatMessageDisplay {
role, 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)] #[derive(Clone)]
pub struct AppStateRest { pub struct AppStateRest {
@@ -58,6 +71,15 @@ pub struct AppStateRest {
} }
impl AppStateRest { impl AppStateRest {
/// Construct the initial application state for a session.
///
/// Flow: load settings/config -> derive download/worktree dirs from
/// `memory_dir`'s parent -> derive `session_id` from the session dir's
/// file name -> build the sub-state structs.
///
/// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails.
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self { pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
let settings = Settings::load(); let settings = Settings::load();
let app_config = AppConfig::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 { pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| { self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned"); 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) { pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg); self.transcript_cache.messages.push(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
@@ -123,11 +151,19 @@ impl AppStateRest {
self.dirty = true; self.dirty = true;
} }
/// Queue a toast notification for display and mark the app dirty.
pub fn push_toast(&mut self, toast: Toast) { pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast); self.misc.push_toast(toast);
self.dirty = true; 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 { pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir.parent() self.session_dir.parent()
.and_then(|p| p.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 { pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
self.tool_ctx_for(Origin::Main) 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 { pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
crate::tool::ToolCtx { crate::tool::ToolCtx {
workspaces: self.workspace_roots.clone(), workspaces: self.workspace_roots.clone(),
+18
View File
@@ -1,6 +1,11 @@
//! Per-session runtime state: message history, pending tool queue,
//! background bash jobs, lesson/review counters, and the `TurnEvent`
//! stream emitted while an agent turn is in flight.
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats { pub struct UsageStats {
pub tokens_in: u64, pub tokens_in: u64,
@@ -10,6 +15,9 @@ pub struct UsageStats {
pub total_ms: u64, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRuntime { pub struct SessionRuntime {
pub messages: Vec<crate::dto::chat::message::ChatMessage>, pub messages: Vec<crate::dto::chat::message::ChatMessage>,
@@ -36,6 +44,7 @@ pub struct SessionRuntime {
pub usage: UsageStats, pub usage: UsageStats,
} }
/// Record of one completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult { pub struct ToolCallResult {
pub tool_call_id: String, pub tool_call_id: String,
@@ -45,6 +54,8 @@ pub struct ToolCallResult {
pub duration_ms: u64, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingTool { pub struct PendingTool {
pub tool_name: String, pub tool_name: String,
@@ -52,6 +63,8 @@ pub struct PendingTool {
pub execution_model: crate::app::state::types::ExecutionModel, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BashJobRef { pub struct BashJobRef {
pub id: String, pub id: String,
@@ -60,6 +73,8 @@ pub struct BashJobRef {
pub running: bool, 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)] #[derive(Debug, Clone)]
pub enum TurnEvent { pub enum TurnEvent {
AssistantMessage(crate::dto::chat::message::ChatMessage), AssistantMessage(crate::dto::chat::message::ChatMessage),
@@ -86,6 +101,8 @@ pub enum TurnEvent {
} }
impl SessionRuntime { 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 { pub fn new(session_dir: PathBuf) -> Self {
SessionRuntime { SessionRuntime {
messages: Vec::new(), 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) { pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
self.messages.push(msg); self.messages.push(msg);
} }
+11
View File
@@ -1,11 +1,16 @@
//! Opaque, serializable snapshot of application state used for
//! attach/daemon IPC transfer.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot { pub struct StateSnapshot {
pub snapshot: serde_json::Value, pub snapshot: serde_json::Value,
} }
impl StateSnapshot { impl StateSnapshot {
/// Create an empty snapshot (`{}`).
pub fn new() -> Self { pub fn new() -> Self {
StateSnapshot { StateSnapshot {
snapshot: serde_json::json!({}), snapshot: serde_json::json!({}),
@@ -13,10 +18,16 @@ impl StateSnapshot {
} }
} }
/// Serialize a snapshot to bytes for transport over the daemon socket.
///
/// Return: JSON-encoded bytes, or a serde error.
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> { pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(snapshot)?) Ok(serde_json::to_vec(snapshot)?)
} }
/// Parse a snapshot previously produced by `serialize_snapshot`.
///
/// Return: the decoded `StateSnapshot`, or a serde error.
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> { pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
Ok(serde_json::from_slice(data)?) Ok(serde_json::from_slice(data)?)
} }
+16
View File
@@ -1,6 +1,10 @@
//! Shared small state types: toasts, overlays, the transcript cache,
//! tool execution model, and call origin tags.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Severity/category of a toast notification, used to pick its color.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind { pub enum ToastKind {
Info, Info,
@@ -10,6 +14,8 @@ pub enum ToastKind {
Lesson, Lesson,
} }
/// A transient status message shown in the TUI, auto-dismissed after
/// `lifetime_ms`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Toast { pub struct Toast {
pub kind: ToastKind, pub kind: ToastKind,
@@ -19,6 +25,7 @@ pub struct Toast {
} }
impl Toast { impl Toast {
/// Create a toast with a default 5-second lifetime, stamped with now.
pub fn new(kind: ToastKind, message: String) -> Self { pub fn new(kind: ToastKind, message: String) -> Self {
Toast { Toast {
kind, 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 { pub fn expired(&self, now_ms: i64) -> bool {
now_ms - self.created_at > self.lifetime_ms as i64 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay { pub enum Overlay {
None, None,
@@ -56,11 +65,13 @@ pub enum Overlay {
} }
impl Overlay { impl Overlay {
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool { pub fn is_active(self) -> bool {
!matches!(self, Overlay::None) !matches!(self, Overlay::None)
} }
} }
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct TranscriptCache { pub struct TranscriptCache {
pub messages: Vec<super::rest::ChatMessageDisplay>, pub messages: Vec<super::rest::ChatMessageDisplay>,
@@ -69,6 +80,7 @@ pub struct TranscriptCache {
} }
impl TranscriptCache { impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self { pub fn new(max_lines: usize) -> Self {
TranscriptCache { TranscriptCache {
messages: Vec::new(), 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExecutionModel { pub enum ExecutionModel {
Inline, Inline,
@@ -85,6 +98,8 @@ pub enum ExecutionModel {
AsyncTokio, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum Origin { pub enum Origin {
Main, Main,
@@ -93,6 +108,7 @@ pub enum Origin {
} }
impl Origin { impl Origin {
/// Short string tag for this origin, used in filenames and logs.
pub fn tag(&self) -> String { pub fn tag(&self) -> String {
match self { match self {
Origin::Main => "main".to_string(), Origin::Main => "main".to_string(),
+14
View File
@@ -1,8 +1,14 @@
//! Construction of a `SubagentContext` from an `AgentDefinition`,
//! including the default read-only tool set for reviewer agents.
use std::path::PathBuf; use std::path::PathBuf;
use super::spawn::AgentDefinition; use super::spawn::AgentDefinition;
/// Default read-only tool names granted to `role == "reviewer"` agents.
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"]; 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 struct SubagentContext {
pub system_prompt: String, pub system_prompt: String,
pub allowed_tools: Vec<String>, pub allowed_tools: Vec<String>,
@@ -10,6 +16,14 @@ pub struct SubagentContext {
pub session_dir: PathBuf, 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 { pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
if def.role == "reviewer" { if def.role == "reviewer" {
+38 -5
View File
@@ -1,3 +1,7 @@
//! Subagent execution loop: drive an LLM conversation, gate tool calls
//! against the context's allowlist, run tools, and stream progress events
//! to the parent via an mpsc channel.
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef; 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::context::SubagentContext;
use super::event::SubagentEvent; use super::event::SubagentEvent;
/// Upper bound on agent loop steps; effectively unbounded (`usize::MAX`).
#[allow(dead_code)] #[allow(dead_code)]
pub const MAX_AGENT_STEPS: usize = usize::MAX; pub const MAX_AGENT_STEPS: usize = usize::MAX;
/// Maps a subagent's allowed tool names to concrete Tool trait objects and /// 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 /// OpenAI-style tool definitions.
/// available; otherwise only explicitly allowed ones are included. ///
/// Flow: load `all_tools()` → if `allowed_tools` is empty, use all; else
/// filter by membership → derive `ToolDef`s for the LLM.
///
/// Why: an empty allowlist means "no restriction" (matches
/// `build_subagent_context`'s default for non-reviewer roles).
///
/// Return: `(tool impls, schema defs)` for the subagent to use.
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) { fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools(); let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() { let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
@@ -24,9 +36,16 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::T
(filtered, defs) (filtered, defs)
} }
/// Resolves the API key, model, and base URL from the persisted application /// Resolve the API key, model, and base URL from persisted app config.
/// configuration rather than environment variables, matching how the main agent ///
/// resolves its credentials. /// Flow: try the settings key for the active provider → fall back to the
/// provider's `api_key_env` env-var → fall back to the provider's
/// `default_api_key` → fall back to an empty string.
///
/// Why: matches the main agent's credential resolution exactly, so
/// subagents automatically inherit the same provider settings.
///
/// Return: `(api_key, model, optional_base_url)`.
fn resolve_provider_config() -> (String, String, Option<String>) { fn resolve_provider_config() -> (String, String, Option<String>) {
let settings = crate::model::settings::Settings::load(); let settings = crate::model::settings::Settings::load();
let app_config = crate::model::app_config::AppConfig::load(); let app_config = crate::model::app_config::AppConfig::load();
@@ -54,6 +73,20 @@ fn resolve_provider_config() -> (String, String, Option<String>) {
(api_key, model, base_url) (api_key, model, base_url)
} }
/// Synchronous subagent entry point: run up to `ctx.max_steps` iterations
/// of the LLM tool loop.
///
/// Flow: inject system prompt → for each step: resolve provider config,
/// build an LLM client, call `chat_with_tools_non_streaming`, process tool
/// calls or collect text output → send `SubagentEvent`s on `tx` → break on
/// first text-only (non-empty) response.
///
/// Why: runs synchronously on a dedicated thread so the main async event
/// loop is not blocked. Tool gating prevents restricted or risky tools from
/// executing unless explicitly allowed.
///
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
/// call fails at any step.
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> { pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
let mut output = String::new(); let mut output = String::new();
let mut messages: Vec<ChatMessage> = Vec::new(); let mut messages: Vec<ChatMessage> = Vec::new();
+5
View File
@@ -1,5 +1,10 @@
//! Event variants that a running subagent can emit to its parent via the
//! shared mpsc channel.
use serde_json::Value; use serde_json::Value;
/// Progress and outcome events emitted by `run_subagent` as it processes
/// LLM responses and tool calls.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SubagentEvent { pub enum SubagentEvent {
StepCompleted { StepCompleted {
+3
View File
@@ -1,3 +1,6 @@
//! Subagent management: spawning, context building, engine loop, and
//! progress events.
pub mod context; pub mod context;
pub mod engine; pub mod engine;
pub mod event; pub mod event;
+8
View File
@@ -1,5 +1,10 @@
//! AgentDefinition -- declarative specification for instantiating a
//! subagent from workflow scripts or programmatic calls.
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition { pub struct AgentDefinition {
pub name: String, pub name: String,
@@ -11,6 +16,8 @@ pub struct AgentDefinition {
} }
impl 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 { pub fn new(name: String, role: String) -> Self {
AgentDefinition { AgentDefinition {
name, 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 { pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps); self.max_steps = Some(steps);
self self
+50
View File
@@ -1,3 +1,7 @@
//! Workflow engine: interprets `ScriptPrimitive` values (agent, parallel,
//! pipeline, phase) by spawning subagents, collecting results, and
//! managing concurrency.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -5,6 +9,7 @@ use super::script::{ScriptPrimitive, WorkflowScript};
static FINDINGS: Mutex<Vec<String>> = Mutex::new(Vec::new()); static FINDINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The lifecycle state of an agent within a workflow run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentState { pub enum AgentState {
Idle, Idle,
@@ -13,6 +18,7 @@ pub enum AgentState {
Failed, Failed,
} }
/// Timestamped status of one workflow agent.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatus { pub struct AgentStatus {
pub state: AgentState, pub state: AgentState,
@@ -21,6 +27,7 @@ pub struct AgentStatus {
pub error: Option<String>, pub error: Option<String>,
} }
/// A single agent tracked within a workflow run.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowAgent { pub struct WorkflowAgent {
pub id: String, pub id: String,
@@ -28,6 +35,8 @@ pub struct WorkflowAgent {
pub status: AgentStatus, pub status: AgentStatus,
} }
/// Orchestrator for running workflow scripts: holds agent roster and a
/// shared finding accumulator visible to all pipeline stages.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WorkflowEngine { pub struct WorkflowEngine {
pub agents: Vec<WorkflowAgent>, pub agents: Vec<WorkflowAgent>,
@@ -35,6 +44,7 @@ pub struct WorkflowEngine {
} }
impl WorkflowEngine { impl WorkflowEngine {
/// Create an empty workflow engine with no agents or findings.
pub fn new() -> Self { pub fn new() -> Self {
WorkflowEngine { WorkflowEngine {
agents: Vec::new(), agents: Vec::new(),
@@ -43,6 +53,14 @@ impl WorkflowEngine {
} }
} }
/// Spawn a single synchronous subagent with the given prompt, passing it
/// any findings from earlier sibling agents.
///
/// Flow: build an `AgentDefinition` -> build a `SubagentContext` ->
/// inject findings into the system prompt -> call `run_subagent` on a
/// dedicated mpsc channel.
///
/// Return: the agent's text output, or an error on failure.
fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::Result<String> { fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::Result<String> {
use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent; use crate::app::subagent::engine::run_subagent;
@@ -74,6 +92,20 @@ fn spawn_single_agent(prompt: &str, findings_snapshot: Vec<String>) -> anyhow::R
type ParallelResult = (usize, anyhow::Result<Vec<String>>); type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
/// concurrency cap for parallel branches.
///
/// Flow: match the primitive ->
/// `Agent` -> `spawn_single_agent`
/// `Parallel` -> spawn threads up to `concurrency_cap`, join
/// `Pipeline` -> spawn threads sequentially, collect in order
/// `Phase` -> recurse (pass-through wrapper)
///
/// Why: parallelism is implemented with `std::thread::spawn` and a
/// counting semaphore so the main async event loop remains unblocked.
///
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted.
pub fn execute_primitive( pub fn execute_primitive(
primitive: &ScriptPrimitive, primitive: &ScriptPrimitive,
args: &HashMap<String, String>, args: &HashMap<String, String>,
@@ -169,6 +201,14 @@ pub fn execute_primitive(
} }
} }
/// Run a `WorkflowScript` with the given template arguments and produce a
/// summary string.
///
/// Flow: clear the global finding store -> cap concurrency to 5 -> call
/// `execute_primitive` on the script's root primitive -> format results
/// into a one-line-per-agent summary.
///
/// Return: a human-readable summary string.
pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> { pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) -> anyhow::Result<String> {
if let Ok(mut findings) = FINDINGS.lock() { if let Ok(mut findings) = FINDINGS.lock() {
findings.clear(); findings.clear();
@@ -201,12 +241,19 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
Ok(summary) 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) { pub fn note_finding(text: &str) {
if let Ok(mut findings) = FINDINGS.lock() { if let Ok(mut findings) = FINDINGS.lock() {
findings.push(text.to_string()); findings.push(text.to_string());
} }
} }
/// Simple template engine: replace `{{key}}` placeholders with values
/// from `args`.
///
/// Why: a structed template engine is unnecessary for the limited
/// use-case; this is intentionally simple and safe.
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String { fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
let mut result = template.to_string(); let mut result = template.to_string();
for (key, value) in args { for (key, value) in args {
@@ -215,6 +262,9 @@ fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
result result
} }
/// A counting semaphore built from a `Mutex` + `Condvar`.
///
/// Used by `execute_primitive` to cap concurrent parallel branches.
struct Semaphore { struct Semaphore {
count: Mutex<usize>, count: Mutex<usize>,
condvar: std::sync::Condvar, condvar: std::sync::Condvar,
+3
View File
@@ -1,2 +1,5 @@
//! Workflow orchestration: a script interpreter that runs pipeline/parallel
//! primitives across multiple subagent instances.
pub mod engine; pub mod engine;
pub mod script; pub mod script;
+12
View File
@@ -1,16 +1,27 @@
//! Script primitives for the workflow engine: agent invocation, parallel
//! execution, pipelines, and phases.
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ScriptPrimitive { pub enum ScriptPrimitive {
/// Run a single agent with the given prompt template.
Agent(String), Agent(String),
/// Execute several primitives concurrently.
Parallel(Vec<ScriptPrimitive>), Parallel(Vec<ScriptPrimitive>),
/// Execute several primitives sequentially, each waiting for the
/// previous to complete.
Pipeline(Vec<ScriptPrimitive>), Pipeline(Vec<ScriptPrimitive>),
/// A named wrapper around another primitive (used for display/tracing).
Phase { Phase {
name: String, name: String,
script: Box<ScriptPrimitive>, script: Box<ScriptPrimitive>,
}, },
} }
/// Runtime options for a workflow execution.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptOptions { pub struct ScriptOptions {
pub max_concurrency: usize, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowScript { pub struct WorkflowScript {
pub name: String, pub name: String,
+12
View File
@@ -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; use crate::app::mode::ModeKind;
/// A parsed slash command from the TUI input buffer.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Command { pub enum Command {
Help, Help,
@@ -23,6 +27,14 @@ pub enum Command {
Unknown(String), 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 { pub fn parse_command(text: &str) -> Command {
let text = text.trim(); let text = text.trim();
if !text.starts_with('/') { if !text.starts_with('/') {
+20
View File
@@ -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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::mode; use crate::app::mode;
@@ -7,6 +11,16 @@ use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
use crate::controller::command::parse_command; 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<Action> { pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// While Editor overlay is active, route input directly to the editor handler // While Editor overlay is active, route input directly to the editor handler
if state.misc.overlay == Overlay::Editor { if state.misc.overlay == Overlay::Editor {
@@ -192,6 +206,12 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
} }
} }
/// 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<Action> { fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
match state.misc.overlay { match state.misc.overlay {
Overlay::Bash => { Overlay::Bash => {
+2
View File
@@ -1,2 +1,4 @@
//! Keyboard input handling and command parsing for the TUI.
pub mod command; pub mod command;
pub mod input; pub mod input;
+10
View File
@@ -1,5 +1,9 @@
//! Chat message types shared across the DTO layer: `Role` and `ChatMessage`
//! with convenience constructors.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The conversation participant who authored a message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role { pub enum Role {
#[serde(rename = "user")] #[serde(rename = "user")]
@@ -15,6 +19,8 @@ pub enum Role {
impl Role { impl Role {
} }
/// A single message in a conversation, compatible with the OpenAI/Anthropic
/// chat-completion API structures.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage { pub struct ChatMessage {
pub role: Role, pub role: Role,
@@ -28,6 +34,7 @@ pub struct ChatMessage {
} }
impl ChatMessage { impl ChatMessage {
/// Build a user-role message with the given text content.
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
ChatMessage { ChatMessage {
role: Role::User, role: Role::User,
@@ -38,6 +45,7 @@ impl ChatMessage {
} }
} }
/// Build an assistant-role message with an optional text response.
pub fn assistant(content: Option<String>) -> Self { pub fn assistant(content: Option<String>) -> Self {
ChatMessage { ChatMessage {
role: Role::Assistant, 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<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
ChatMessage { ChatMessage {
role: Role::System, 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 { pub fn tool_result(tool_call_id: String, content: String) -> Self {
ChatMessage { ChatMessage {
role: Role::Tool, role: Role::Tool,
+2
View File
@@ -1,2 +1,4 @@
//! Chat DTO submodules: message roles/content and tool-call structures.
pub mod message; pub mod message;
pub mod tool; pub mod tool;
+22
View File
@@ -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::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
/// A single tool-call request emitted by the model in an assistant message.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall { pub struct ToolCall {
pub id: String, pub id: String,
@@ -9,12 +20,23 @@ pub struct ToolCall {
pub function: ToolFunction, pub function: ToolFunction,
} }
/// The function name and raw arguments payload for a `ToolCall`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction { pub struct ToolFunction {
pub name: String, pub name: String,
pub arguments: Value, 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 { pub fn sanitize_tool_arguments(args: &Value) -> Value {
match args { match args {
Value::String(s) => { Value::String(s) => {
+3
View File
@@ -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 chat;
pub mod provider; pub mod provider;
+2
View File
@@ -1,3 +1,5 @@
//! Provider-facing DTOs: chat completion request, response, and usage/cost.
pub mod request; pub mod request;
pub mod response; pub mod response;
pub mod usage; pub mod usage;
+33
View File
@@ -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::{Deserialize, Serialize};
use serde_json::Value; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest { pub struct ChatRequest {
pub model: String, pub model: String,
@@ -21,11 +40,21 @@ pub struct ChatRequest {
pub stream_options: Option<StreamOptions>, pub stream_options: Option<StreamOptions>,
} }
/// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions { pub struct StreamOptions {
pub include_usage: bool, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef { pub struct ToolDef {
#[serde(rename = "type")] #[serde(rename = "type")]
@@ -33,6 +62,10 @@ pub struct ToolDef {
pub function: ToolFunctionDef, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionDef { pub struct ToolFunctionDef {
pub name: String, pub name: String,
+20
View File
@@ -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}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse { pub struct ChatResponse {
pub id: String, pub id: String,
@@ -9,6 +25,10 @@ pub struct ChatResponse {
pub created: Option<i64>, pub created: Option<i64>,
} }
/// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice { pub struct Choice {
pub index: u32, pub index: u32,
+13
View File
@@ -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}; 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)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Usage { pub struct Usage {
pub prompt_tokens: Option<u32>, pub prompt_tokens: Option<u32>,
+17
View File
@@ -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 anyhow::Result;
use super::conn::Connection; use super::conn::Connection;
/// Client-side handle for the `--attach` process: wraps a `Connection`
/// to a daemon's Unix socket.
pub struct IpcClient { pub struct IpcClient {
conn: Connection, conn: Connection,
} }
impl IpcClient { 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<Self> { pub fn connect_unix(path: &str) -> Result<Self> {
let conn = Connection::connect_unix(path)?; let conn = Connection::connect_unix(path)?;
Ok(IpcClient { conn }) Ok(IpcClient { conn })
} }
/// Serialize and send a value to the daemon (see `frame::write_frame`).
pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> { pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
self.conn.send(value) 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<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> { pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> {
self.conn.receive() self.conn.receive()
} }
+17
View File
@@ -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 std::os::unix::net::UnixStream;
use anyhow::Result; use anyhow::Result;
use super::frame; 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 { pub struct Connection {
inner: UnixStream, inner: UnixStream,
} }
impl Connection { impl Connection {
/// Wrap an already-connected/accepted `UnixStream`.
pub fn from_stream(stream: UnixStream) -> Result<Self> { pub fn from_stream(stream: UnixStream) -> Result<Self> {
Ok(Connection { inner: stream }) Ok(Connection { inner: stream })
} }
/// Open a new Unix-socket connection to `path`.
pub fn connect_unix(path: &str) -> Result<Self> { pub fn connect_unix(path: &str) -> Result<Self> {
let stream = UnixStream::connect(path)?; let stream = UnixStream::connect(path)?;
Ok(Connection { inner: stream }) Ok(Connection { inner: stream })
} }
/// Serialize `value` to JSON and write it as one length-prefixed frame.
pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> { pub fn send<T: serde::Serialize>(&mut self, value: &T) -> Result<()> {
let data = frame::serialize_frame(value)?; let data = frame::serialize_frame(value)?;
frame::write_frame(&mut self.inner, &data) 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<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> { pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> Result<Option<T>> {
let data = frame::read_frame(&mut self.inner)?; let data = frame::read_frame(&mut self.inner)?;
match data { match data {
+26
View File
@@ -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<Change>`, built via `StateDiff::new`/`add_change`
//! and reset via `clear`.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDiff { pub struct StateDiff {
pub timestamp: i64, pub timestamp: i64,
@@ -8,6 +19,7 @@ pub struct StateDiff {
pub changes: Vec<Change>, pub changes: Vec<Change>,
} }
/// A single field change: the JSON path and its old/new values.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change { pub struct Change {
pub path: String, pub path: String,
@@ -16,6 +28,7 @@ pub struct Change {
} }
impl StateDiff { impl StateDiff {
/// Create an empty diff for `session_id`, timestamped at creation.
pub fn new(session_id: String) -> Self { pub fn new(session_id: String) -> Self {
StateDiff { StateDiff {
timestamp: chrono::Utc::now().timestamp_millis(), 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<Value>, new_value: Option<Value>) { pub fn add_change(&mut self, path: String, old_value: Option<Value>, new_value: Option<Value>) {
self.changes.push(Change { self.changes.push(Change {
path, path,
@@ -32,16 +46,28 @@ impl StateDiff {
}); });
} }
/// Whether the diff has no recorded changes.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.changes.is_empty() self.changes.is_empty()
} }
/// Drop all changes and refresh the timestamp.
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.changes.clear(); self.changes.clear();
self.timestamp = chrono::Utc::now().timestamp_millis(); 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<Change>) { pub fn compute_diff(before: &Value, after: &Value, path: &str, changes: &mut Vec<Change>) {
if before == after { if before == after {
return; return;
+30
View File
@@ -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 std::io::{Read, Write};
use anyhow::Result; 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; 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<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> { pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
let len = data.len(); let len = data.len();
if len > MAX_FRAME_SIZE { if len > MAX_FRAME_SIZE {
@@ -15,6 +35,14 @@ pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
Ok(()) 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<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> { pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4]; let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf) { match reader.read_exact(&mut len_buf) {
@@ -31,6 +59,7 @@ pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
Ok(Some(buf)) Ok(Some(buf))
} }
/// Serialize `value` to JSON bytes, rejecting output over `MAX_FRAME_SIZE`.
pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> { pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
let json = serde_json::to_vec(value)?; let json = serde_json::to_vec(value)?;
if json.len() > MAX_FRAME_SIZE { if json.len() > MAX_FRAME_SIZE {
@@ -39,6 +68,7 @@ pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
Ok(json) Ok(json)
} }
/// Deserialize a frame's raw JSON bytes into `T`.
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> { pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
Ok(serde_json::from_slice(data)?) Ok(serde_json::from_slice(data)?)
} }
+4
View File
@@ -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 client;
pub mod conn; pub mod conn;
pub mod frame; pub mod frame;
+21
View File
@@ -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}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum KeyAction { pub enum KeyAction {
Char(char), Char(char),
@@ -19,6 +33,8 @@ pub enum KeyAction {
Function(u8), 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientRequest { pub enum ClientRequest {
Tick, Tick,
@@ -33,6 +49,7 @@ pub enum ClientRequest {
Close, Close,
} }
/// Flattened chat message sent from daemon to client for transcript display.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEntry { pub struct MessageEntry {
pub role: String, pub role: String,
@@ -40,6 +57,7 @@ pub struct MessageEntry {
pub timestamp: i64, pub timestamp: i64,
} }
/// Flattened toast notification sent from daemon to client for rendering.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastEntry { pub struct ToastEntry {
pub kind: String, pub kind: String,
@@ -48,6 +66,8 @@ pub struct ToastEntry {
pub lifetime_ms: u64, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatePayload { pub struct StatePayload {
pub session_id: String, pub session_id: String,
@@ -61,6 +81,7 @@ pub struct StatePayload {
pub input_cursor: usize, pub input_cursor: usize,
} }
/// Messages the daemon sends back to an attached client.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DaemonFrame { pub enum DaemonFrame {
StateUpdate(Box<StatePayload>), StateUpdate(Box<StatePayload>),
+15
View File
@@ -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 std::os::unix::net::UnixListener;
use anyhow::Result; use anyhow::Result;
use super::conn::Connection; 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 { pub struct IpcServer {
listener: UnixListener, listener: UnixListener,
} }
impl IpcServer { 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<Self> { pub fn bind_unix(path: &str) -> Result<Self> {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path)?; let listener = UnixListener::bind(path)?;
Ok(IpcServer { listener }) Ok(IpcServer { listener })
} }
/// Block until a client connects, then wrap it as a `Connection`.
pub fn accept(&self) -> Result<Connection> { pub fn accept(&self) -> Result<Connection> {
let (stream, _addr) = self.listener.accept()?; let (stream, _addr) = self.listener.accept()?;
Connection::from_stream(stream) Connection::from_stream(stream)
+14
View File
@@ -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::{Deserialize, Serialize};
use serde_json::Value; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot { pub struct StateSnapshot {
pub timestamp: i64, pub timestamp: i64,
@@ -15,6 +26,7 @@ pub struct StateSnapshot {
} }
impl 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 { pub fn new(session_id: String, mode: String, model: String) -> Self {
StateSnapshot { StateSnapshot {
timestamp: chrono::Utc::now().timestamp_millis(), 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<Vec<u8>> { pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
let data = serde_json::to_vec(snapshot)?; let data = serde_json::to_vec(snapshot)?;
Ok(data) Ok(data)
} }
/// Deserialize JSON bytes back into a `StateSnapshot`.
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> { pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
let snapshot: StateSnapshot = serde_json::from_slice(data)?; let snapshot: StateSnapshot = serde_json::from_slice(data)?;
Ok(snapshot) Ok(snapshot)
+102
View File
@@ -1,3 +1,10 @@
//! Zesdex binary entry point.
//!
//! Parses `--daemon` / `--attach <id>` 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;
use std::io::Write; use std::io::Write;
use std::sync::Mutex; use std::sync::Mutex;
@@ -17,6 +24,16 @@ mod tool;
mod resources; mod resources;
mod view; mod view;
/// Process entry point: parse CLI flags, initialize logging, then dispatch
/// to single-process, daemon, or attach mode.
///
/// Flow: parse `--daemon`/`--attach <id>` 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<()> { fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let is_daemon = args.iter().any(|a| a == "--daemon"); let is_daemon = args.iter().any(|a| a == "--daemon");
@@ -61,6 +78,17 @@ fn main() -> Result<()> {
run_single_process() 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<()> { fn run_single_process() -> Result<()> {
let store = model::store::Store::new(); let store = model::store::Store::new();
store.ensure_dirs()?; store.ensure_dirs()?;
@@ -110,6 +138,11 @@ fn run_single_process() -> Result<()> {
Ok(()) 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<ipc::protocol::KeyAction> { fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::KeyAction> {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
match code { match code {
@@ -132,6 +165,9 @@ fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::
} }
} }
/// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode`
/// from a `KeyAction` received over IPC, for replaying it into the
/// daemon's normal key-handling path.
fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode { fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
match action { 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<()> { fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload}; 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) 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( fn apply_client_update(
state: &mut app::state::rest::AppStateRest, state: &mut app::state::rest::AppStateRest,
payload: ipc::protocol::StatePayload, payload: ipc::protocol::StatePayload,
@@ -260,6 +316,20 @@ fn apply_client_update(
state.input.cursor = payload.input_cursor; 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
/// `<store>/run/<session_id>.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<()> { fn run_daemon() -> Result<()> {
use app::runtime::actions::{Action, apply_action}; use app::runtime::actions::{Action, apply_action};
use ipc::protocol::ClientRequest; use ipc::protocol::ClientRequest;
@@ -362,6 +432,19 @@ fn run_daemon() -> Result<()> {
Ok(()) 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<()> { fn run_attach(session_id: &str) -> Result<()> {
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use ipc::protocol::ClientRequest; use ipc::protocol::ClientRequest;
@@ -467,6 +550,14 @@ fn run_attach(session_id: &str) -> Result<()> {
Ok(()) 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( fn run_loop(
state: &mut app::state::rest::AppStateRest, state: &mut app::state::rest::AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
@@ -481,6 +572,17 @@ fn run_loop(
result 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( fn run_loop_inner(
state: &mut app::state::rest::AppStateRest, state: &mut app::state::rest::AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
+12
View File
@@ -1,5 +1,17 @@
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
use crate::app::subagent::spawn::AgentDefinition; 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<AgentDefinition>` (coder, reviewer,
/// researcher, planner).
pub fn builtin_agents() -> Vec<AgentDefinition> { pub fn builtin_agents() -> Vec<AgentDefinition> {
vec![ vec![
AgentDefinition::new( AgentDefinition::new(
+32
View File
@@ -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; use crate::app::subagent::spawn::AgentDefinition;
/// Load all globally-registered agent definitions from disk.
///
/// Flow: resolve `<store>/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<AgentDefinition>`, empty if the directory doesn't exist
/// or contains no valid definitions.
pub fn load_global_agents() -> Vec<AgentDefinition> { pub fn load_global_agents() -> Vec<AgentDefinition> {
let store = crate::model::store::Store::new(); let store = crate::model::store::Store::new();
let agents_dir = store.base_dir.join("agents"); let agents_dir = store.base_dir.join("agents");
@@ -22,6 +36,16 @@ pub fn load_global_agents() -> Vec<AgentDefinition> {
agents agents
} }
/// Persist a global agent definition as `<store>/agents/<name>.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<()> { pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
let store = crate::model::store::Store::new(); let store = crate::model::store::Store::new();
let agents_dir = store.base_dir.join("agents"); let agents_dir = store.base_dir.join("agents");
@@ -32,6 +56,14 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Delete a global agent definition by name, if it exists.
///
/// Flow: resolve `<store>/agents/<name>.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<()> { pub fn remove_global_agent(name: &str) -> anyhow::Result<()> {
let store = crate::model::store::Store::new(); let store = crate::model::store::Store::new();
let path = store.base_dir.join("agents").join(format!("{}.json", name)); let path = store.base_dir.join("agents").join(format!("{}.json", name));
+3
View File
@@ -1,3 +1,6 @@
//! Agent definition sources: built-in defaults, global (user-wide), and
//! per-session overrides.
pub mod builtin; pub mod builtin;
pub mod global; pub mod global;
pub mod session; pub mod session;
+35
View File
@@ -1,6 +1,20 @@
//! Load, save, add, and remove agent definitions scoped to a single
//! session (`<session_dir>/agents.json`).
use std::path::Path; use std::path::Path;
use crate::app::subagent::spawn::AgentDefinition; use crate::app::subagent::spawn::AgentDefinition;
/// Load agent definitions saved for a specific session.
///
/// Flow: check `<session_dir>/agents.json` exists → read → JSON-decode
/// into `Vec<AgentDefinition>`.
///
/// 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<AgentDefinition> { pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
let agents_file = session_dir.join("agents.json"); let agents_file = session_dir.join("agents.json");
if !agents_file.exists() { if !agents_file.exists() {
@@ -17,6 +31,13 @@ pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
} }
} }
/// Overwrite `<session_dir>/agents.json` with the given agent list.
///
/// Flow: serialize `agents` to pretty JSON → write to
/// `<session_dir>/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<()> { pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
let agents_file = session_dir.join("agents.json"); let agents_file = session_dir.join("agents.json");
let content = serde_json::to_string_pretty(agents)?; let content = serde_json::to_string_pretty(agents)?;
@@ -24,6 +45,14 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an
Ok(()) 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<()> { pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> {
let mut agents = load_session_agents(session_dir); let mut agents = load_session_agents(session_dir);
agents.retain(|a| a.name != def.name); 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) 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<()> { pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> {
let mut agents = load_session_agents(session_dir); let mut agents = load_session_agents(session_dir);
agents.retain(|a| a.name != name); agents.retain(|a| a.name != name);
+19
View File
@@ -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 serde::{Deserialize, Serialize};
use std::collections::HashMap; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub providers: HashMap<String, ProviderConfig>, pub providers: HashMap<String, ProviderConfig>,
@@ -9,6 +14,7 @@ pub struct AppConfig {
pub default_model: String, pub default_model: String,
} }
/// Connection details for a single LLM provider (base URL, API key source).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig { pub struct ProviderConfig {
pub api_base: String, pub api_base: String,
@@ -17,6 +23,8 @@ pub struct ProviderConfig {
pub default_api_key: Option<String>, pub default_api_key: Option<String>,
} }
/// A named role (e.g. "default") mapping to a specific provider/model and
/// its generation parameters.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRole { pub struct ModelRole {
pub provider: String, pub provider: String,
@@ -57,6 +65,17 @@ impl Default for AppConfig {
} }
impl AppConfig { impl AppConfig {
/// Load app config from disk, falling back to defaults on any failure.
///
/// Flow: read `<store>/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 { pub fn load() -> Self {
let store = super::store::Store::new(); let store = super::store::Store::new();
let path = store.base_dir.join("app_config.json"); let path = store.base_dir.join("app_config.json");
+20
View File
@@ -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}; use serde::{Deserialize, Serialize};
/// A single conversation's message history and generation settings.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation { pub struct Conversation {
pub messages: Vec<crate::dto::chat::message::ChatMessage>, pub messages: Vec<crate::dto::chat::message::ChatMessage>,
@@ -11,6 +15,8 @@ pub struct Conversation {
} }
impl 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 { pub fn new(system_prompt: String, session_id: String) -> Self {
Conversation { Conversation {
messages: Vec::new(), 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) { pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) {
self.messages.push(msg); 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) { pub fn rebuild_system(&mut self, new_prompt: String) {
self.system_prompt = new_prompt; self.system_prompt = new_prompt;
self.messages.retain(|m| { 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<crate::dto::chat::message::ChatMessage> { pub fn to_api_messages(&self) -> Vec<crate::dto::chat::message::ChatMessage> {
let mut msgs = Vec::with_capacity(self.messages.len() + 1); let mut msgs = Vec::with_capacity(self.messages.len() + 1);
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt)); msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
@@ -40,6 +58,8 @@ impl Conversation {
msgs msgs
} }
/// Number of messages in the conversation history (excluding the
/// synthesized system message).
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.messages.len() self.messages.len()
} }
+20
View File
@@ -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}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditLogEntry { pub struct EditLogEntry {
pub ts: i64, pub ts: i64,
@@ -12,6 +17,7 @@ pub struct EditLogEntry {
pub session_id: String, pub session_id: String,
} }
/// In-memory view of a session's edit log, backed by `edits.jsonl` on disk.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EditLog { pub struct EditLog {
pub entries: Vec<EditLogEntry>, pub entries: Vec<EditLogEntry>,
@@ -19,6 +25,8 @@ pub struct EditLog {
} }
impl 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 { pub fn new(session_dir: &std::path::Path) -> Self {
let path = session_dir.join("edits.jsonl"); let path = session_dir.join("edits.jsonl");
let entries = Self::load_from_disk(&path); let entries = Self::load_from_disk(&path);
@@ -40,6 +48,17 @@ impl EditLog {
.collect() .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<()> { pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
let line = serde_json::to_string(&entry)? + "\n"; let line = serde_json::to_string(&entry)? + "\n";
let parent = self.path.parent().unwrap(); let parent = self.path.parent().unwrap();
@@ -54,6 +73,7 @@ impl EditLog {
Ok(()) Ok(())
} }
/// Number of edit entries recorded so far in this log.
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.entries.len() self.entries.len()
} }
+86
View File
@@ -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 std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A single memory entry (lesson, reference, etc.) with frontmatter
/// metadata and free-form markdown content.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory { pub struct Memory {
pub name: String, pub name: String,
@@ -18,6 +23,17 @@ pub struct Memory {
} }
impl 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<String> { pub fn slugify(s: &str) -> Option<String> {
let slug: String = s let slug: String = s
.to_lowercase() .to_lowercase()
@@ -35,11 +51,27 @@ impl Memory {
Some(slug) 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 { pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
slug_path(memory_dir, &format!("{}.md", slug)) 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<()> { pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
let path = Self::path(memory_dir, &self.name); let path = Self::path(memory_dir, &self.name);
let parent = path.parent().unwrap(); let parent = path.parent().unwrap();
@@ -65,12 +97,29 @@ impl Memory {
Ok(()) 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<Self> { pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
let path = Self::path(memory_dir, name); let path = Self::path(memory_dir, name);
let content = std::fs::read_to_string(&path)?; let content = std::fs::read_to_string(&path)?;
Self::parse(&content) 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<Self> { pub fn parse(content: &str) -> std::io::Result<Self> {
let content = content.strip_prefix("---\n").unwrap_or(content); let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); 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<()> { pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
let path = Self::path(memory_dir, name); let path = Self::path(memory_dir, name);
if path.exists() { if path.exists() {
@@ -111,6 +163,13 @@ impl Memory {
Ok(()) 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<String> { pub fn list(memory_dir: &Path) -> Vec<String> {
let entries = match std::fs::read_dir(memory_dir) { let entries = match std::fs::read_dir(memory_dir) {
Ok(e) => e, 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 { pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
let clean: String = raw.chars() let clean: String = raw.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' }) .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 }) 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<Memory>` 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<()> { pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
let names = Memory::list(memory_dir); let names = Memory::list(memory_dir);
let lessons: Vec<Memory> = names.iter() let lessons: Vec<Memory> = names.iter()
@@ -147,6 +222,17 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
std::fs::write(output, data)?; std::fs::write(output, data)?;
Ok(()) Ok(())
} }
/// Import memories from a JSON export file into `memory_dir`, skipping
/// duplicates.
///
/// Flow: read+JSON-decode `input` into `Vec<Memory>` → 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<usize> { pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
let data = std::fs::read_to_string(input)?; let data = std::fs::read_to_string(input)?;
let lessons: Vec<Memory> = serde_json::from_str(&data) let lessons: Vec<Memory> = serde_json::from_str(&data)
+3
View File
@@ -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 app_config;
pub mod editlog; pub mod editlog;
pub mod memory; pub mod memory;
+21
View File
@@ -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 rusqlite::{Connection, params};
use anyhow::Result; 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<()> { 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(); let created_at = chrono::Utc::now().timestamp_millis();
conn.execute( conn.execute(
@@ -10,6 +19,10 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
Ok(()) 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<Option<Vec<u8>>> { pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
let result = conn.query_row( let result = conn.query_row(
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", "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)] #[allow(dead_code)]
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> { pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
let rows = conn.execute( let rows = conn.execute(
@@ -32,6 +49,10 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul
Ok(rows > 0) Ok(rows > 0)
} }
/// List all blob keys stored for a session, oldest first.
///
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
/// underlying SQLite error.
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> { pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC" "SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
+11
View File
@@ -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 blobs;
pub mod query; pub mod query;
pub mod schema; pub mod schema;
@@ -5,6 +8,14 @@ pub mod schema;
pub use blobs::store_blob; pub use blobs::store_blob;
pub use query::insert_message; pub use query::insert_message;
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
/// schema is initialized.
///
/// Flow: resolve `<session_dir>/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<rusqlite::Connection> { pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite"); let path = session_dir.join("messages.sqlite");
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
+9
View File
@@ -1,7 +1,16 @@
//! Insert queries against the message log's `messages` table.
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use anyhow::Result; use anyhow::Result;
use crate::dto::chat::message::{ChatMessage, Role}; 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<i64> { pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
let content = msg.content.as_deref(); let content = msg.content.as_deref();
let tool_call_id = msg.tool_call_id.as_deref(); let tool_call_id = msg.tool_call_id.as_deref();
+9
View File
@@ -1,6 +1,15 @@
//! SQLite schema definition for the message log database.
use rusqlite::Connection; use rusqlite::Connection;
use anyhow::Result; 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<()> { pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
" "
+8
View File
@@ -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}; use serde::{Deserialize, Serialize};
/// Summary metadata for one archived/summarized session.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryRecord { pub struct SummaryRecord {
pub session_id: String, pub session_id: String,
@@ -13,6 +17,8 @@ pub struct SummaryRecord {
} }
impl 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 { pub fn new(session_id: String, title: String, model: String) -> Self {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
SummaryRecord { SummaryRecord {
@@ -27,11 +33,13 @@ impl SummaryRecord {
} }
} }
/// Replace the summary text and bump `updated_at`.
pub fn update_summary(&mut self, summary: String) { pub fn update_summary(&mut self, summary: String) {
self.summary = summary; self.summary = summary;
self.updated_at = chrono::Utc::now().timestamp_millis(); 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) { pub fn increment_counts(&mut self, messages: usize, tokens: usize) {
self.message_count += messages; self.message_count += messages;
self.token_count += tokens; self.token_count += tokens;
+30
View File
@@ -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 std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::Utc; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub id: String, pub id: String,
@@ -17,6 +22,8 @@ pub struct Session {
} }
impl 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 { pub fn new(id: String, title: String) -> Self {
let now = Utc::now().timestamp_millis(); let now = Utc::now().timestamp_millis();
Session { Session {
@@ -33,14 +40,25 @@ impl Session {
} }
} }
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
pub fn session_dir(&self, base_dir: &Path) -> PathBuf { pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
base_dir.join("sessions").join(&self.id) base_dir.join("sessions").join(&self.id)
} }
/// Compute this session's `conversation.json` path.
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf { pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("conversation.json") 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<()> { pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
let dir = self.session_dir(base_dir); let dir = self.session_dir(base_dir);
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
@@ -52,6 +70,10 @@ impl Session {
Ok(()) Ok(())
} }
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/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<Self> { pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
let path = base_dir.join("sessions").join(id).join("session.json"); let path = base_dir.join("sessions").join(id).join("session.json");
let data = std::fs::read_to_string(path)?; let data = std::fs::read_to_string(path)?;
@@ -59,6 +81,14 @@ impl Session {
Ok(session) Ok(session)
} }
/// List all loadable sessions under `<base_dir>/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<Session>`, empty if the directory can't be read or
/// contains no valid sessions.
pub fn list(base_dir: &Path) -> Vec<Self> { pub fn list(base_dir: &Path) -> Vec<Self> {
let sessions_dir = base_dir.join("sessions"); let sessions_dir = base_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) { let entries = match std::fs::read_dir(&sessions_dir) {
+24
View File
@@ -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::path::{Path, PathBuf};
use std::fs; use std::fs;
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
/// auto-removed on drop.
pub struct SessionLock { pub struct SessionLock {
path: PathBuf, path: PathBuf,
pid: u32, pid: u32,
} }
impl SessionLock { 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 { pub fn new(session_dir: &Path) -> Self {
SessionLock { SessionLock {
path: session_dir.join(".lock"), 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<bool> { pub fn try_lock(&self) -> std::io::Result<bool> {
if self.path.exists() { if self.path.exists() {
let content = fs::read_to_string(&self.path).unwrap_or_default(); let content = fs::read_to_string(&self.path).unwrap_or_default();
@@ -27,10 +47,12 @@ impl SessionLock {
Ok(true) Ok(true)
} }
/// Explicitly release the lock by removing the lock file.
pub fn unlock(&self) { pub fn unlock(&self) {
let _ = fs::remove_file(&self.path); let _ = fs::remove_file(&self.path);
} }
/// Check whether a process with the given PID is currently alive.
fn is_alive(&self, pid: u32) -> bool { fn is_alive(&self, pid: u32) -> bool {
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal // whether the process exists and the caller has permission to signal
@@ -40,6 +62,8 @@ impl SessionLock {
} }
impl Drop for 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) { fn drop(&mut self) {
let _ = fs::remove_file(&self.path); let _ = fs::remove_file(&self.path);
} }
+25
View File
@@ -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}; 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(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Default)] #[derive(Default)]
pub enum InternetMode { 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
pub internet_mode: InternetMode, pub internet_mode: InternetMode,
@@ -49,6 +63,12 @@ impl Default for Settings {
} }
impl Settings { impl Settings {
/// Load settings from `<store_base_dir>/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 { pub fn load() -> Self {
let store = super::store::Store::new(); let store = super::store::Store::new();
let path = store.base_dir.join("settings.json"); let path = store.base_dir.join("settings.json");
@@ -58,6 +78,11 @@ impl Settings {
.unwrap_or_default() .unwrap_or_default()
} }
/// Serialize and write settings to `<store_base_dir>/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<()> { pub fn save(&self) -> std::io::Result<()> {
let store = super::store::Store::new(); let store = super::store::Store::new();
std::fs::create_dir_all(&store.base_dir)?; std::fs::create_dir_all(&store.base_dir)?;
+15
View File
@@ -1,6 +1,12 @@
//! Filesystem layout for zesdex's persistent and scratch data directories.
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Store { pub struct Store {
pub base_dir: PathBuf, pub base_dir: PathBuf,
@@ -11,6 +17,12 @@ pub struct Store {
} }
impl 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 { pub fn new() -> Self {
let base = dirs::data_dir() let base = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from(".local/share")) .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<()> { pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.base_dir)?; std::fs::create_dir_all(&self.base_dir)?;
std::fs::create_dir_all(&self.memory_dir)?; std::fs::create_dir_all(&self.memory_dir)?;
+3
View File
@@ -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_PROMPT: &str = include_str!("../src-misc/system-prompt.txt");
pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt"); pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt");
+2
View File
@@ -1,2 +1,4 @@
//! External service integrations: the LLM provider HTTP client and OAuth flows.
pub mod provider; pub mod provider;
pub mod oauth; pub mod oauth;
+26
View File
@@ -1,28 +1,46 @@
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; 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 { pub struct LoopbackServer {
listener: TcpListener, listener: TcpListener,
port: u16, port: u16,
} }
impl LoopbackServer { 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<Self> { pub fn bind() -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?; let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
Ok(LoopbackServer { listener, port }) Ok(LoopbackServer { listener, port })
} }
/// The redirect URI to hand to the OAuth authorization endpoint.
pub fn redirect_uri(&self) -> String { pub fn redirect_uri(&self) -> String {
format!("http://127.0.0.1:{}/callback", self.port) 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<String> { pub fn wait_for_code(&self, timeout_ms: u64) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?; let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?; stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream) 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<String> { fn read_callback(stream: &mut TcpStream) -> std::io::Result<String> {
let mut buf = [0u8; 4096]; let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?; 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")) 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<String> { fn extract_code(request: &str) -> Option<String> {
let line = request.lines().next()?; let line = request.lines().next()?;
let path = line.split(' ').nth(1)?; 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 { fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len()); let mut result = String::with_capacity(s.len());
let mut chars = s.chars(); let mut chars = s.chars();
+22 -6
View File
@@ -1,6 +1,9 @@
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken { pub struct OAuthToken {
pub access_token: String, pub access_token: String,
@@ -12,6 +15,7 @@ pub struct OAuthToken {
impl OAuthToken { impl OAuthToken {
} }
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig { pub struct OAuthConfig {
pub auth_url: String, 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 struct OAuthManager {
pub config: OAuthConfig, pub config: OAuthConfig,
pub token: Option<OAuthToken>, pub token: Option<OAuthToken>,
@@ -40,6 +45,7 @@ pub struct OAuthManager {
} }
impl OAuthManager { impl OAuthManager {
/// Create a manager for the given provider config with no token yet acquired.
pub fn new(config: OAuthConfig) -> Self { pub fn new(config: OAuthConfig) -> Self {
OAuthManager { OAuthManager {
config, 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> { pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> {
let mut params = std::collections::HashMap::new(); let mut params = std::collections::HashMap::new();
params.insert("grant_type", "authorization_code"); params.insert("grant_type", "authorization_code");
@@ -83,13 +95,17 @@ impl OAuthManager {
Ok(()) 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 { 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) { let mut url = match url::Url::parse(&self.config.auth_url) {
Ok(u) if !self.config.auth_url.is_empty() => u, Ok(u) if !self.config.auth_url.is_empty() => u,
_ => { _ => {
+3
View File
@@ -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 pkce;
pub mod loopback; pub mod loopback;
pub mod manager; pub mod manager;
+14
View File
@@ -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 base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Sha256, Digest}; use sha2::{Sha256, Digest};
const VERIFIER_LENGTH: usize = 64; const VERIFIER_LENGTH: usize = 64;
/// A randomly generated, base64url-encoded PKCE code verifier.
pub struct CodeVerifier(String); pub struct CodeVerifier(String);
impl CodeVerifier { impl CodeVerifier {
/// Generate a fresh random code verifier.
pub fn new() -> Self { pub fn new() -> Self {
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect(); let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes)) 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 { pub fn as_str(&self) -> &str {
&self.0 &self.0
} }
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
/// in the authorization request.
pub fn challenge(&self) -> CodeChallenge { pub fn challenge(&self) -> CodeChallenge {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(self.0.as_bytes()); 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 { fn rand_byte() -> u8 {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now() let nanos = SystemTime::now()
@@ -35,9 +47,11 @@ fn rand_byte() -> u8 {
(nanos & 0xFF) as u8 (nanos & 0xFF) as u8
} }
/// The S256-derived code challenge sent in the authorization request URL.
pub struct CodeChallenge(String); pub struct CodeChallenge(String);
impl CodeChallenge { impl CodeChallenge {
/// Borrow the challenge as a string.
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
&self.0 &self.0
} }
+36
View File
@@ -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 std::time::Duration;
use anyhow::Result; 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 CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); 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 struct LlmClient {
pub client: reqwest::blocking::Client, pub client: reqwest::blocking::Client,
pub api_key: String, pub api_key: String,
@@ -20,6 +27,14 @@ pub struct LlmClient {
} }
impl 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<String>) -> Self { pub fn new(mut api_key: String, model: String, base_url: Option<String>) -> Self {
if api_key.is_empty() { if api_key.is_empty() {
api_key = DEFAULT_API_KEY.to_string(); 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( pub fn chat_with_tools_non_streaming(
&self, &self,
messages: &[ChatMessage], 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( fn try_stream_once(
&self, &self,
req: &ChatRequest, req: &ChatRequest,
+5
View File
@@ -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 serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::Tool; use super::Tool;
use super::ToolCtx; use super::ToolCtx;
/// Tool: fetch buffered output from a background bash job by `job_id`.
pub struct BashOutput; pub struct BashOutput;
impl Tool for 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; pub struct BashKill;
impl Tool for BashKill { impl Tool for BashKill {
+7
View File
@@ -1,3 +1,5 @@
//! Tool: `delete` — remove a file or empty directory relative to a workspace root.
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -7,6 +9,7 @@ use super::super::ToolCtx;
use super::super::resolve_path; use super::super::resolve_path;
use super::helpers::arg_str; use super::helpers::arg_str;
/// Tool: delete a file or empty directory. Refuses non-empty directories.
pub struct Delete; pub struct Delete;
impl Tool for 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<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?; let rel = arg_str(args, "path")?;
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
+10
View File
@@ -1,3 +1,5 @@
//! Tool: `edit` — replace a substring in a file with a new string.
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -8,6 +10,7 @@ use super::super::resolve_path;
use super::super::check_graduated_checks; use super::super::check_graduated_checks;
use super::helpers::arg_str; 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; pub struct Edit;
impl Tool for 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<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?; let rel = arg_str(args, "path")?;
let old = arg_str(args, "old")?; let old = arg_str(args, "old")?;
+13
View File
@@ -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 std::path::Path;
use serde_json::Value; use serde_json::Value;
use anyhow::{Result, anyhow}; 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<String> { pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name) args.get(name)
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -9,6 +16,12 @@ pub fn arg_str(args: &Value, name: &str) -> Result<String> {
.ok_or_else(|| anyhow!("missing required argument: {}", name)) .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 { 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 canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let in_ws = ctx.workspaces.iter().any(|w| { let in_ws = ctx.workspaces.iter().any(|w| {
+3
View File
@@ -1,3 +1,6 @@
//! Filesystem tool implementations: read, write, edit, and delete operations
//! on workspace-rooted paths.
pub mod delete; pub mod delete;
pub mod edit; pub mod edit;
pub mod helpers; pub mod helpers;
+10
View File
@@ -1,3 +1,5 @@
//! Tool: `read` — display file contents with line numbers.
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -7,6 +9,7 @@ use super::super::ToolCtx;
use super::super::resolve_path; use super::super::resolve_path;
use super::helpers::{arg_str, not_found_help}; 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; pub struct Read;
impl Tool for 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<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?; let rel = arg_str(args, "path")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize); let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
+10
View File
@@ -1,3 +1,5 @@
//! Tool: `write` — write content to a file, creating parent directories on demand.
use std::fs; use std::fs;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
@@ -7,6 +9,7 @@ use super::super::resolve_path;
use super::super::check_graduated_checks; use super::super::check_graduated_checks;
use super::helpers::arg_str; use super::helpers::arg_str;
/// Tool: write content to a file, auto-creating parent directories as needed.
pub struct Write; pub struct Write;
impl Tool for 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<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?; let rel = arg_str(args, "path")?;
let content = arg_str(args, "content")?; let content = arg_str(args, "content")?;
+12
View File
@@ -1,9 +1,12 @@
//! Tool wrapper around `git credential` for store/get/erase operations.
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use std::process::Command; use std::process::Command;
use super::Tool; use super::Tool;
use super::ToolCtx; use super::ToolCtx;
/// Tool that shells out to `git credential <op>` to store, retrieve, or erase credentials.
pub struct GitCred; pub struct GitCred;
impl Tool for GitCred { impl Tool for GitCred {
@@ -29,6 +32,15 @@ impl Tool for GitCred {
}) })
} }
/// Run `git credential <operation>`, forwarding stdin-less invocation to the git binary.
///
/// Flow: extract `operation` arg → spawn `git credential <operation>` → 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<String> { fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = args.get("operation") let operation = args.get("operation")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+13
View File
@@ -1,9 +1,12 @@
//! Generic tool for running arbitrary git subcommands.
use std::process::Command; use std::process::Command;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::Tool; use super::Tool;
use super::ToolCtx; use super::ToolCtx;
/// Tool that runs `git <operation> [args...]` and returns combined stdout/stderr.
pub struct GitOperator; pub struct GitOperator;
impl Tool for GitOperator { impl Tool for GitOperator {
@@ -33,6 +36,16 @@ impl Tool for GitOperator {
}) })
} }
/// Run `git <operation> [args...]` and return its combined output.
///
/// Flow: extract `operation` + `args` → spawn `git <operation> <args>` → 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<String> { fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = args.get("operation") let operation = args.get("operation")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+10
View File
@@ -1,9 +1,12 @@
//! Tool for creating git worktrees under the session's worktrees directory.
use std::process::Command; use std::process::Command;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::Tool; use super::Tool;
use super::ToolCtx; use super::ToolCtx;
/// Tool that creates a new git worktree (`git worktree add`) from a given base ref.
pub struct GitWorktree; pub struct GitWorktree;
impl Tool for GitWorktree { impl Tool for GitWorktree {
@@ -32,6 +35,13 @@ impl Tool for GitWorktree {
}) })
} }
/// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`.
///
/// 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<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args.get("name") let name = args.get("name")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+9
View File
@@ -1,9 +1,12 @@
//! Tool for deleting a persisted memory entry by name.
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::super::Tool; use super::super::Tool;
use super::super::ToolCtx; use super::super::ToolCtx;
use crate::model::memory::Memory; use crate::model::memory::Memory;
/// Tool that removes a single memory entry from `ctx.memory_dir` by exact name.
pub struct Forget; pub struct Forget;
impl Tool for 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<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args.get("name") let name = args.get("name")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())

Some files were not shown because too many files have changed in this diff Show More