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