docs: tambah doc comment, logging, dan inline comments di semua 255 file

Meliputi:
- File-level //! doc comment: tujuan file, alur kerja, komponen utama
- Function-level /// doc comment: apa, parameter, return, flow, edge cases
- Struct/enum/trait /// doc comment: peran, field docs
- Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi
- Inline comments untuk variable dan branching logic penting
- Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities,
  zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils
- Build: 0 errors, 242/242 tests passed
This commit is contained in:
asepharyana
2026-07-19 17:05:47 +07:00
parent 6680795ce7
commit 5aaedbf787
255 changed files with 5666 additions and 743 deletions
@@ -14,15 +14,22 @@ use std::sync::Mutex;
use std::sync::OnceLock;
use super::job::BashJob;
use tracing::debug;
/// Lazily-initialised, process-wide registry of background bash jobs keyed
/// by job id.
///
/// Flow: first call creates the `Mutex<HashMap>` inside a `OnceLock`;
/// subsequent calls return the same static reference.
///
/// 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()))
JOBS.get_or_init(|| {
debug!("bash_jobs_map initialised");
Mutex::new(HashMap::new())
})
}
/// Drain any newly available output lines from a background bash job.
@@ -43,8 +50,10 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
lines.push(line);
}
if lines.is_empty() {
debug!(%id, "bash_output: no new lines");
None
} else {
debug!(%id, count = lines.len(), "bash_output: new lines drained");
Some(lines)
}
}
@@ -60,6 +69,8 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
/// 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<()> {
debug!(%id, "bash_kill called");
let mut map = bash_jobs_map()
.lock()
.map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
@@ -69,9 +80,12 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> {
// Actually terminate the child process via its PID
if job.child_pid > 0 {
#[cfg(unix)]
// SAFETY: job.child_pid is the real PID of the spawned child;
// SIGTERM is safe and the process may already be dead.
unsafe {
libc::kill(job.child_pid as i32, libc::SIGTERM);
}
debug!(%id, pid = job.child_pid, "bash_kill: SIGTERM sent");
}
Ok(())
}
+12 -3
View File
@@ -13,6 +13,7 @@ use std::io::BufRead;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use tracing::{debug, warn};
/// Maximum number of output lines buffered in memory per background job.
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
@@ -26,9 +27,15 @@ const MAX_OUTPUT_LINES: usize = 10_000;
/// synchronously, so the TUI can poll for new lines without blocking.
/// The bounded channel prevents OOM from fast producers (e.g. `yes`).
pub struct BashJob {
/// Unique identifier for this job (UUID v4).
pub id: String,
/// OS process ID of the spawned child, used by `bash_kill` to send SIGTERM.
pub child_pid: u32,
/// Receiving end of the bounded channel carrying stdout/stderr lines
/// and `__exit:<code>` sentinels from the background thread.
pub output_rx: mpsc::Receiver<String>,
/// Exit code captured from the `__exit:` sentinel, or `None` if the job
/// is still running or hasn't been polled past its exit sentinel yet.
pub exit_code: Option<i32>,
}
@@ -71,7 +78,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
})
.is_err()
{
tracing::warn!(
warn!(
"[bgbash:{}] failed to spawn named thread, using unnamed fallback",
id_for_log
);
@@ -130,7 +137,7 @@ fn spawn_bash_thread_body(
let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
if stderr_tx.try_send(format!("[stderr] {line}")).is_err() {
tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
debug!("[bgbash] stderr buffer full, discarding remaining stderr");
break;
}
}
@@ -142,7 +149,7 @@ fn spawn_bash_thread_body(
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if output_tx.try_send(line).is_err() {
tracing::debug!(
debug!(
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
id_for_log,
MAX_OUTPUT_LINES,
@@ -171,11 +178,13 @@ impl BashJob {
Ok(line) => {
if line.starts_with("__exit:") {
self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok());
debug!(%self.id, exit_code = ?self.exit_code, "try_read_line: job exited");
None
} else {
Some(line)
}
}
// Channel empty or disconnected — no new output yet.
Err(_) => None,
}
}
@@ -1,4 +1,9 @@
//! Background bash: run shell commands off the main thread, poll their
//! output non-blockingly, and terminate them on demand.
//!
//! Flow: [`job`] defines the `BgJob` struct (a spawned child process with a
//! ticker for incremental output). [`control`] provides the UI-facing actions
//! (start, cancel, follow, etc.) that operate on the shared job registry at
//! `state.bg_bash`.
pub mod control;
pub mod job;
+21 -1
View File
@@ -6,6 +6,7 @@
pub mod patterns;
use patterns::*;
use tracing::debug;
/// Outcome of gating a tool call: whether it's allowed to run.
#[derive(Debug, Clone, PartialEq)]
@@ -33,39 +34,47 @@ impl Guard {
) -> Verdict {
let is_risky = crate::tool::tool_is_risky(tool_name);
let is_mcp = tool_name.starts_with("mcp__");
debug!(tool_name, is_risky, is_mcp, "gating tool call");
// Universal checks applied to EVERY tool.
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
debug!(tool_name, "blocked by path-traversal check");
return v;
}
if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) {
debug!(tool_name, "blocked by output-path check");
return v;
}
// Non-risky, non-MCP tools pass after universal checks.
if !is_risky && !is_mcp {
debug!(tool_name, "non-risky non-MCP tool allowed after universal checks");
return Verdict::Allow;
}
// File-mutating tools: require a meaningful reason.
if matches!(tool_name, "write" | "edit" | "delete") {
if let Err(msg) = Self::validate_reason(tool_name, args) {
debug!(tool_name, "blocked by reason validation");
return Verdict::Block(msg);
}
}
// write / edit content scanning for stub/denial/assumption patterns.
if let Some(v) = Self::check_content_safety(tool_name, args) {
debug!(tool_name, "blocked by content-safety check");
return v;
}
// Bash-specific destructive / exfiltration checks.
if let Some(v) = Self::check_bash_safety(args) {
debug!(tool_name, "blocked by bash-safety check");
return v;
}
// git_operator: require a non-trivial reason.
if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) {
debug!(tool_name, "blocked by git_operator reason check");
if args.get("reason").and_then(|v| v.as_str()).is_some() {
return Verdict::Block(format!(
"git_operator requires a non-trivial 'reason' \
@@ -81,12 +90,14 @@ impl Guard {
if is_mcp {
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
if reason.trim().len() < MIN_REASON_LEN {
debug!(tool_name, "blocked by MCP reason length");
return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a non-trivial 'reason' \
(>= {MIN_REASON_LEN} chars) explaining why it is needed"
));
}
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
debug!(tool_name, "blocked by missing MCP reason");
return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a 'reason' argument \
explaining the operation"
@@ -94,6 +105,7 @@ impl Guard {
}
}
debug!(tool_name, "tool call allowed");
Verdict::Allow
}
@@ -374,9 +386,17 @@ impl Default for Guard {
#[cfg(test)]
mod tests {
//! Unit tests for the Guard gating system: verdict parsing, tool
//! classification, path-traversal detection, content-safety patterns,
//! and reason validation.
use super::*;
use serde_json::json;
/// Parse a verdict from either a JSON object `{"verdict": "allow|block",
/// "reason": "..."}` or a text line `Verdict: Allow|Block <reason>`.
///
/// Flow: try JSON parse first → fall back to text line parsing → fall
/// back to keyword heuristics.
fn parse_verdict(text: &str) -> Option<Verdict> {
let trimmed = text.trim();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
@@ -7,20 +7,24 @@
/// Stub / placeholder / denial / assumption patterns that should never reach
/// a file in real code. Detected in write/edit content and bash heredocs.
pub const STUB_PATTERNS: &[&str] = &[
// Rust macro stubs
"todo!()",
"todo!(",
"unimplemented!()",
"unimplemented!(",
"todo_macro",
// Review markers left by the AI
"FIXME",
"fixme:",
"XXX:",
// Explicit placeholder tokens
"PLACEHOLDER",
"REPLACE_ME",
"stub_value",
"stub_function",
"fake_response",
"fake_data",
// Admission that work was deferred
"not implemented",
"not yet implemented",
"to be implemented",
@@ -30,36 +34,43 @@ pub const STUB_PATTERNS: &[&str] = &[
/// Language patterns indicating the AI is denying responsibility or
/// punting the work ("I'll skip this", "for now just", etc).
pub const DENIAL_PATTERNS: &[&str] = &[
// Explicit skip/punt
"// skip",
"// skipping",
"// skipping for now",
"// for now just",
"// punt",
"// punted",
// Hack / workaround framing
"// hack:",
"// hacky",
"// hack workaround",
"// workaround:",
"// cba",
// Deferral language
"// later",
"// do later",
"// ignore for now",
"// disable",
"// disabled",
"// bypass",
// Temporary / quick-fix framing (likely will never be revisited)
"// quick fix",
"// temp fix",
"// temporary fix",
"// temp:",
"// temporary:",
// No-op placeholder
"// noop",
];
/// Assumption-language patterns: words/phrases that indicate the code is
/// reasoning based on guesswork rather than data.
pub const ASSUMPTION_PATTERNS: &[&str] = &[
// Assertions without evidence
"// assume",
"// assuming",
// Speculative qualification
"// probably",
"// maybe",
"// might",
@@ -75,14 +86,18 @@ pub const ASSUMPTION_PATTERNS: &[&str] = &[
/// Network-exfiltration and credential-disclosure patterns for bash.
pub const EXFIL_PATTERNS: &[&str] = &[
// Network data-transfer tools
"curl ",
"wget ",
// Reverse shells / netcat
"nc -e ",
"ncat ",
"/dev/tcp/",
// Obfuscated payloads
"base64 -d |",
"base64 --decode |",
"openssl s_client",
// SSH and file-transfer exfiltration
"ssh -R ",
"scp /",
"rsync /",
@@ -90,17 +105,22 @@ pub const EXFIL_PATTERNS: &[&str] = &[
/// Substrings of well-known credential / secret files that bash must not read.
pub const SENSITIVE_PATH_PATTERNS: &[&str] = &[
// SSH private keys and auth
".ssh/id_rsa",
".ssh/id_ed25519",
".ssh/authorized_keys",
// Cloud / package-manager credentials
".aws/credentials",
".aws/config",
".netrc",
".pypirc",
".npmrc",
// Container orchestration secrets
".kube/config",
".docker/config.json",
// GPG keys
".gnupg/",
// System-level secrets
"/etc/shadow",
"/etc/passwd",
"/proc/self/environ",
+99 -2
View File
@@ -1,20 +1,48 @@
//! Low-level LSP client: spawns a language server subprocess, speaks
//! JSON-RPC 2.0 over stdio, and exposes typed methods for the LSP
//! lifecycle and text-document notifications.
//!
//! Flow: `LspClient::spawn` → `initialize` handshake → `didOpen` / `didChange`
//! / `didClose` → positional queries (hover, completion, etc.) →
//! `shutdown` / `exit` on drop.
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tracing::{debug, info};
/// Timeout for the `initialize` handshake (60 s).
const LSP_INIT_TIMEOUT_MS: u64 = 60_000;
/// Timeout for regular LSP method calls (30 s).
const LSP_CALL_TIMEOUT_MS: u64 = 30_000;
/// Timeout waiting for a `textDocument/publishDiagnostics` notification (10 s).
const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
/// A connected LSP language server over stdio JSON-RPC 2.0.
///
/// Holds the child's stdin/stdout streams and tracks the next request id
/// together with the capabilities the server advertised during `initialize`.
/// The caller is responsible for calling `shutdown` before dropping.
pub struct LspClient {
/// Write end of the child's stdin pipe.
stdin: std::process::ChildStdin,
/// Buffered read end of the child's stdout pipe.
stdout: BufReader<std::process::ChildStdout>,
/// Monotonically increasing request id for JSON-RPC calls.
next_id: u64,
/// The `capabilities` blob returned by the server's `initialize` response.
server_capabilities: Value,
}
/// Convert an arbitrary file path (relative or absolute) to a `file://` URI
/// suitable for the LSP `TextDocumentItem.uri` field.
///
/// Flow: resolve relative paths against CWD → canonicalize → prepend `file://`
/// with platform-appropriate slashes.
///
/// Edge case: on Windows, drive letters get a triple slash (`file:///C:/...`).
fn file_path_to_uri(path: &str) -> String {
let abs_path = std::path::Path::new(path);
let abs_path = if abs_path.is_relative() {
@@ -40,7 +68,20 @@ fn file_path_to_uri(path: &str) -> String {
}
impl LspClient {
/// Spawn an LSP server process and run the `initialize` handshake.
///
/// Flow: spawn child with piped stdio → build `LspClient` → send
/// `initialize` request with client capabilities → store
/// `server_capabilities` from the response → send `initialized`
/// notification.
///
/// Param `command`: path or name of the LSP server binary.
/// Param `args`: CLI arguments passed to the binary.
///
/// Return: a fully initialized `LspClient`, or an error if spawn or
/// handshake fails.
pub fn spawn(command: &str, args: &[String]) -> anyhow::Result<Self> {
info!(command = command, "LspClient::spawn");
let mut cmd = Command::new(command);
cmd.args(args);
cmd.stdin(Stdio::piped());
@@ -69,6 +110,7 @@ impl LspClient {
server_capabilities: Value::Null,
};
// Build the `initialize` params with client capabilities.
let init_params = json!({
"processId": std::process::id(),
"clientInfo": {
@@ -118,21 +160,29 @@ impl LspClient {
&init_params,
Duration::from_millis(LSP_INIT_TIMEOUT_MS),
)?;
// Store the capabilities blob for later inspection.
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
client.notify("initialized", &json!({}))?;
info!(command = command, "LSP client initialized");
Ok(client)
}
/// Return the server capabilities blob from the `initialize` response.
pub fn server_capabilities(&self) -> &Value {
&self.server_capabilities
}
/// Send a JSON-RPC request and wait for the matching response (default timeout).
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
}
/// Send a JSON-RPC request and wait for the matching response (custom timeout).
///
/// Flow: bump `next_id` → build `{"jsonrpc","id","method","params"}` →
/// `send_frame` → `read_response` with the chosen timeout.
fn call_with_timeout(
&mut self,
method: &str,
@@ -140,26 +190,33 @@ impl LspClient {
timeout: Duration,
) -> anyhow::Result<Value> {
self.next_id += 1;
let id = self.next_id;
let id = self.next_id; // unique id for this request
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
debug!(method = method, id = id, "LSP call");
self.send_frame(&req)?;
self.read_response(id, timeout)
}
/// Send a JSON-RPC notification (no response expected).
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params
});
debug!(method = method, "LSP notify");
self.send_frame(&req)
}
/// Write a JSON-RPC frame (Content-Length header + body) to the child's stdin.
///
/// Flow: serialize msg → build `Content-Length: N\r\n\r\n` → write header
/// → write body → flush. All I/O errors are wrapped with context.
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
let body = serde_json::to_string(msg)
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
@@ -176,6 +233,11 @@ impl LspClient {
Ok(())
}
/// Read frames from stdout until one matches `expected_id`, then return its
/// `result` (or error on a JSON-RPC error response).
///
/// Flow: loop `read_frame` until id matches → check for `error` field →
/// return `result` or bail with the error code/message.
fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
@@ -200,6 +262,10 @@ impl LspClient {
}
}
/// Read frames from stdout until one matches the given `method`
/// notification, then return its `params`.
///
/// Flow: loop `read_frame` until `method` field matches → return `params`.
pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
@@ -213,8 +279,16 @@ impl LspClient {
}
}
/// Read a single JSON-RPC frame (header + body) from the child's stdout.
///
/// Flow: loop reading header lines until blank line → parse
/// `Content-Length` (capped at 64 MiB) → read exact body bytes →
/// parse JSON. Returns the parsed JSON value.
///
/// Edge case: Content-Length values >64 MiB are rejected (CWE-400).
fn read_frame(&mut self) -> anyhow::Result<Value> {
let mut content_length: Option<usize> = None;
// Read header lines until a blank line.
loop {
let mut line = String::new();
match self.stdout.read_line(&mut line) {
@@ -224,7 +298,7 @@ impl LspClient {
}
let trimmed = line.trim();
if trimmed.is_empty() {
break;
break; // end of headers
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
// Cap Content-Length at 64 MiB to prevent OOM from a
@@ -257,6 +331,7 @@ impl LspClient {
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
}
/// Notify the server that a document was opened (`textDocument/didOpen`).
pub fn did_open(
&mut self,
uri: &str,
@@ -264,6 +339,7 @@ impl LspClient {
version: i32,
text: &str,
) -> anyhow::Result<()> {
debug!(uri = uri, version = version, "LSP didOpen");
self.notify(
"textDocument/didOpen",
&json!({
@@ -277,7 +353,9 @@ impl LspClient {
)
}
/// Notify the server that a document's content changed (`textDocument/didChange`).
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
debug!(uri = uri, version = version, "LSP didChange");
self.notify(
"textDocument/didChange",
&json!({
@@ -292,7 +370,9 @@ impl LspClient {
)
}
/// Notify the server that a document was closed (`textDocument/didClose`).
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
debug!(uri = uri, "LSP didClose");
self.notify(
"textDocument/didClose",
&json!({
@@ -326,14 +406,17 @@ impl LspClient {
self.call(method, &body)
}
/// Request hover information at a document position.
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional("textDocument/hover", uri, line, character, None)
}
/// Request completion items at a document position.
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional("textDocument/completion", uri, line, character, None)
}
/// Request the definition location of the symbol at a position.
pub fn goto_definition(
&mut self,
uri: &str,
@@ -343,6 +426,7 @@ impl LspClient {
self.call_positional("textDocument/definition", uri, line, character, None)
}
/// Request all references to the symbol at a position, including the declaration.
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional(
"textDocument/references", uri, line, character,
@@ -350,6 +434,10 @@ impl LspClient {
)
}
/// Open a document, collect its diagnostics, then close it.
///
/// Flow: `didOpen` → wait for `textDocument/publishDiagnostics` notification
/// → `didClose` → return the `diagnostics` array (or empty on error).
pub fn collect_diagnostics(
&mut self,
uri: &str,
@@ -371,13 +459,19 @@ impl LspClient {
}
}
/// Send `shutdown` + `exit` to the server gracefully.
///
/// Flow: call `shutdown` with 5 s timeout → send `exit` notification.
/// Failures are silently ignored (best-effort cleanup).
pub fn shutdown(&mut self) {
info!("LSP shutdown");
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
let _ = self.notify("exit", &json!({}));
}
}
impl Drop for LspClient {
/// Best-effort `exit` notification on drop.
fn drop(&mut self) {
let _ = self.notify("exit", &json!({}));
}
@@ -396,6 +490,9 @@ fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
}
}
/// Convert an arbitrary file path to a `file://` URI for LSP protocol use.
///
/// This is the public entry point; delegates to the private `file_path_to_uri`.
pub fn path_to_lsp_uri(path: &str) -> String {
file_path_to_uri(path)
}
+26 -8
View File
@@ -1,6 +1,14 @@
//! LSP server connection management: registry of connected servers,
//! per-extension routing, and file-change notification dispatch.
//!
//! Flow: [`LspManager::connect`] spawns a server → [`register_extensions`]
//! maps file extensions to a language id → [`did_change_file`] routes edits
//! as `didOpen` / `didChange` notifications.
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};
mod client;
pub mod provisioner;
@@ -70,6 +78,7 @@ impl LspManager {
language_id: language_id.to_string(),
client: Arc::new(Mutex::new(client)),
});
info!(language_id = language_id, command = command, "LSP server connected");
Ok(())
}
@@ -93,7 +102,11 @@ impl LspManager {
}
let len = self.servers.len();
self.servers.retain(|s| s.language_id != language_id);
self.servers.len() < len
let removed = self.servers.len() < len;
if removed {
info!(language_id = language_id, "LSP server disconnected");
}
removed
}
/// Return the language id (e.g. "rust") registered for `language_id`.
@@ -111,10 +124,12 @@ impl LspManager {
/// are accepted at this layer — caller must ensure a server for
/// `language_id` is connected or will be connected later.
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
let count = extensions.len();
for ext in extensions {
self.extension_registry
.insert(ext.to_string(), language_id.to_string());
}
debug!(language_id = language_id, count = count, "extensions registered");
}
/// Notify the relevant LSP server that a file's contents have changed.
@@ -124,7 +139,7 @@ impl LspManager {
/// -> update `open_files` with the new version.
///
/// Non-critical failures (file missing, server unreachable, send
/// error) are logged with `tracing::warn!` rather than propagated,
/// error) are logged with `warn!` rather than propagated,
/// so a stale notification cannot abort the calling flow.
pub fn did_change_file(&mut self, path: &Path) {
let Some(ext) = path
@@ -132,12 +147,12 @@ impl LspManager {
.and_then(|e| e.to_str())
.map(|s| format!(".{s}"))
else {
tracing::warn!("did_change_file: path has no extension: {:?}", path);
warn!("did_change_file: path has no extension: {:?}", path);
return;
};
let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
tracing::warn!(
warn!(
"did_change_file: no LSP server registered for extension '{}'",
ext
);
@@ -149,13 +164,13 @@ impl LspManager {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
warn!("did_change_file: failed to read {:?}: {}", path, e);
return;
}
};
let Some(client) = self.get_client(&language_id) else {
tracing::warn!("did_change_file: no client for language '{}'", language_id);
warn!("did_change_file: no client for language '{}'", language_id);
return;
};
@@ -168,7 +183,7 @@ impl LspManager {
let mut client = match client.lock() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
warn!(
"did_change_file: client mutex poisoned for '{}': {}",
language_id,
e
@@ -184,7 +199,7 @@ impl LspManager {
};
if let Err(e) = send_result {
tracing::warn!(
warn!(
"did_change_file: failed to notify '{}' for {}: {}",
language_id,
uri,
@@ -208,12 +223,14 @@ impl LspManager {
/// drop the vec. Failures from individual shutdowns are swallowed
/// because the goal is best-effort termination during teardown.
pub fn shutdown_all(&mut self) {
let count = self.servers.len();
for server in &self.servers {
if let Ok(mut client) = server.client.lock() {
client.shutdown();
}
}
self.servers.clear();
info!(count = count, "all LSP servers shut down");
}
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
@@ -248,6 +265,7 @@ impl LspManager {
) -> anyhow::Result<()> {
self.connect(command, args, language_id)?;
self.register_extensions(language_id, extensions);
info!(language_id = language_id, "LSP connected with extensions");
Ok(())
}
}
@@ -38,6 +38,8 @@ pub enum ProvisionResult {
/// Sentinel command names used by `provision_single` to detect "download"
/// tiers (which are dispatched to `download_*` helpers rather than
/// `run_command`). Kept as constants so `supported_servers` stays readable.
/// These are never actual executables — they are matched by prefix/suffix in
/// `manager.rs` and dispatched to `install::run_download_tier`.
pub(super) const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__";
pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
@@ -1,8 +1,12 @@
//! Environment discovery: finding binaries on PATH and detecting available
//! toolchains / package managers on the host system.
//!
//! Flow: [`detect_env`] shells out to `which` for each tool and builds an
//! [`EnvInfo`] struct that the provisioner uses to gate install tiers.
use std::path::PathBuf;
use std::process::Command;
use tracing::{debug, info};
/// Rust toolchain availability on the host PATH.
#[derive(Debug, Clone)]
@@ -57,6 +61,16 @@ pub struct EnvInfo {
pub is_macos: bool,
}
/// Check whether `binary` exists on PATH by shelling out to `which`.
///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
/// `which` returns exit 0 + stdout path when found, non-zero
/// otherwise. We return the first stdout line as the `PathBuf`.
///
/// Returns None if `which` itself is missing, fails to spawn, or the
/// binary is not on PATH. We deliberately don't cache this — it's only
/// called during provisioning and the results feed into install-tier
/// gating, which is already cheap.
/// Check whether `binary` exists on PATH by shelling out to `which`.
///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
@@ -68,6 +82,7 @@ pub struct EnvInfo {
/// called during provisioning and the results feed into install-tier
/// gating, which is already cheap.
pub fn which(binary: &str) -> Option<PathBuf> {
debug!(binary = binary, "checking PATH");
let output = Command::new("which").arg(binary).output().ok()?;
if !output.status.success() {
return None;
@@ -77,7 +92,9 @@ pub fn which(binary: &str) -> Option<PathBuf> {
if first.is_empty() {
None
} else {
Some(PathBuf::from(first))
let path = PathBuf::from(first);
debug!(binary = binary, path = %path.display(), "found on PATH");
Some(path)
}
}
@@ -92,7 +109,8 @@ pub fn which(binary: &str) -> Option<PathBuf> {
/// Edge case: `which` may not exist on Windows; we guard with cfg so
/// this only ever runs on Unix-like targets.
pub fn detect_env() -> EnvInfo {
EnvInfo {
debug!("detecting host environment");
let detected = EnvInfo {
rust: RustToolchain {
has_rustup: which("rustup").is_some(),
has_cargo: which("cargo").is_some(),
@@ -116,5 +134,14 @@ pub fn detect_env() -> EnvInfo {
},
is_linux: cfg!(target_os = "linux"),
is_macos: cfg!(target_os = "macos"),
}
};
info!(
?detected.rust,
?detected.web,
?detected.platform,
?detected.pacman_brew,
?detected.apt_dnf,
"environment detected"
);
detected
}
@@ -3,30 +3,43 @@
//!
//! Each helper downloads a prebuilt binary (or archive) and places it
//! under `~/.local/share/zesdex/lsp/<server-name>/`.
//!
//! Flow: `run_download_tier` dispatches sentinel command names to the
//! appropriate installer (`install_rust_analyzer_binary` or
//! `install_jdtls_from_eclipse`). Each installer downloads, extracts,
//! and sets executable permissions on the binary.
use std::path::{Path, PathBuf};
use tracing::info;
use tracing::{debug, info};
use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN};
use super::discovery::EnvInfo;
use super::manager::run_command;
/// Resolve the directory where downloaded LSP binaries are stored.
///
/// Returns `~/.local/share/zesdex/lsp/<server>/` (using `dirs::data_dir`).
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
let base = dirs::data_dir()
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
.join("zesdex")
.join("lsp")
.join(server);
debug!(server = server, path = %base.display(), "LSP install dir");
Ok(base)
}
/// Check whether `def` was previously installed via the download tier
/// (binary/launcher lives under `~/.local/share/zesdex/lsp/<name>/`).
///
/// Flow: resolve install dir → iterate known binary name patterns under
/// that dir → return the first existing file path.
///
/// Returns the path to the binary if found.
pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) -> Option<PathBuf> {
let base = lsp_install_dir(&def.name).ok()?;
// Candidate relative paths under the install directory for each server.
let candidates: &[&str] = match def.name.as_str() {
"rust-analyzer" => &["rust-analyzer"],
"jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"],
@@ -39,17 +52,24 @@ pub(super) fn previous_download_install(def: &super::config::LanguageServerDef)
if p.exists() {
// Skip directory entries that exist but are the base dir itself.
if p.is_file() {
debug!(name = %def.name, path = %p.display(), "found previous install");
return Some(p);
}
}
}
debug!(name = %def.name, "no previous install found");
None
}
/// Download a file from `url` to `dest` using curl.
///
/// Flow: build curl args with connect-timeout (15 s) and max-time
/// (`max_secs`) → delegate to `run_command` → return error on failure.
fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
let path_str = dest.to_str().ok_or("invalid dest path")?.to_string();
info!(url = url, dest = %path_str, "downloading");
info!(url = url, dest = %path_str, max_secs = max_secs, "downloading file");
// curl flags: -f (fail on HTTP error), -sS (silent but show errors),
// -L (follow redirects), --connect-timeout, --max-time, -o (output).
let args = [
"-fsSL",
"--connect-timeout",
@@ -64,11 +84,15 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
if !ok {
return Err(format!("download failed: {}", out.trim()));
}
info!(url = url, "download complete");
Ok(())
}
/// Download rust-analyzer from GitHub releases and install into
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
/// `~/.local/share/zesdex/lsp/rust-analyzer/rust-analyzer`.
///
/// Flow: create install dir → pick platform URL → download gzipped binary →
/// decompress with gunzip → set executable permissions → return binary path.
fn install_rust_analyzer_binary(
env: &EnvInfo,
progress: ProgressFn<'_>,
@@ -76,6 +100,7 @@ fn install_rust_analyzer_binary(
let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
// GitHub release URLs for the latest rust-analyzer prebuilt binary.
let url = if env.is_linux {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
} else if env.is_macos {
@@ -84,9 +109,10 @@ fn install_rust_analyzer_binary(
return Err("no prebuilt binary for this OS".to_string());
};
let gz = base.join("rust-analyzer.gz");
let target = base.join("rust-analyzer");
let gz = base.join("rust-analyzer.gz"); // downloaded archive
let target = base.join("rust-analyzer"); // final binary path
info!("rust-analyzer: downloading prebuilt binary");
if let Some(cb) = progress {
cb("Rust: downloading prebuilt binary...");
}
@@ -103,6 +129,7 @@ fn install_rust_analyzer_binary(
if !target.exists() {
return Err("binary missing after decompression".to_string());
}
// Set executable bit on Unix (0o755 = rwxr-xr-x).
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -112,17 +139,24 @@ fn install_rust_analyzer_binary(
if let Some(cb) = progress {
cb("Rust: installed ✓");
}
info!("rust-analyzer: installed at {}", target.display());
Ok(target)
}
/// Download Eclipse JDT-LS from the official snapshot server, extract it,
/// and create a launcher script at `bin/jdtls`.
///
/// Flow: create install dir → download ~150 MB tarball → extract with tar →
/// verify `plugins/` exists → write a bash launcher script that resolves
/// the JDT-LS launcher JAR and config → set launcher executable.
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let base = lsp_install_dir("jdtls")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
let tarball = base.join("jdtls.tar.gz");
info!("jdtls: downloading (~150 MB)");
if let Some(cb) = progress {
cb("Java: downloading JDT-LS (~150MB)...");
}
@@ -146,6 +180,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
}
let _ = std::fs::remove_file(&tarball);
// Validate that the extracted contents include the plugins directory.
if !base.join("plugins").exists() {
return Err("extracted archive missing plugins/ directory".to_string());
}
@@ -182,15 +217,20 @@ exec java \
if let Some(cb) = progress {
cb("Java: JDT-LS installed ✓");
}
info!("jdtls: installed at {}", launcher.display());
Ok(launcher)
}
/// Dispatch a sentinel download tier to the correct helper.
///
/// Matches sentinel constants (`DOWNLOAD_RUST_BIN`, `DOWNLOAD_JDTLS`) and
/// routes to the appropriate platform-aware installer.
pub(super) fn run_download_tier(
name: &str,
env: &EnvInfo,
progress: ProgressFn<'_>,
) -> Result<PathBuf, String> {
info!(tier = name, "running download tier");
match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
@@ -5,7 +5,7 @@ use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult};
use super::discovery::{self, EnvInfo};
@@ -25,6 +25,7 @@ use crate::app::lsp::LspManager;
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
debug!(cmd = cmd, args = ?args, "running command");
let mut command = Command::new(cmd);
command.args(args);
command.stdout(Stdio::piped());
@@ -1,10 +1,11 @@
//! Auto-provisioning engine for LSP language servers.
//!
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
//! → `provision_single()` tries install tiers in order → returns
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call `auto_connect()` to attach available servers
//! to an existing `LspManager`.
//! Flow: [`discovery::detect_env()`] probes the host → for each server in
//! [`config::supported_servers()`] → [`manager::provision_all_with_progress()`]
//! tries install tiers in order → returns [`config::ProvisionResult`]
//! (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call [`manager::auto_connect()`] to attach available
//! servers to an existing [`crate::app::lsp::LspManager`].
//!
//! Why: opening a project on a fresh machine should not require the user
//! to manually hunt down and install 4 different language servers.
@@ -26,13 +27,13 @@ pub use config::{InstallTier, LanguageServerDef, ProgressFn, ProvisionResult};
#[allow(unused_imports)]
pub use config::supported_servers;
// Environment discovery
// Environment discovery — toolchain and package-manager detection
#[allow(unused_imports)]
pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain};
#[allow(unused_imports)]
pub use discovery::which;
// Manager / orchestration
// Manager / orchestration — provisioning loop and LspManager attachment
#[allow(unused_imports)]
pub use manager::{auto_connect, provision_all_with_progress, run_command};
+21 -11
View File
@@ -1,9 +1,16 @@
//! MCP server connection management: spawning/talking to stdio child
//! processes and HTTP endpoints, and adapting their advertised tools to
//! the crate's `Tool` trait.
//!
//! Flow: [`McpManager::connect_stdio`] spawns an MCP server → runs
//! `initialize` handshake → calls `tools/list` → wraps each advertised
//! tool in an [`McpToolAdapter`] (which implements `Tool`) → stores the
//! server with its persistent child handle for subsequent `tools/call`.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
use super::transport::{call_via_http, call_via_stdio, mcp_static_str, spawn_stdio_child};
@@ -128,6 +135,7 @@ impl McpManager {
command: &str,
extra_args: &[String],
) -> anyhow::Result<()> {
info!(name = name, command = command, "MCP connect stdio");
let transport = McpTransport::Stdio {
command: command.to_string(),
args: extra_args.to_vec(),
@@ -146,18 +154,18 @@ impl McpManager {
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
tracing::warn!(
"[mcp] tool {} missing description",
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
);
""
})
.to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
tracing::warn!(
"[mcp] tool {} missing inputSchema",
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
warn!(
tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"),
"MCP tool missing description"
);
""
})
.to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
warn!(
tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"),
"MCP tool missing inputSchema"
);
serde_json::Value::Null
}),
})
@@ -169,6 +177,7 @@ impl McpManager {
let handle = Arc::new(Mutex::new(child));
let tool_count = tools.len();
self.servers.push(McpServer {
name: name.to_string(),
transport,
@@ -176,6 +185,7 @@ impl McpManager {
child_handle: Some(handle),
});
info!(name = name, tool_count = tool_count, "MCP server connected");
Ok(())
}
}
+7 -2
View File
@@ -1,4 +1,9 @@
//! Model Context Protocol (MCP) client: connects to external MCP servers
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
pub mod manager;
pub mod transport;
//!
//! Sub-modules:
//! - [`manager`] — server registry, connection lifecycle, tool adapter
//! - [`transport`] — low-level stdio child management and HTTP client calls
pub mod manager; // McpManager, McpServer, McpToolAdapter
pub mod transport; // McpTransport, McpToolInfo, StdioChild, wire helpers
+19 -9
View File
@@ -1,11 +1,16 @@
//! MCP transport layer: stdio child process management and HTTP client calls.
//! This module handles the low-level protocol details of communicating with
//! MCP servers (both spawned subprocesses and remote HTTP endpoints).
//!
//! Flow: `spawn_stdio_child` → `StdioChild::call` for JSON-RPC messages;
//! `call_via_stdio` / `call_via_http` are convenience wrappers for
//! `tools/call` that reuse a persistent child handle or spawn a fresh one.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::sync::{Mutex, OnceLock};
use tracing::{debug, info, warn};
// ---------------------------------------------------------------------------
// Constants
@@ -26,7 +31,7 @@ pub(super) fn mcp_static_str(s: &str) -> &'static str {
let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() {
Ok(c) => c,
Err(poisoned) => {
tracing::warn!("[mcp] static string cache mutex poisoned, recovering");
warn!("[mcp] static string cache mutex poisoned, recovering");
poisoned.into_inner()
}
};
@@ -88,6 +93,7 @@ impl StdioChild {
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
self.next_id += 1;
let id = self.next_id;
debug!(method = method, id = id, "MCP stdio call");
let req = json!({
"jsonrpc": "2.0",
"id": id,
@@ -163,7 +169,7 @@ impl StdioChild {
anyhow::bail!("MCP error: {err}");
}
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
warn!("MCP stdio response missing 'result' field: {}", trimmed);
Value::Null
}));
}
@@ -179,6 +185,7 @@ pub(crate) fn spawn_stdio_child(
command: &str,
extra_args: &[String],
) -> anyhow::Result<StdioChild> {
info!(command = command, "MCP spawn stdio child");
let parts: Vec<&str> = command.split_whitespace().collect();
let (prog, prog_args) = parts
.split_first()
@@ -249,6 +256,7 @@ pub(super) fn call_via_stdio(
tool_name: &str,
tool_args: &Value,
) -> anyhow::Result<String> {
debug!(tool = tool_name, has_handle = existing_handle.is_some(), "MCP call_via_stdio");
// Reuse the persistent child handle if available; otherwise spawn a new one.
let mut guard;
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
@@ -257,6 +265,7 @@ pub(super) fn call_via_stdio(
.map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
&mut guard
} else {
// No persistent handle — spawn a fresh child for this one call.
let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call(
"tools/call",
@@ -280,13 +289,14 @@ pub(super) fn call_via_stdio(
}
pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
debug!(tool = tool_name, url = url, "MCP call_via_http");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
.build()
.unwrap_or_else(|e| {
tracing::warn!(
"[mcp] HTTP client builder failed with connect timeout: {}. \
warn!(
"MCP HTTP client builder failed with connect timeout: {}. \
retrying without connect timeout",
e,
);
@@ -294,8 +304,8 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.build()
.unwrap_or_else(|e2| {
tracing::warn!(
"[mcp] also failed: {}. using default client (no configured timeouts)",
warn!(
"MCP also failed: {}. using default client (no configured timeouts)",
e2,
);
reqwest::blocking::Client::new()
@@ -323,7 +333,7 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
warn!("MCP failed to read HTTP response body: {}", e);
String::new()
});
anyhow::bail!("MCP HTTP server returned {status}: {text}");
@@ -338,7 +348,7 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an
}
let result = response.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] HTTP response missing 'result' field");
warn!("MCP HTTP response missing 'result' field");
Value::Null
});
Ok(extract_text_content(&result))
@@ -365,7 +375,7 @@ pub(super) fn extract_text_content(result: &Value) -> String {
}
}
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
warn!("MCP failed to pretty-print result: {}", e);
result.to_string()
})
}
+12 -11
View File
@@ -1,14 +1,15 @@
//! Top-level application module: tool gate, modes, runtime loop, state,
//! workflows, subagents, review, background bash, MCP integration, and
//! native LSP client.
pub mod bgbash;
pub mod guard;
pub mod lsp;
pub mod mcp;
pub mod mode;
pub mod review;
pub mod runtime;
pub mod state;
pub mod subagent;
pub mod util;
pub mod workflow;
pub mod bgbash; // Background bash process management
pub mod guard; // Tool gate: per-tool access control & permissions
pub mod lsp; // Native LSP client integration
pub mod mcp; // Model Context Protocol tool bridge
pub mod mode; // Application operating modes (normal, yolo, etc.)
pub mod review; // Post-edit auto-review subagent
pub mod runtime; // Action dispatch, streams, slash commands
pub mod state; // AppStateRest, runtime state, turn events
pub mod subagent; // Spawned subagents (test-gen, arch, security review)
pub mod util; // Miscellaneous helpers
pub mod workflow; // Hive-mind orchestration & agent workflows
@@ -1,5 +1,6 @@
//! Bash mode: handles submitting a shell command from the bash input panel.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Launch a background bash job for the submitted command.
///
@@ -12,7 +13,10 @@ use crate::app::state::rest::AppStateRest;
/// the shared jobs map for later polling.
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
if !command.is_empty() {
debug!(command_len = command.len(), "spawning bash job from mode");
let _ = crate::app::bgbash::job::spawn_bash_job(command);
state.dirty = true;
} else {
debug!("bash submit with empty command — ignored");
}
}
@@ -2,6 +2,7 @@
//! with bounded undo history.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use tracing::debug;
/// State for the built-in line editor overlay: buffer contents, cursor
/// position, and a bounded undo stack.
@@ -30,7 +31,9 @@ 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 is_new = existing_content.is_none();
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
debug!(path = %path, is_new, lines = content.len(), "editor opened");
EditorState {
path,
content,
@@ -116,6 +119,7 @@ impl EditorState {
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
let editor = &mut state.misc.editor;
let Some(ed) = editor.as_mut() else {
debug!("editor input received but no editor open — ignored");
return;
};
for c in text.chars() {
@@ -138,7 +142,14 @@ pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
}
/// Close the editor overlay without saving, clearing editor state.
///
/// Flow: reset editor to `None` → set overlay to `Overlay::None` →
/// mark state dirty for re-render.
///
/// Why: discards unsaved edits; the caller is responsible for saving
/// via a separate commit action.
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
debug!("editor dismissed without saving");
state.misc.editor = None;
state.misc.overlay = Overlay::None;
state.dirty = true;
+12 -3
View File
@@ -1,13 +1,18 @@
//! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and `max_tokens` for subsequent turns.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Named effort levels from lowest to highest. Higher levels allocate more
/// tokens and use lower temperature for more deterministic reasoning.
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use,
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token
/// budget for lower temperature (more deterministic, more room to reason/act).
/// Multiplier applied to the user's configured `max_tokens` per effort level.
/// Same index as `EFFORT_LEVELS`. Higher effort = larger token budget.
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0];
/// Temperature override per effort level. Higher effort = lower temperature
/// (more deterministic, less creative variation).
const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1];
/// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent
@@ -28,6 +33,9 @@ pub fn current_effort(state: &AppStateRest) -> usize {
}
/// Return the current effort level's display name (e.g. "medium").
///
/// Flow: delegate to `current_effort` for clamped index → index into
/// `EFFORT_LEVELS`.
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
let idx = current_effort(state);
EFFORT_LEVELS[idx]
@@ -41,6 +49,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
let label = current_effort_str(state);
debug!(from = %EFFORT_LEVELS[current], to = %label, "effort level cycled");
state.toast_info(format!("Effort: {label}"));
state.dirty = true;
}
@@ -1,8 +1,18 @@
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
//! Key input mode: raw text capture overlay used for one-off key/text prompts
//! such as rename, search, and inline file paths.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Replace the input buffer with the given text and mark state dirty.
/// Store the captured text into the input buffer and mark state dirty.
///
/// Flow: write `text` into `state.input.buffer` → set dirty flag so the
/// TUI re-renders the overlay with the new text.
///
/// Why: the overlay reads `state.input.buffer` to display the current
/// prompt text; this is the single point where captured keystrokes
/// become visible to the renderer.
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
debug!(len = text.len(), "key-input text captured");
state.input.buffer = text;
state.dirty = true;
}
+25 -1
View File
@@ -1,7 +1,19 @@
//! Learning mode: TUI overlay for reviewing and managing lesson items.
//! Loads both pending lessons (from the session directory) and stored
//! lessons (from long-term memory) into a unified list for the overlay.
//!
//! Flow: read pending files → deserialize as `PendingLesson` → read
//! long-term memory dir → filter by `kind == "lesson"` → merge into
//! a single `Vec<LearningItem>`.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
use zesdex_cms::domain::repository::MemoryRepository;
/// A unified representation of a lesson item for the interactive TUI overlay.
///
/// Two variants: `Pending` (not yet committed to long-term memory) and
/// `Stored` (already persisted in the memory directory).
#[derive(Debug, Clone)]
pub enum LearningItem {
Pending {
@@ -19,7 +31,16 @@ pub enum LearningItem {
},
}
/// Dynamically read all pending and stored lessons.
/// Dynamically read all pending and stored lessons from session dir and
/// long-term memory.
///
/// Flow: load pending lessons from `state.session_runtime.session_dir` →
/// map each to `LearningItem::Pending` → load stored memories from
/// `state.memory_dir` → filter by `kind == "lesson"` → collect remaining
/// items.
///
/// Return: merged `Vec<LearningItem>` (pending first, then stored). Empty
/// vec if nothing is found.
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let mut items = Vec::new();
@@ -29,6 +50,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
} else {
Vec::new()
};
debug!(pending_count = pending.len(), "loading pending lessons");
for p in pending {
let scope_str = match p.lesson.scope {
@@ -58,6 +80,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
.list(&state.memory_dir)
.unwrap_or_default();
debug!(stored_names = names.len(), "loading stored lessons");
for name in names {
if let Ok(mem) =
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
@@ -75,5 +98,6 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
}
}
debug!(total_items = items.len(), "learning items loaded");
items
}
+14
View File
@@ -1,11 +1,25 @@
//! MCP mode: overlay for connecting to a configured MCP server.
//!
//! Flow: invoked from the TUI overlay — reads the server name from user input,
//! then delegates to the appropriate MCP connection path.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Placeholder entry point for connecting to an MCP server by name.
///
/// Flow: marks state dirty → overlay re-renders.
///
/// Why: not yet wired to `McpManager::connect_stdio` — currently just
/// marks state dirty so the overlay re-renders.
///
/// ## Future
/// Once `McpManager::connect_stdio` is wired, this function will:
/// 1. Resolve `server_name` from the config registry.
/// 2. Spawn the stdio subprocess.
/// 3. Register the transport in the MCP manager.
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
debug!(%server_name, "connect_mcp called");
let _ = server_name;
// Mark state dirty to trigger a re-render of the MCP overlay.
state.dirty = true;
}
+13 -10
View File
@@ -1,16 +1,19 @@
//! TUI mode definitions and per-mode input/action handlers, one submodule
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
pub mod bash;
pub mod editor;
pub mod effort;
pub mod key_input;
pub mod mcp;
//! Each mode encapsulates its own keyboard input parsing, state transitions,
//! and view rendering so the top-level event loop can dispatch generically.
pub mod learning;
pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
pub mod bash; // Shell-command input overlay: prompt, history, execution
pub mod editor; // Multi-line text editor overlay (write/edit tool content)
pub mod effort; // Reasoning-effort selector overlay
pub mod key_input; // Generic single-key prompt overlay (e.g. rename, search)
pub mod mcp; // MCP tool argument builder overlay
pub mod learning; // Learning/reflection input overlay
pub mod quit_confirm; // Quit confirmation dialog overlay
pub mod rewind; // Rewind/undo checkpoint selection overlay
pub mod settings; // Settings panel overlay
pub mod todo; // TODO-list management overlay
/// Cycle `current` in the range `[0, len)`.
///
@@ -1,11 +1,18 @@
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
//!
//! Flow: user presses quit → overlay appears with yes/no → `handle_quit_confirm`
//! translates the choice into an `Action`.
use crate::app::runtime::actions::Action;
use tracing::debug;
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
///
/// Flow: receives `true` (yes, quit) or `false` (no, cancel).
///
/// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
/// to dismiss the prompt without quitting.
pub fn handle_quit_confirm(yes: bool) -> Action {
debug!(%yes, "handle_quit_confirm");
if yes {
Action::ForceQuit
} else {
+39 -4
View File
@@ -1,23 +1,47 @@
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store.
//!
//! Flow: user invokes Rewind overlay → `rewind_count` shows available snapshots
//! → user picks an index → `rewind_to` fetches the blob, writes it back to disk,
//! and logs the rewind in the edit log.
use crate::app::state::rest::AppStateRest;
use sha2::Digest;
use tracing::{debug, info};
use zesdex_cms::domain::repository::EditLogRepository;
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
///
/// Flow: opens the session DB → lists blob keys → returns count.
///
/// Return: `0` if the DB cannot be opened or no blobs exist.
pub fn rewind_count(state: &AppStateRest) -> usize {
let Ok(conn) = open_session_db(&state.session_dir) else {
debug!("rewind_count: cannot open session DB, returning 0");
return 0;
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
let count = crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok()
.map_or(0, |keys| keys.len())
.map_or(0, |keys| keys.len());
debug!(count, "rewind_count");
count
}
/// Restores a file to its pre-edit state by retrieving the blob stored under index
/// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
/// of a running turn (e.g. from the Rewind overlay).
///
/// Flow:
/// 1. Open session DB.
/// 2. List blob keys.
/// 3. Validate index bounds.
/// 4. Retrieve blob bytes.
/// 5. Resolve the original file path from the edit log.
/// 6. Write bytes back to disk.
/// 7. Log the rewind as an edit-log entry.
/// 8. Mark transcript cache dirty to force a UI refresh.
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
debug!(%index, "rewind_to start");
let conn = match open_session_db(&state.session_dir) {
Ok(c) => c,
Err(e) => {
@@ -66,6 +90,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
match std::fs::write(&restore_path, &bytes) {
Ok(()) => {
info!(path = %restore_path.display(), "rewind_to: file restored from snapshot");
state.toast_success(format!("Restored {} from snapshot", restore_path.display()));
}
Err(e) => {
@@ -73,7 +98,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
}
}
// Log the rewind itself as an edit entry
// Log the rewind itself as an edit entry so the operation is auditable.
let repo =
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&state.session_dir) {
@@ -90,17 +115,27 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let _ = repo.append(&state.session_dir, &mut el, entry);
}
// Clear the transcript to force a refresh
// Clear the transcript cache to force the UI to refresh.
state.transcript_cache.dirty = true;
state.dirty = true;
debug!("rewind_to finished");
}
/// Open a direct SQLite connection to the session database.
///
/// Flow: constructs the path to `messages.sqlite` under `session_dir` → opens with rusqlite.
fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite");
let conn = rusqlite::Connection::open(&path)?;
debug!(path = %path.display(), "open_session_db opened");
Ok(conn)
}
/// Walk the edit log backwards to find the most recent `write` or `edit` entry,
/// and return its path.
///
/// Why: the blob key is a `tool_call_id`, but the edit log stores paths, not
/// tool_call_ids. We fall back to the last-known written/edited path.
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&state.session_dir)
@@ -3,6 +3,7 @@
//! Flow: exposes small mutation functions (currently just cycling the
//! internet access mode) invoked by keybindings while the settings overlay
//! is active.
use tracing::debug;
use zesdex_cms::domain::settings::{InternetMode, Settings};
/// Advance the internet access mode to the next value in the cycle.
@@ -14,9 +15,11 @@ use zesdex_cms::domain::settings::{InternetMode, Settings};
///
/// Return: nothing; mutates `settings.internet_mode` in place.
pub fn cycle_internet_mode(settings: &mut Settings) {
let before = settings.internet_mode.clone();
settings.internet_mode = match settings.internet_mode {
InternetMode::Off => InternetMode::ReadOnly,
InternetMode::ReadOnly => InternetMode::Full,
InternetMode::Full => InternetMode::Off,
};
debug!(?before, ?settings.internet_mode, "cycle_internet_mode");
}
@@ -4,6 +4,7 @@
//! the todo overlay.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use tracing::debug;
/// Toggle the todo-list overlay open or closed.
///
@@ -14,10 +15,12 @@ use crate::app::state::types::Overlay;
///
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
pub fn handle_todo_toggle(state: &mut AppStateRest) {
let before = state.misc.overlay;
if state.misc.overlay == Overlay::Todo {
state.misc.overlay = Overlay::None;
} else {
state.misc.overlay = Overlay::Todo;
}
debug!(before = %before, after = %state.misc.overlay, "handle_todo_toggle");
state.dirty = true;
}
+19 -1
View File
@@ -73,8 +73,11 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
/// 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) {
tracing::info!("[review] triggering quality-review subagent");
state.misc.lesson_running = true;
// Ensure docs/lesson/ is gitignored so generated lesson files don't
// pollute the workspace's tracked state.
if let Some(workspace) = state.workspace_roots.first() {
let gitignore_path = workspace.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
@@ -108,6 +111,8 @@ pub fn trigger_review(state: &mut AppStateRest) {
ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces.clone_from(&state.workspace_roots);
// Run build/test probe so the review subagent gets a real pass/fail
// signal rather than reviewing changes blind.
let probe_result = probe::probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
@@ -117,20 +122,30 @@ pub fn trigger_review(state: &mut AppStateRest) {
let probe_note = match &probe_result {
Some(r) => {
if r.passed {
tracing::debug!("[review] probe passed: {}", r.command);
format!("Build/test verification passed ({}).", r.command)
} else if r.timed_out {
tracing::debug!("[review] probe timed out: {}", r.command);
format!("Build/test verification timed out ({}).", r.command)
} else {
tracing::debug!("[review] probe failed: {}", r.command);
format!(
"Build/test verification failed ({}). Output: {}",
r.command, r.output
)
}
}
None => "No build/test probe matched.".to_string(),
None => {
tracing::debug!("[review] no probe matched");
"No build/test probe matched.".to_string()
}
};
ctx.system_prompt = prompt::compose_review_prompt(state, &probe_note);
tracing::debug!(
"[review] prompt length: {} chars",
ctx.system_prompt.len()
);
let turn_events_for_drain = state.turn_events.clone();
let (tx, _drain_thread) = spawn_subagent_with_drain(move |event| {
@@ -164,6 +179,7 @@ pub fn trigger_review(state: &mut AppStateRest) {
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
tracing::debug!("[review] subagent thread started");
let result = run_subagent(&ctx, &tx);
let message = match result {
Ok(verdict) => {
@@ -180,6 +196,8 @@ pub fn trigger_review(state: &mut AppStateRest) {
}
});
// Push a non-blocking toast so the user knows a lesson is being
// generated; the actual outcome arrives via SystemNote.
state.push_toast(Toast::new(
ToastKind::Info,
"Generating lesson...".to_string(),
@@ -60,12 +60,13 @@ pub fn process_pending_lessons(
) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
let grace_window = 5_000;
let grace_window = 5_000; // 5 seconds for user to reject auto-resolve
let mut remaining = Vec::new();
let mut to_keep = Vec::new();
for p in &pending {
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
tracing::debug!("[pending] auto-resolving lesson: {}", p.lesson.name);
to_keep.push(p.lesson.clone());
} else {
remaining.push(p.clone());
@@ -119,6 +120,7 @@ pub fn resolve_pending_lesson(
for p in pending {
if p.lesson.name == lesson_name {
if keep {
tracing::info!("[pending] committing lesson: {lesson_name}");
let mem = Memory {
name: p.lesson.name.clone(),
description: p.lesson.content.chars().take(80).collect(),
@@ -136,6 +138,8 @@ pub fn resolve_pending_lesson(
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
.map_err(|e| std::io::Error::other(e.to_string()))?;
} else {
tracing::debug!("[pending] discarding lesson: {lesson_name}");
}
} else {
remaining.push(p);
+22 -1
View File
@@ -35,6 +35,10 @@ pub fn probe_build_test(
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
tracing::debug!("[probe] running: {cmd} in {:?}", probe_dir);
// Split "command arg1 arg2" into program + args for Command API.
// If there's no space, args are empty.
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(
|| (cmd.clone(), String::new()),
|(p, a)| (p.to_string(), a.to_string()),
@@ -47,6 +51,7 @@ pub fn probe_build_test(
.stderr(std::process::Stdio::piped())
.spawn()
else {
tracing::warn!("[probe] failed to spawn: {cmd_prog}");
return None;
};
@@ -72,9 +77,14 @@ pub fn probe_build_test(
} else {
format!("{stdout}\n{stderr}")
};
let passed = status.success();
tracing::debug!(
"[probe] finished: passed={passed}, exit={:?}",
status.code()
);
return Some(ProbeResult {
command: cmd.clone(),
passed: status.success(),
passed,
output: truncate_output(&combined, 2048),
timed_out: false,
});
@@ -86,6 +96,7 @@ pub fn probe_build_test(
}
};
if timed_out {
tracing::debug!("[probe] timed out after {timeout_ms}ms: {cmd}");
Some(ProbeResult {
command: cmd.clone(),
passed: false,
@@ -93,6 +104,7 @@ pub fn probe_build_test(
timed_out: true,
})
} else {
tracing::debug!("[probe] unexpected exit from polling loop for: {cmd}");
None
}
}
@@ -114,23 +126,30 @@ pub(crate) fn resolve_verify_command(
probe_dir: &std::path::Path,
override_cmd: Option<&str>,
) -> Option<String> {
// Use explicit override if provided and non-empty.
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
tracing::debug!("[probe] using override command: {cmd}");
return Some(cmd.trim().to_string());
}
}
// Auto-detect from project marker files, trying common ecosystems
// in priority order.
let has_file = |name: &str| probe_dir.join(name).exists();
let has_dir = |name: &str| probe_dir.join(name).is_dir();
if has_file("Cargo.toml") {
tracing::debug!("[probe] detected Cargo project");
if has_dir("src") || has_dir("tests") {
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
}
return Some("cargo build 2>&1".to_string());
}
if has_file("go.mod") {
tracing::debug!("[probe] detected Go project");
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
}
if has_file("package.json") {
tracing::debug!("[probe] detected Node project");
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?;
@@ -160,6 +179,7 @@ pub(crate) fn resolve_verify_command(
|| has_file("Pipfile")
|| has_file("poetry.lock")
{
tracing::debug!("[probe] detected Python project");
if has_file("pyproject.toml") {
let content =
std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
@@ -223,6 +243,7 @@ pub(crate) fn resolve_verify_command(
if has_file("Project.toml") || has_file("JuliaProject.toml") {
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
}
tracing::debug!("[probe] no project marker files matched in {probe_dir:?}");
None
}
@@ -9,6 +9,8 @@ pub(crate) const STALE_AFTER_DAYS: i64 = 60;
/// Compose the system prompt for the quality-review subagent.
pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String {
tracing::debug!("[prompt] composing review prompt");
// Capture the unstaged diff so the reviewer can evaluate actual changes.
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
std::process::Command::new("git")
.arg("diff")
@@ -22,6 +24,8 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S
String::new()
};
// Extract the last 10 chat messages (user + assistant) so the reviewer
// can cross-check what was discussed against what was actually changed.
let history_output = if let Some(rt) = &state.session_runtime {
let msgs: Vec<String> = rt
.messages
@@ -41,6 +45,11 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S
String::new()
};
tracing::debug!(
"[prompt] diff={}chars, history={}chars",
diff_output.len(),
history_output.len()
);
let session_dir_disp = state.session_dir.display();
format!(
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\
@@ -17,6 +17,7 @@ use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryReposito
/// 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>> {
tracing::debug!("[staleness] starting sweep in {:?}", memory_dir);
let mut flagged = Vec::new();
let names = MarkdownMemoryRepository::new()
.list(memory_dir)
@@ -26,6 +27,7 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
for name in names {
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
tracing::info!("[staleness] flagging as stale: {name}");
mem.lifecycle = "stale".to_string();
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
@@ -48,8 +50,10 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
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 {
tracing::trace!("[staleness] sweep skipped (rate-limited)");
return;
}
tracing::debug!("[staleness] sweep window elapsed, running");
state.misc.last_staleness_sweep_ms = now;
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
if !flagged.is_empty() {
@@ -53,6 +53,7 @@ pub struct Lesson {
pub provenance: Provenance,
}
/// Default is an empty unverified project-scoped lesson with no provenance.
impl Default for Lesson {
fn default() -> Self {
Self {
@@ -1,5 +1,7 @@
//! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process.
use tracing::debug;
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
use crate::controller::command::Command;
@@ -14,7 +16,9 @@ use crate::controller::command::Command;
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
/// by `apply_action`.
pub fn apply_command(command: Command) -> Vec<Action> {
debug!("apply_command: {:?}", command);
match command {
// ── Navigation overlays ────────────────────────────────────────
Command::Help => {
vec![Action::OpenOverlay(Overlay::Help)]
}
@@ -27,12 +31,16 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::ClearConfirm => {
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
}
// ── System actions ─────────────────────────────────────────────
Command::Clear => {
vec![Action::SystemNote {
kind: "clear".to_string(),
message: "transcript cleared".to_string(),
}]
}
// ── Login / auth ───────────────────────────────────────────────
Command::Login { provider } if provider.is_empty() => {
vec![Action::SystemNote {
kind: "error".to_string(),
@@ -42,6 +50,8 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Login { provider } => {
vec![Action::StartOAuth { provider }]
}
// ── Editor ─────────────────────────────────────────────────────
Command::Edit(path) if path == "." || path.is_empty() => {
vec![Action::SystemNote {
kind: "info".to_string(),
@@ -51,6 +61,8 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Edit(path) => {
vec![Action::OpenEditor { path }]
}
// ── Tools / configuration ──────────────────────────────────────
Command::McpAdd { name, command } => {
vec![Action::McpAdd { name, command }]
}
@@ -61,12 +73,15 @@ pub fn apply_command(command: Command) -> Vec<Action> {
vec![Action::Compact]
}
// ── Dashboard overlays ─────────────────────────────────────────
Command::TodoOpen => {
vec![Action::OpenOverlay(Overlay::Todo)]
}
Command::UsageOpen => {
vec![Action::OpenOverlay(Overlay::Usage)]
}
// ── Fallback ───────────────────────────────────────────────────
Command::Unknown(cmd) => {
vec![Action::SystemNote {
kind: "error".to_string(),
@@ -1,6 +1,15 @@
//! Simple action handler functions — one per `Action` variant, called by
//! `apply_action` in the root module. Each handler mutates `AppStateRest`
//! in place.
//!
//! Handlers are deliberately short and focused — they extract arguments from
//! the `Action` variant, perform a single state mutation, and mark `dirty`
//! so the TUI re-renders on the next frame.
//!
//! More complex orchestration (turn spawning, OAuth background threads) is
//! delegated to sibling sub-modules (`spawn`, `oauth`, `io`, `memory`).
use tracing::debug;
use crate::app::runtime::context::tokens::count_tokens;
use crate::app::runtime::context::window;
@@ -16,71 +25,104 @@ use super::memory::refresh_lesson_counters;
use super::spawn::spawn_turn;
use super::oauth::run_oauth_flow;
/// Hard exit — save session, shut down LSP, set quit flag.
///
/// Flow: persist session metadata and conversation → terminate LSP client →
/// set `quit = true` so the event loop exits on the next iteration.
pub(super) fn handle_force_quit(state: &mut AppStateRest) {
save_current_session(state);
state.shutdown_lsp();
state.quit = true;
debug!("handle_force_quit");
save_current_session(state); // Persist session metadata + messages
state.shutdown_lsp(); // Gracefully shut down LSP connection
state.quit = true; // Signal event loop to exit
}
/// Submit user text as a new LLM turn.
///
/// Flow: mark input as submitted → trim → guard empty → push `ChatMessageDisplay`
/// into transcript → push `ChatMessage` into session runtime → refresh lesson
/// counters → set `thinking = true` → spawn a background turn thread.
pub(super) fn handle_submit_input(state: &mut AppStateRest, text: String) {
debug!("handle_submit_input: len={}", text.len());
state.input.submit();
let text = text.trim().to_string();
if text.is_empty() {
state.dirty = true;
return;
}
// Push user message into both the display transcript and the session-runtime message list
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::user(text));
refresh_lesson_counters(&state.memory_dir, rt);
} else {
// No active session — ensure the memory directory exists for future use
let _ = std::fs::create_dir_all(&state.memory_dir);
}
state.misc.thinking = true;
spawn_turn(state);
spawn_turn(state); // launches LLM streaming on a background OS thread
state.dirty = true;
}
/// Delete one character left of the cursor in the input buffer.
pub(super) fn handle_delete_char(state: &mut AppStateRest) {
debug!("handle_delete_char");
state.input.delete_left();
state.dirty = true;
}
/// Delete one character right of the cursor in the input buffer.
pub(super) fn handle_delete_char_right(state: &mut AppStateRest) {
debug!("handle_delete_char_right");
state.input.delete_right();
state.dirty = true;
}
/// Move the cursor one position left.
pub(super) fn handle_cursor_left(state: &mut AppStateRest) {
debug!("handle_cursor_left");
state.input.char_left();
}
/// Move the cursor one position right.
pub(super) fn handle_cursor_right(state: &mut AppStateRest) {
debug!("handle_cursor_right");
state.input.char_right();
}
/// Navigate up through input history.
pub(super) fn handle_history_up(state: &mut AppStateRest) {
debug!("handle_history_up");
state.input.history_up();
state.dirty = true;
}
/// Navigate down through input history.
pub(super) fn handle_history_down(state: &mut AppStateRest) {
debug!("handle_history_down");
state.input.history_down();
state.dirty = true;
}
/// Scroll the transcript pane up by 5 lines.
pub(super) fn handle_scroll_up(state: &mut AppStateRest) {
debug!("handle_scroll_up");
state.scroll.scroll_up(5);
state.dirty = true;
}
/// Scroll the transcript pane down by 5 lines.
pub(super) fn handle_scroll_down(state: &mut AppStateRest) {
debug!("handle_scroll_down");
state.scroll.scroll_down(5);
state.dirty = true;
}
/// Open a named overlay — sets the overlay variant and resets selection index
/// for overlays that support list navigation (Learning, Rewind, ModelSelector).
pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
debug!("handle_open_overlay: {:?}", overlay);
state.misc.overlay = overlay;
// Reset selection index for list-based overlays
if overlay == Overlay::Learning
|| overlay == Overlay::Rewind
|| overlay == Overlay::ModelSelector
@@ -90,13 +132,20 @@ pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
state.dirty = true;
}
/// Open the inline file editor for `path`.
///
/// Flow: resolve the workspace-relative path → read file content →
/// construct `EditorState` → set overlay to `Editor`.
pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) {
debug!("handle_open_editor: {}", path);
// Resolve path relative to workspace roots
let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
match resolved {
Ok(abs_path) => {
let content = std::fs::read_to_string(&abs_path).unwrap_or_default();
let lines: Vec<String> =
content.lines().map(std::string::ToString::to_string).collect();
// Create the editor state from the file content lines
let ed = crate::app::mode::editor::EditorState::open(
abs_path.to_string_lossy().to_string(),
Some(lines),
@@ -115,13 +164,20 @@ pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) {
state.dirty = true;
}
/// Register a new MCP server by name and shell command.
///
/// Flow: parse command string into (cmd, args) → call `connect_stdio` on the
/// MCP manager → push success/error toast.
pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
debug!("handle_mcp_add: name={}, command={}", name, command);
// Split the command string into program + arguments
let extra_args: Vec<String> =
command.split_whitespace().map(std::string::ToString::to_string).collect();
let cmd = extra_args.first().cloned().unwrap_or_default();
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
let cmd = extra_args.first().cloned().unwrap_or_default(); // main executable
let args: Vec<String> = extra_args.into_iter().skip(1).collect(); // remaining args
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
Ok(()) => {
// Read back the tool count from the newly connected server
let tool_count = state
.mcp_manager
.servers
@@ -142,14 +198,22 @@ pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: St
}
}
/// Open the model-picker overlay and reset the selection index.
pub(super) fn handle_model_list(state: &mut AppStateRest) {
debug!("handle_model_list");
state.misc.selected_index = 0;
state.misc.overlay = Overlay::ModelSelector;
state.dirty = true;
}
/// Close the current overlay — dismisses the editor overlay specially if active.
///
/// Flow: if the active overlay is the Editor, call `handle_editor_dismiss` to
/// finalise edits before clearing the overlay; otherwise just reset to `None`.
/// Always marks `dirty` so the TUI re-renders without the overlay.
pub(super) fn handle_close_overlay(state: &mut AppStateRest) {
// If the overlay is the Editor, dismiss it properly first
debug!("handle_close_overlay");
// Dismiss the editor with save-confirm if it is currently open
if state.misc.overlay == Overlay::Editor {
crate::app::mode::editor::handle_editor_dismiss(state);
}
@@ -157,30 +221,42 @@ pub(super) fn handle_close_overlay(state: &mut AppStateRest) {
state.dirty = true;
}
/// Push an informational toast with the given message.
pub(super) fn handle_system_note(state: &mut AppStateRest, message: String) {
debug!("handle_system_note: {}", message);
let toast = Toast::new(ToastKind::Info, message);
state.push_toast(toast);
}
/// Show the quit-confirmation overlay.
pub(super) fn handle_quit_confirm(state: &mut AppStateRest) {
state.misc.overlay = Overlay::QuitConfirm;
state.dirty = true;
}
/// Handle terminal resize — update the scroll max-visible width.
pub(super) fn handle_resize(state: &mut AppStateRest, w: u16) {
debug!("handle_resize: width={}", w);
state.scroll.set_max_visible(w as usize);
state.dirty = true;
}
/// Start an OAuth device-code login flow on a background thread.
///
/// Flow: clone the turn-events queue → spawn thread → run `run_oauth_flow` →
/// push result as a `TurnEvent::SystemNote` back to the main loop.
pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
debug!("handle_start_oauth: provider={}", provider);
let turn_events = state.turn_events.clone();
let provider_clone = provider.clone();
// Run the blocking OAuth HTTP flow off the main thread
std::thread::spawn(move || {
let result = run_oauth_flow(&provider_clone);
let message = match result {
Ok(msg) => msg,
Err(e) => format!("OAuth login failed: {e}"),
};
// Push result back via the shared turn-events queue
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "oauth".to_string(),
@@ -196,7 +272,13 @@ pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
state.dirty = true;
}
/// Set the abort flag to signal the currently running LLM turn to stop.
///
/// Flow: atomically set `abort_flag` to `true` (checked by the streaming
/// task between tool calls) → push a warning toast to inform the user.
pub(super) fn handle_abort_turn(state: &mut AppStateRest) {
debug!("handle_abort_turn");
// Signal the streaming task to stop at the next safe point
state
.abort_flag
.store(true, std::sync::atomic::Ordering::SeqCst);
@@ -206,13 +288,22 @@ pub(super) fn handle_abort_turn(state: &mut AppStateRest) {
));
}
/// AI-summary compaction of the conversation history.
///
/// Flow: resolve max-wire-tokens → extract provider config (API key, model,
/// base URL) → build an `LlmClient` → delegate to `shape_messages` which
/// summarises older messages via the LLM → compute token diff → push toast.
///
/// Why: compaction preserves semantic context (goals, decisions, files, state)
/// instead of naively dropping messages, using the configured LLM to produce
/// a concise summary of what came before.
pub(super) fn handle_compact(state: &mut AppStateRest) {
debug!("handle_compact");
// Resolve the maximum allowed tokens from the wire window config
let max_wire_tokens = window::resolve(&state.app_config, &state.settings);
// Extract config before borrowing session_runtime mutably to avoid
// borrow conflicts. An LLM client is needed for summarization so the
// compacted result preserves meaningful context (goals, decisions,
// files, state) instead of a useless static placeholder.
// ── Extract config before borrowing session_runtime mutably ────────────
// These clones avoid borrow conflicts when we later take &mut rt below.
let api_key = state
.settings
.api_keys
@@ -227,7 +318,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
.map(|p| p.api_base.clone());
let abort_flag = state.abort_flag.clone();
// Build the LLM client if we have a configured base_url.
// ── Build the LLM client if a base_url is configured ───────────────────
let llm_client = base_url.map(|url| {
let key = if api_key.is_empty() {
state
@@ -262,8 +353,10 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
return;
}
// ── Run compaction, capturing before/after token counts ──────────────
let (before_tokens, after_tokens, msg_count) =
if let Some(ref mut rt) = state.session_runtime {
// Estimate total tokens before compaction
let token_estimate: usize = rt
.messages
.iter()
@@ -272,6 +365,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
.sum();
let before = token_estimate;
// Run the actual compaction via shaping (summarises old messages)
rt.messages = crate::app::runtime::context::shaping::shape_messages(
&rt.messages,
token_estimate,
@@ -280,6 +374,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
llm_client.as_ref(),
Some(&*abort_flag),
);
// Estimate tokens after compaction
let after: usize = rt
.messages
.iter()
@@ -307,7 +402,13 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
state.dirty = true;
}
/// Accept a pending lesson (learned behaviour pattern) by name.
///
/// Flow: resolve the pending lesson with `accepted = true` → refresh lesson
/// counters → push success toast.
pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
debug!("handle_lesson_accept: {}", name);
// Resolve the pending lesson file (writes accepted=true metadata)
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir,
@@ -316,6 +417,7 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
true,
);
}
// Re-read on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt);
}
@@ -326,7 +428,13 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
state.dirty = true;
}
/// Reject a pending lesson by name — resolves it with `accepted = false`.
///
/// Flow: resolve the pending lesson with `accepted = false` → refresh lesson
/// counters → push info toast.
pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
debug!("handle_lesson_reject: {}", name);
// Resolve the pending lesson file (writes accepted=false metadata)
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir,
@@ -335,6 +443,7 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
false,
);
}
// Re-read on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt);
}
@@ -345,8 +454,16 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
state.dirty = true;
}
/// Delete a previously stored lesson by name — removes the underlying
/// memory file and refreshes counters.
///
/// Flow: delete the memory markdown file via the CMS repository → refresh
/// lesson counters from the remaining on-disk state → push info toast.
pub(super) fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
debug!("handle_lesson_delete: {}", name);
// Remove the memory file from disk via the CMS repository
let _ = MarkdownMemoryRepository::new().delete(&state.memory_dir, &name);
// Re-read remaining on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt);
}
@@ -1,5 +1,10 @@
//! I/O helper functions: session persistence, API connectivity checks,
//! and review-available notification.
//! I/O helper functions used by action handlers: session persistence, API
//! connectivity checks, and review-available notification toasts.
//!
//! These are deliberately kept separate from `handlers.rs` to keep handler
//! bodies short and to allow these helpers to be called from multiple places.
use tracing::debug;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
@@ -8,12 +13,16 @@ use zesdex_iam::domain::repository::SessionRepository;
/// 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.
/// Flow: build a `Session` object → save its metadata via
/// `FileSystemSessionRepository` → serialise `rt.messages` as JSON →
/// write to the conversation file. All errors are silently ignored so the
/// save is best-effort and non-blocking.
///
/// Why: called on `ForceQuit` so the session can be resumed later.
/// Why: called on `ForceQuit` so the session (including full message history)
/// can be resumed after a restart.
pub(super) fn save_current_session(state: &AppStateRest) {
debug!("save_current_session: session_id={}", state.session_id);
// Resolve the persistent store base directory (usually ~/.local/share/zesdex/)
let base = state.store_base_dir();
let session = zesdex_iam::domain::session::Session::new(
state.session_id.clone(),
@@ -21,8 +30,10 @@ pub(super) fn save_current_session(state: &AppStateRest) {
);
let session_repo =
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
// Save session metadata record (id, type, timestamps) to the repo directory
let _ = session_repo.save_session(&base, &session);
if let Some(ref rt) = state.session_runtime {
// Write the full message list as JSON to the conversation file
let conv_path = session.conversation_path(&base);
if let Ok(data) = serde_json::to_string(&rt.messages) {
let _ = std::fs::write(&conv_path, data);
@@ -40,9 +51,12 @@ pub(super) fn save_current_session(state: &AppStateRest) {
/// `should_trigger_review` on `Tick`), only informs the user that
/// a review has material to examine.
pub(super) fn maybe_trigger_review(state: &mut AppStateRest) {
debug!("maybe_trigger_review");
// Respect the user's review-disable toggle
if !state.settings.flags.review_enabled {
return;
}
// Only notify if there were actual edits this session
let edit_count = state
.session_runtime
.as_ref()
@@ -57,15 +71,18 @@ pub(super) fn maybe_trigger_review(state: &mut AppStateRest) {
}
/// Spawn a background thread that checks API reachability via a lightweight HEAD
/// request to `<base_url>/models`, pushing the result as a `SystemNote` so the
/// next `Tick` handler updates `api_connected`.
/// request to `<base_url>/chat/completions`, pushing the result as a `SystemNote`
/// so the next `Tick` handler updates `api_connected`.
///
/// Flow: resolve the provider's base URL → build a short-lived reqwest client
/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push
/// a `connectivity` `SystemNote` with the result.
/// Flow: resolve the provider's base URL → build a short-lived `reqwest` client
/// with 3 s connect / 5 s total timeout → HEAD the `/chat/completions` endpoint
/// → treat HTTP 200/401/403 as "connected", anything else as "disconnected" →
/// push a `connectivity` `SystemNote` with the boolean result.
///
/// Why: runs off the event loop so a slow/timed-out network does not block the TUI.
/// Why: runs off the event loop so a slow or timed-out network does not block the TUI.
pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
debug!("spawn_api_connectivity_check");
// Resolve the base URL from the configured provider, falling back to default
let base_url = state
.app_config
.providers
@@ -74,13 +91,17 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
|| crate::service::provider::DEFAULT_BASE_URL.to_string(),
|p| p.api_base.clone(),
);
// Clone the shared queue handle before moving into the background thread
let turn_events = state.turn_events.clone();
// Fire-and-forget: the blocking HTTP call runs on a background thread
// so a slow/timed-out network does not block the TUI event loop.
std::thread::spawn(move || {
// Build the health-check URL, removing any trailing slash from the base
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
let connected = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.connect_timeout(std::time::Duration::from_secs(3))
.timeout(std::time::Duration::from_secs(5)) // total request timeout
.connect_timeout(std::time::Duration::from_secs(3)) // TCP connect timeout
.build()
{
Ok(client) => match client.head(&url).send() {
@@ -89,10 +110,11 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
// 401/403 means the server is reachable (just auth is wrong)
s.is_success() || s.as_u16() == 401 || s.as_u16() == 403
}
Err(_) => false,
Err(_) => false, // Network error or timeout → disconnected
},
Err(_) => false,
Err(_) => false, // Client construction failed → disconnected
};
// Push result back via the shared turn-events queue for the next Tick
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "connectivity".to_string(),
@@ -1,5 +1,7 @@
//! Memory / lesson-counter helpers: refresh counters from on-disk data.
use tracing::debug;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
@@ -18,7 +20,12 @@ pub(super) fn refresh_lesson_counters(
memory_dir: &std::path::Path,
rt: &mut crate::app::state::runtime::SessionRuntime,
) {
debug!("refresh_lesson_counters: dir={:?}", memory_dir);
// Fetch all memory slugs from the directory listing
let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default();
// Reset all counters before recounting (avoid stale accumulation)
rt.lesson_count = 0;
rt.lessons_user = 0;
rt.lessons_feedback = 0;
@@ -27,22 +34,30 @@ pub(super) fn refresh_lesson_counters(
rt.lessons_active = 0;
rt.lessons_stale = 0;
rt.lessons_contradicted = 0;
// Iterate over every memory slug and classify it by kind + lifecycle
for name in &names {
if let Ok(mem) = MarkdownMemoryRepository::new().load(memory_dir, name) {
rt.lesson_count += 1;
// Classify by memory kind (user-defined, feedback, project, reference)
match mem.kind.as_str() {
"user" => rt.lessons_user += 1,
"feedback" => rt.lessons_feedback += 1,
"project" => rt.lessons_project += 1,
"reference" => rt.lessons_reference += 1,
_ => {}
_ => {} // Unknown kind — skip
}
// Classify by lifecycle stage (active, stale, contradicted)
match mem.lifecycle.as_str() {
"active" => rt.lessons_active += 1,
"stale" => rt.lessons_stale += 1,
"contradicted" => rt.lessons_contradicted += 1,
_ => {}
_ => {} // Unknown lifecycle — skip
}
}
// If the memory file was deleted between list() and load(),
// silently skip — no error noise needed.
}
}
@@ -2,7 +2,7 @@
//! chokepoint through which every key input, streaming event, and async
//! background-thread result mutates `AppStateRest`.
//!
//! Flow: controllers/subagent threads construct `Action` values → the event
//! 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
@@ -15,6 +15,17 @@
//! 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.
//!
//! Sub-modules:
//! - `handlers` — one handler function per `Action` variant (except `Tick`)
//! - `io` — I/O helpers (save transcript, trigger review) used by handlers
//! - `memory` — memory-file read/write operations
//! - `oauth` — OAuth device-code login flow
//! - `spawn` — spawning turns on background OS threads
//! - `tick` — the periodic `Tick` handler that drains `TurnEvent`s
//! - `turn` — the core agent-turn logic (LLM streaming, tool execution)
use tracing::debug;
mod handlers;
mod io;
@@ -27,7 +38,7 @@ mod turn;
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// A single, well-typed event in the app — produced by key input, the
/// 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`.
///
@@ -37,65 +48,101 @@ use crate::app::state::types::Overlay;
/// observable and cancellable from the UI.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay (Help, Settings, Mcp, Todo, Usage, etc.).
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event — carries the new column count.
Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s and runs side jobs.
Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept {
name: String,
},
/// Reject a lesson by name.
LessonReject {
name: String,
},
/// Delete a previously stored lesson by name.
LessonDelete {
name: String,
},
/// Start the OAuth device-code login flow for a named provider.
StartOAuth {
provider: String,
},
/// Open the inline file editor for `path`.
OpenEditor {
path: String,
},
/// Register a new MCP server by name and shell command.
McpAdd {
name: String,
command: String,
},
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
}
/// 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).
/// Flow: pattern-match the variant → delegate to the corresponding handler
/// function in `handlers` (or `tick::handle_tick` for `Tick`) → handler
/// mutates `state` (input buffer, scroll position, overlay, transcript,
/// runtime, toasts, dirty flag, etc.).
///
/// For `Tick`: also drains queued `TurnEvent`s from the shared queue and
/// runs 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.
/// need to know how to *produce* actions, not how to update state safely.
///
/// Return: nothing; `state` is mutated in place.
pub fn apply_action(state: &mut AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => handlers::handle_force_quit(state),
Action::QuitConfirm => handlers::handle_quit_confirm(state),
Action::Resize(w, _h) => handlers::handle_resize(state, w),
Action::Tick => tick::handle_tick(state),
// ── Input / editing ───────────────────────────────────────────
Action::SubmitInput(text) => handlers::handle_submit_input(state, text),
Action::DeleteChar => handlers::handle_delete_char(state),
Action::DeleteCharRight => handlers::handle_delete_char_right(state),
@@ -103,23 +150,30 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
Action::CursorRight => handlers::handle_cursor_right(state),
Action::HistoryUp => handlers::handle_history_up(state),
Action::HistoryDown => handlers::handle_history_down(state),
// ── Scroll / navigation ───────────────────────────────────────
Action::ScrollUp => handlers::handle_scroll_up(state),
Action::ScrollDown => handlers::handle_scroll_down(state),
Action::OpenOverlay(overlay) => handlers::handle_open_overlay(state, overlay),
Action::CloseOverlay => handlers::handle_close_overlay(state),
Action::SystemNote { kind: _kind, message } => handlers::handle_system_note(state, message),
Action::QuitConfirm => handlers::handle_quit_confirm(state),
Action::Resize(w, _h) => handlers::handle_resize(state, w),
Action::OpenEditor { path } => handlers::handle_open_editor(state, path),
Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command),
// ── System / info ─────────────────────────────────────────────
Action::SystemNote { kind: _kind, message } => {
handlers::handle_system_note(state, message)
}
Action::ModelList => handlers::handle_model_list(state),
Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider),
Action::AbortTurn => handlers::handle_abort_turn(state),
Action::Compact => handlers::handle_compact(state),
// ── Editor / MCP / OAuth ──────────────────────────────────────
Action::OpenEditor { path } => handlers::handle_open_editor(state, path),
Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command),
Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider),
// ── Lessons ───────────────────────────────────────────────────
Action::LessonAccept { name } => handlers::handle_lesson_accept(state, name),
Action::LessonReject { name } => handlers::handle_lesson_reject(state, name),
Action::LessonDelete { name } => handlers::handle_lesson_delete(state, name),
Action::Tick => tick::handle_tick(state),
}
}
@@ -129,24 +183,35 @@ mod tests {
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::SessionRuntime;
use crate::app::state::runtime::TurnEvent;
use tracing::info;
/// Verify that a `TurnEvent::SystemNote` with `kind == "hive_mind_converged"`
/// sets the `hive_mind_converged` flag on the session runtime after `Tick`.
///
/// Flow: create a fresh state → push a `hive_mind_converged` `TurnEvent`
/// onto the shared queue → apply `Tick` → assert the flag is now `true`.
#[test]
fn hive_mind_converged_system_note_sets_session_flag() {
info!("test: hive_mind_converged_system_note_sets_session_flag");
let tmp = std::env::temp_dir().join(format!("zesdex-actions-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let mut state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"));
state.session_runtime = Some(SessionRuntime::new(tmp.clone()));
// Verify the flag starts as false
assert!(!state.session_runtime.as_ref().unwrap().hive_mind_converged);
// Push a hive_mind_converged system note onto the turn-event queue
if let Ok(mut q) = state.turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(),
message: String::new(),
});
}
// Tick drains the queue and processes the note
apply_action(&mut state, Action::Tick);
// Verify the flag is now set
assert!(state.session_runtime.as_ref().unwrap().hive_mind_converged);
std::fs::remove_dir_all(&tmp).ok();
@@ -1,5 +1,7 @@
//! OAuth PKCE flow — browser-based login for API providers.
use tracing::{info, warn};
/// Run a browser-based OAuth PKCE flow for the given provider.
///
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
@@ -15,6 +17,7 @@
/// Return: a success message on completion, or an error if the flow fails
/// at any step.
pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
info!(provider = provider, "starting OAuth flow");
use zesdex_iam::domain::oauth::OAuthConfig;
use zesdex_iam::domain::service::OAuthService;
use zesdex_iam::application::oauth_service::OAuthServiceImpl;
@@ -86,20 +89,22 @@ pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
let (auth_url, state) = oauth_service.start_flow(&config, &redirect_uri)?;
if auth_url.is_empty() {
tracing::warn!("[oauth] auth_url was empty for provider '{}'", provider);
warn!("OAuth auth_url was empty for provider '{}'", provider);
} else if webbrowser::open(&auth_url).is_err() {
tracing::warn!(
"[oauth] could not open browser for '{}'; user must open URL manually:\n{}",
warn!(
"OAuth could not open browser for '{}'; user must open URL manually:\n{}",
provider,
auth_url
);
}
info!(provider = provider, "waiting for OAuth redirect");
let code = server.wait_for_code(120_000, &state)?;
oauth_service
.complete_flow(&config, &redirect_uri, &code, &state)
.map_err(|e| anyhow::anyhow!("{e}"))?;
info!(provider = provider, "OAuth flow completed");
Ok(format!("Successfully authenticated with {provider}."))
}
@@ -1,8 +1,16 @@
//! Turn-spawning logic: `spawn_turn` and the `TurnCtx` bundle passed to
//! Turn-spawning logic: `spawn_turn` and the [`TurnCtx`] bundle passed to
//! the background thread that runs `run_agent_turn`.
//!
//! Flow: `spawn_turn` collects messages, config, API key, and tools from
//! `AppStateRest` → builds a [`TurnCtx`] → spawns a plain OS thread that
//! calls `run_agent_turn` → drains errors into `TurnEvent::Error`.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use tracing::{error, info};
use super::turn::run_agent_turn;
@@ -41,6 +49,7 @@ pub(super) struct TurnCtx {
///
/// Return: nothing; results flow through `state.turn_events`.
pub(super) fn spawn_turn(state: &AppStateRest) {
info!("spawn_turn: starting new turn");
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard
} else {
@@ -110,14 +119,14 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
let in_flight_flag = state.turn_in_flight.clone();
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
let abort_flag = state.abort_flag.clone();
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
abort_flag.store(false, Ordering::SeqCst);
let hive_mind_converged = state
.session_runtime
.as_ref()
.is_some_and(|rt| rt.hive_mind_converged);
*in_flight_flag.lock().unwrap_or_else(|e| {
tracing::error!("[spawn_turn] in_flight_flag mutex poisoned: {}", e);
error!("spawn_turn: in_flight_flag mutex poisoned: {}", e);
e.into_inner()
}) = true;
@@ -126,7 +135,7 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
std::thread::spawn(move || {
let db = crate::model::msglog::open_or_create(&edit_session_dir)
.ok()
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
.map(|c| Arc::new(std::sync::Mutex::new(c)));
let tc = TurnCtx {
client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url),
tdefs: tool_defs,
@@ -145,10 +154,12 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
};
let result = run_agent_turn(&tc, &messages, &events_q);
if let Err(e) = result {
info!("spawn_turn: turn returned error: {}", e);
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error(e.to_string()));
}
}
info!("spawn_turn: turn completed");
if let Ok(mut flag) = in_flight_flag.lock() {
*flag = false;
}
@@ -1,6 +1,14 @@
//! Tick-action handler: drain turn events, LSP provision messages,
//! API connectivity checks, staleness sweep, pending lessons, and
//! todo.md polling.
//!
//! Flow: `handle_tick` is called from the event loop on each cycle.
//! It drains the `turn_events` queue (driving the transcript cache and
//! session runtime), drains `lsp_provision_msgs` into toasts, runs
//! background maintenance (todo.md poll, API connectivity, staleness
//! sweep, lessons), and flags `state.dirty` when something changed.
use tracing::debug;
use crate::app::review::{should_trigger_review, trigger_review};
use crate::app::state::rest::AppStateRest;
@@ -14,11 +22,24 @@ use super::turn::HIVE_MIND_KICKOFF_NOTE;
/// Handle `Action::Tick` — the periodic event that drains async results
/// and runs background maintenance tasks.
///
/// Flow:
/// 1. Bump tick counter, drain expired toasts
/// 2. Every 10 ticks: poll `todo.md` for external changes
/// 3. Every N ticks: `spawn_api_connectivity_check` (N=20 when disconnected, 600 when connected)
/// 4. Run staleness sweep and process pending lessons
/// 5. Drain `lsp_provision_msgs` into toasts
/// 6. Drain `turn_events` queue, dispatching each variant to state mutation
/// 7. If turn finished, trigger optional review
pub(super) fn handle_tick(state: &mut AppStateRest) {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
let tick = state.misc.tick_count;
debug!(tick = tick, "handle_tick");
let now_ms = chrono::Utc::now().timestamp_millis();
// Remove expired toasts from the display stack.
state.misc.drain_expired_toasts(now_ms);
// Poll todo.md every 10 ticks (~1 s) for external edits.
if state.misc.tick_count.is_multiple_of(10) {
let todo_path = state.session_dir.join("todo.md");
if let Ok(content) = std::fs::read_to_string(&todo_path) {
@@ -35,6 +56,7 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
// Background API connectivity check — runs on a background thread
// every ~1s while disconnected, every ~30s while connected, so the
// status bar reflects real API availability without user input.
// Poll interval: every ~2 s when disconnected (20 ticks), ~60 s when connected (600 ticks).
let check_interval = if state.misc.api_connected { 600 } else { 20 };
if state.misc.tick_count.is_multiple_of(check_interval) {
spawn_api_connectivity_check(state);
@@ -68,6 +90,9 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
state.push_toast(Toast::new(kind, msg.clone()));
}
// Drain the background-thread turn events queue. Each variant maps to
// state mutations — transcript cache updates, session runtime messages,
// toast notifications, and workflow engine agent roster changes.
let events: Vec<TurnEvent> = {
if let Ok(mut q) = state.turn_events.lock() {
q.drain(..).collect()
@@ -370,9 +395,11 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
}
}
}
// If a turn just completed, trigger the optional inline review flow.
if turn_finished {
maybe_trigger_review(state);
}
// Ensure state is marked dirty if anything changed this cycle.
if turn_finished || state.dirty {
state.dirty = true;
}
@@ -314,6 +314,11 @@ pub(super) fn run_agent_turn(
let mut todo_retry_count = 0usize;
tracing::debug!(
"[turn] entering main agent loop — max todo retries: {}",
MAX_TODO_RETRIES,
);
loop {
let token_estimate: usize = msgs
.iter()
@@ -493,6 +498,9 @@ pub(super) fn run_agent_turn(
archive_message(tc.db.as_ref(), &tc.session_id, &response);
msgs.push(response);
let mut results_vec = Vec::new();
// Execute all tool calls in parallel using std::thread::scope,
// which guarantees all spawned threads complete before the
// closure returns — no manual join needed.
std::thread::scope(|s| {
let mut handles = Vec::new();
let tc_ref = tc;
@@ -725,6 +733,11 @@ pub(super) fn run_agent_turn(
}
}
tracing::debug!(
"[turn] agent turn completed — total edits this turn: {}",
total_edits_this_turn.as_ref().map_or(0, |(c, _, _)| *c),
);
push_event(&events_q, TurnEvent::Done);
Ok(())
@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Cross-call tool-result deduplication: when a read-only tool is called
//! again with identical arguments, the earlier result is replaced with a
//! placeholder so only the latest copy occupies context.
@@ -19,6 +18,7 @@ use crate::app::subagent::division::tool_scope::READ_TOOLS;
use crate::dto::chat::message::{ChatMessage, Role};
use sha2::Digest;
use std::collections::HashMap;
use tracing;
const DUPLICATE_PLACEHOLDER: &str =
"[duplicate result — superseded by a later identical call, see below]";
@@ -29,7 +29,15 @@ const DUPLICATE_PLACEHOLDER: &str =
/// `true` iff at least one entry was replaced. The caller uses the
/// `bool` to decide whether the result is worth persisting/announcing,
/// without `ChatMessage` needing to implement `PartialEq`.
///
/// # Status
///
/// This function is defined but not yet wired into the compaction loop;
/// it will be called from the per-turn auto-compaction pass once the
/// shaping integration is complete.
#[expect(dead_code, reason = "will be wired into the compaction loop")]
pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
tracing::debug!(n_messages = messages.len(), "dedup::collapse — start");
// tool_call_id -> (tool name, canonical JSON of its arguments)
let mut call_info: HashMap<String, (String, String)> = HashMap::new();
for m in messages {
@@ -84,6 +92,7 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
})
.collect();
tracing::debug!(changed, "dedup::collapse — done");
(result, changed)
}
@@ -101,6 +110,9 @@ fn dedup_key(tool_name: &str, canonical_args: &str) -> String {
#[cfg(test)]
mod tests {
//! Unit tests for tool-result dedup: identical read-tool calls are
//! collapsed, different args / mutating tools are left untouched,
//! and orphaned tool results pass through unchanged.
use super::*;
use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction};
@@ -2,6 +2,18 @@
//! per-result compression, budget-based shaping, and shared
//! context-window resolution — replaces `runtime::shortsend`.
//!
//! # Sub-modules
//!
//! | Module | Responsibility |
//! |------------|----------------------------------------------------------|
//! | `dedup` | Cross-call deduplication of repeated tool results |
//! | `shaping` | Budget-based message shaping within the context window |
//! | `squash` | Per-result compression (summarisation / truncation) |
//! | `tokens` | Token counting and estimation |
//! | `window` | Resolve the active model's context-window size |
//!
//! # Call-sites
//!
//! No facade function here: `dedup`, `shaping`, and `tokens` are called
//! directly from each call site (the per-turn auto-compaction loop in
//! `actions::run_agent_turn`, and `Action::Compact`), matching this
@@ -68,6 +68,10 @@ const FORCE_KEEP_MAX: usize = 15;
const SUMMARY_PREFIX: &str = "[Summary of compacted prior conversation:";
/// Detect whether a message contains a previous compaction summary.
///
/// Flow: check if message `content` starts with [`SUMMARY_PREFIX`].
/// Used to filter out old summaries from the "dropped" set so they
/// are handled by progressive summarization instead.
fn msg_has_prior_summary(m: &ChatMessage) -> bool {
m.content
.as_deref()
@@ -77,6 +81,13 @@ fn msg_has_prior_summary(m: &ChatMessage) -> bool {
/// Format dropped messages for the summarization prompt, excluding any
/// messages that are themselves previous summaries (those are handled
/// separately by progressive summarization).
///
/// Flow: filter out prior-summary messages → for each remaining message,
/// render a `[Role]: content` line with optional tool-call list appended.
/// Join entries with `\n\n---\n\n` as separator.
///
/// Return: a single string suitable as the `### New messages to merge`
/// section of the summarization prompt.
fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
dropped
.iter()
@@ -106,6 +117,13 @@ fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
}
/// Extract the content of a previous compaction summary from a message.
///
/// Flow: check if `content` starts with [`SUMMARY_PREFIX`] → strip prefix
/// and trailing `]` → return inner text. Returns `None` if the message
/// is not a prior-summary message.
///
/// Why: progressive summarization needs the old summary text so the LLM
/// can merge it with new context instead of starting from scratch.
fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
let content = m.content.as_deref()?;
if content.starts_with(SUMMARY_PREFIX) {
@@ -124,6 +142,13 @@ fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
/// Build the summarization prompt, supporting progressive compaction:
/// if the dropped messages contain a previous summary, it is extracted
/// and the new prompt asks the LLM to build on it.
///
/// Flow: search dropped messages for a prior summary via `extract_prior_summary`.
/// If found, emit a "build on this" prompt with the previous summary + new
/// content. Otherwise emit a plain "summarize this history" prompt.
/// In both cases the prompt requests a structured 5-section summary.
///
/// Return: a fully-formed user-style prompt string ready to send to the LLM.
fn build_summarization_prompt(
dropped_msgs: &[ChatMessage],
dropped_content: &str,
@@ -174,6 +199,14 @@ fn build_summarization_prompt(
/// `[prior conversation compacted]` placeholder — it tells the LLM how
/// many messages of each role were dropped and what tools were used,
/// preserving key structural context.
///
/// Flow: count messages by role → collect unique tool names → extract
/// the last user message as a hint → format as:
/// `[prior conversation: N user, M assistant, ... | tools used: ... | last request: ...]`
///
/// Why: a static placeholder provides zero useful context. Even without
/// AI summarization, structural metadata helps the LLM understand what
/// was lost.
fn make_structural_summary(dropped: &[ChatMessage]) -> String {
use std::fmt::Write;
@@ -244,11 +277,22 @@ pub fn shape_messages(
client: Option<&crate::service::provider::LlmClient>,
abort_flag: Option<&AtomicBool>,
) -> Vec<ChatMessage> {
tracing::debug!(
n_messages = messages.len(),
token_count,
max_wire_tokens,
force,
has_client = client.is_some(),
"shape_messages — entry"
);
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
tracing::debug!("shape_messages — under budget or too few messages, no-op");
return messages.to_vec();
}
if force && messages.len() < 5 {
tracing::debug!("shape_messages — force but fewer than 5 messages, no-op");
return messages.to_vec();
}
@@ -352,10 +396,12 @@ pub fn shape_messages(
}
}
} else {
tracing::debug!("shape_messages — summarization aborted by user, using structural summary");
make_structural_summary(&dropped_msgs)
}
} else {
// No LLM client available (tests / edge case with no provider).
tracing::debug!("shape_messages — no LLM client, using structural summary");
make_structural_summary(&dropped_msgs)
};
@@ -363,11 +409,19 @@ pub fn shape_messages(
}
result.extend(keep_recent.into_iter().rev());
tracing::debug!(
result_len = result.len(),
dropped = dropped_msgs.len(),
"shape_messages — done"
);
result
}
#[cfg(test)]
mod tests {
//! Unit tests for message shaping: threshold hysteresis, system-message
//! preservation, structural-summary fallback, and most-recent survival.
use super::*;
use crate::dto::chat::message::ChatMessage;
@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Per-tool-result compression: shrink large tool outputs before they
//! ever enter conversation history, dispatching by content shape.
//!
@@ -12,6 +11,7 @@
//! overall budget.
use std::collections::HashSet;
use std::fmt::Write;
use tracing;
/// Below this size, compression isn't worth the risk of losing detail —
/// pass the output through unchanged.
@@ -46,16 +46,28 @@ const LOG_SHAPED_TOOLS: &[&str] = &["bash"];
/// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at
/// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from
/// whichever detector matches its content shape.
///
/// # Status
///
/// Defined but not yet wired into the tool-execution pipeline; will be
/// called from `tool::shell` and MCP result handlers once integration
/// is complete.
#[expect(dead_code, reason = "will be wired into the tool-execution pipeline")]
pub fn apply(tool_name: &str, output: &str) -> String {
tracing::trace!(tool_name, output_len = output.len(), "squash::apply — start");
if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES {
tracing::trace!(tool_name, "squash::apply — passthrough (never-squash tool or under floor)");
return output.to_string();
}
if serde_json::from_str::<serde_json::Value>(output).is_ok() {
tracing::trace!(tool_name, "squash::apply — routing to squash_json");
return squash_json(output);
}
if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(output) {
tracing::trace!(tool_name, "squash::apply — routing to squash_log");
return squash_log(output);
}
tracing::trace!(tool_name, "squash::apply — routing to squash_generic");
squash_generic(output, GENERIC_BUDGET_BYTES)
}
@@ -287,6 +299,16 @@ fn squash_generic(text: &str, budget: usize) -> String {
/// Render a subset of `lines` in order, inserting a `[N lines omitted]`
/// marker at every gap between kept lines.
///
/// Flow: sort kept indices → iterate; for each kept line, if a gap
/// exists before it write `[N lines omitted]`, then write the line.
/// After all kept lines, write a final omission marker if lines remain.
///
/// Why `[N lines omitted]` instead of a comment-shaped marker: the
/// `rtk` project's own regression tests found that comment shapes get
/// parsed by the LLM as code and trigger a retry loop.
///
/// Return: rendered string with kept lines in original order.
fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
kept_sorted.sort_unstable();
@@ -309,6 +331,9 @@ fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
#[cfg(test)]
mod tests {
//! Unit tests for tool-result squashing: floor threshold, read-tool
//! exemption, JSON structure preservation, log compression, and
//! generic truncation with head/tail retention.
use super::*;
#[test]
@@ -11,6 +11,7 @@
//! closer than a flat byte-per-token guess; it's only used for the
//! 85%/95% budget thresholds, not for billing-accurate counts.
use tracing;
/// Count tokens in a single string under `o200k_base`.
///
@@ -20,17 +21,23 @@
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
/// as ordinary text, not interpreted as a control token.
pub fn count_tokens(text: &str) -> usize {
tiktoken_rs::o200k_base_singleton()
let count = tiktoken_rs::o200k_base_singleton()
.encode_ordinary(text)
.len()
.len();
tracing::trace!(len = text.len(), count, "count_tokens");
count
}
#[cfg(test)]
mod tests {
//! Unit tests for token counting: empty strings, known phrases, code,
//! and ChatMessage content extraction.
use super::*;
use crate::dto::chat::message::ChatMessage;
/// Count tokens in a `ChatMessage`'s text content.
///
/// Returns 0 when the message has no content (None).
fn count_message_tokens(msg: &ChatMessage) -> usize {
msg.content.as_deref().map_or(0, count_tokens)
}
@@ -1,10 +1,10 @@
#![allow(dead_code)]
//! Single source of truth for resolving the active model's context
//! window size, replacing three copies of the same lookup that had
//! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each
//! had their own inline version — the status bar's copy additionally
//! displayed "?" on no match instead of falling back like the other two,
//! an inconsistency this unifies away).
use tracing::debug;
use zesdex_cms::domain::app_config::AppConfig;
use zesdex_cms::domain::settings::Settings;
@@ -15,14 +15,31 @@ use zesdex_cms::domain::settings::Settings;
/// `settings` -> use its `context_window` if set -> otherwise fall back
/// to `app_config.default_context_window`.
///
/// # Tracing
/// Outputs a `tracing::debug!` event with the resolved token count and
/// matching role name (or "fallback") at each call site.
///
/// Return: always a concrete token count, never "unknown".
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
app_config
.model_roles
.values()
.find(|role| role.provider == settings.provider && role.model == settings.model)
// Search model roles for one matching the active provider + model pair
let matched = app_config.model_roles.values().find(|role| {
role.provider == settings.provider && role.model == settings.model
});
// Use the role's explicit context_window, or fall back to the default
let tokens: usize = matched
.and_then(|role| role.context_window)
.unwrap_or(app_config.default_context_window) as usize
.unwrap_or(app_config.default_context_window) as usize;
debug!(
provider = %settings.provider,
model = %settings.model,
tokens,
source = if matched.is_some() { "model_role" } else { "default_fallback" },
"resolved context-window size",
);
tokens
}
#[cfg(test)]
@@ -1,9 +1,15 @@
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
//! after any activity, then slows down to conserve CPU.
//!
//! Flow: `mark_active()` sets a fast-poll deadline; `poll_interval()`
//! checks if the deadline is still in the future and returns either
//! `FAST_POLL_MS` (8 ms) or `SLOW_POLL_MS` (100 ms). `is_idle()` reports
//! whether the deadline has expired.
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::app::state::runtime::TurnEvent;
use tracing;
const FAST_POLL_MS: u64 = 8;
const SLOW_POLL_MS: u64 = 100;
@@ -40,9 +46,17 @@ impl EventLoop {
/// Mark the current time as the last activity and arm the fast-poll
/// window for the next `IDLE_THRESHOLD_MS`.
///
/// Called by the event loop whenever a TurnEvent arrives, keeping the
/// UI responsive during bursts of activity.
pub fn mark_active(&mut self) {
self.last_activity = Instant::now();
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS));
let deadline = Duration::from_millis(IDLE_THRESHOLD_MS);
self.fast_poll_until = Some(Instant::now() + deadline);
tracing::debug!(
"[event-loop] marked active — fast-poll armed for next {}ms",
IDLE_THRESHOLD_MS,
);
}
/// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`.
@@ -52,11 +66,24 @@ impl EventLoop {
/// Drain all pending `TurnEvent`s from the shared mutex queue.
///
/// Flow: acquire the mutex lock → drain the VecDeque into a Vec → release.
/// Returns an empty Vec if the lock is poisoned.
///
/// 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> {
events.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
let drained: Vec<TurnEvent> = events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
if !drained.is_empty() {
tracing::debug!(
"[event-loop] drained {} event(s)",
drained.len(),
);
}
drained
}
}
+6 -3
View File
@@ -3,6 +3,7 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tracing;
use super::state::runtime::TurnEvent;
@@ -17,10 +18,12 @@ pub mod stream;
/// errors inline. Used by the 20+ locations in `actions/turn.rs` that push
/// events and want to skip the boilerplate.
pub fn push_event(
q: &Arc<Mutex<VecDeque<TurnEvent>>>,
event: TurnEvent,
q: &Arc<Mutex<VecDeque<TurnEvent>>>, // shared turn-event queue (locked on access)
event: TurnEvent, // event to enqueue
) {
tracing::debug!("pushing turn event");
// Silently ignores a poisoned mutex so callers never have to handle lock errors
if let Ok(mut guard) = q.lock() {
guard.push_back(event);
guard.push_back(event); // enqueue at the back for FIFO processing
}
}
@@ -5,9 +5,17 @@
//! we want tools to receive whatever arguments were already emitted so the
//! partial work can proceed.
//!
//! How: a single left-to-right scan pushes opening brackets/braces onto a
//! stack and pops them on matching closes, while tracking in-string/escape
//! state. At EOF, the algorithm:
//! 1. Removes a dangling escape backslash if present.
//! 2. Closes an unterminated string.
//! 3. Closes every unclosed bracket/brace in reverse (LIFO) order.
//!
//! Why LIFO vs. depth counters: `{` inside `[` must be closed with `}`
//! *before* the `]`, not after it. Simple depth counters get the order
//! wrong for nested heterogenous structures.
use tracing;
/// Try to repair truncated JSON by closing open strings, braces, and brackets.
///
@@ -17,17 +25,24 @@
/// At the end, if the last char was a backslash (start of an escape
/// sequence), remove it; if inside a string, append `"`; then close
/// every unclosed opener in reverse (LIFO) order.
///
/// Return: the input string with any missing closing delimiters appended.
/// If the input is already valid JSON, it is returned unchanged.
pub fn repair_incomplete_json(s: &str) -> String {
let original_len = s.len();
tracing::trace!(original_len, input_preview = &s[..original_len.min(80)], "repair_incomplete_json — start");
// LIFO stack of open brackets/braces encountered outside strings.
let mut stack: Vec<char> = Vec::new();
let mut in_string = false;
let mut in_string = false; // true between unescaped `"`
let mut prev_was_backslash = false;
// `true` only when the very last character consumed was a bare `\`
// True only when the very last character consumed was a bare `\`
// inside a string (i.e. the start of an escape that was never completed).
let mut ends_with_unclosed_escape = false;
for c in s.chars() {
if prev_was_backslash {
// Consume the character that was being escaped — the escape is
// This character is being escaped — the escape sequence is
// complete, so clear the unclosed-escape flag.
prev_was_backslash = false;
ends_with_unclosed_escape = false;
@@ -44,26 +59,33 @@ pub fn repair_incomplete_json(s: &str) -> String {
continue;
}
if in_string {
continue;
continue; // skip structural chars inside a string
}
match c {
'{' | '[' => stack.push(c),
'}' | ']' => {
// Pop the matching opener unconditionally. If the JSON is
// malformed (e.g. mismatched brackets), we still pop to keep
// the LIFO tracking as lossy — the repair phase will close
// whatever remains on the stack, which is good enough for
// our heuristic use case.
stack.pop();
}
_ => {}
}
}
// --- Build repaired output ---
let mut result = s.to_string();
if ends_with_unclosed_escape {
// The last character is a dangling backslash that started an escape
// The last char is a dangling backslash that started an escape
// but got cut off before the escaped char — remove it.
result.pop();
}
if in_string {
result.push('"');
result.push('"'); // close an unterminated string
}
// Close every unclosed bracket/brace in reverse (LIFO) order.
for &opener in stack.iter().rev() {
match opener {
'{' => result.push('}'),
@@ -71,11 +93,21 @@ pub fn repair_incomplete_json(s: &str) -> String {
_ => {}
}
}
let repaired_len = result.len();
tracing::debug!(
original_len,
repaired_len,
added_chars = (repaired_len - original_len),
"repair_incomplete_json — completed"
);
result
}
#[cfg(test)]
mod tests {
//! Unit tests for JSON repair: unclosed strings, unclosed braces,
//! nested structures, trailing backslashes, and escaped quotes.
use super::*;
#[test]
@@ -1,6 +1,15 @@
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
/// JSON-repair utilities for malformed streaming fragments (truncated JSON,
/// missing brackets, escaped newlines inside strings).
pub mod json_repair;
/// Turn-level streaming state machine: manages buffering, SSE parsing,
/// tool-call accumulation, and per-chunk event dispatch.
pub mod turn;
/// Re-export from `zesdex-entities` for convenience:
/// - `SseParser` — low-level SSE line/event parser
/// - `StreamEvent` — typed event variants yielded by the parser
pub use zesdex_entities::{SseParser, StreamEvent};
@@ -1,12 +1,20 @@
//! Accumulates streaming LLM responses into complete message/tool-call
//! representation via `StreamedTurn`, and provides a standalone tool-call
//! accumulator in `tools::ToolCallAccumulator`.
//!
//! Flow: the caller feeds [`StreamEvent`] items (from `SseParser`) one by
//! one into [`StreamedTurn::apply_event`], which builds up content, reasoning,
//! and tool-call deltas incrementally. When the stream ends, call
//! [`StreamedTurn::build_assistant_message`] to produce a complete
//! `ChatMessage`. If the connection drops before `[DONE]`,
//! [`StreamedTurn::incomplete_tool_call`] detects truncated tool-call JSON.
use super::json_repair::repair_incomplete_json;
use super::StreamEvent;
use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing;
/// Accumulates a single streaming assistant turn into its final
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
@@ -29,33 +37,46 @@ pub struct ParsedToolCall {
pub is_complete: bool,
}
impl ParsedToolCall {}
impl ParsedToolCall {
// Placeholder for future convenience constructors or helpers.
// Today all field mutation happens inside `StreamedTurn::apply_event`;
// this block exists to reserve the namespace.
}
impl StreamedTurn {
/// Create an empty turn accumulator.
///
/// All fields start at their default (empty / false) state. The caller
/// then feeds [`StreamEvent`] items via [`apply_event`](Self::apply_event).
pub fn new() -> Self {
tracing::debug!("StreamedTurn::new — initialised empty accumulator");
StreamedTurn {
messages: Vec::new(),
tool_calls: Vec::new(),
is_complete: false,
done_received: false,
accumulated_content: String::new(),
accumulated_reasoning: String::new(),
is_complete: false, // set to true when [DONE] is received
done_received: false, // tracks whether a Done event was seen
accumulated_content: String::new(), // text tokens, growing
accumulated_reasoning: String::new(), // reasoning tokens, growing
}
}
/// Apply a `StreamEvent` to the turn, updating accumulated content,
/// reasoning, and tool-call deltas.
/// Apply a single `StreamEvent` to the in-progress accumulation.
///
/// 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`.
///
/// Any event variant not explicitly handled here (e.g. `Usage`) is
/// silently ignored, since only content/reasoning/tool-call state
/// is relevant for final message construction.
pub fn apply_event(&mut self, event: &StreamEvent) {
match event {
StreamEvent::Token(token) => {
tracing::trace!(len = token.len(), "apply_event: Token");
self.accumulated_content.push_str(token);
}
StreamEvent::Reasoning(reasoning) => {
tracing::trace!(len = reasoning.len(), "apply_event: Reasoning");
self.accumulated_reasoning.push_str(reasoning);
}
StreamEvent::ToolCallDelta {
@@ -64,6 +85,11 @@ impl StreamedTurn {
name,
arguments_delta,
} => {
tracing::trace!(
index, id, name, delta_len = arguments_delta.len(),
"apply_event: ToolCallDelta"
);
// Pad the tool_calls vector with stubs so we can index by `index`.
while self.tool_calls.len() <= *index {
self.tool_calls.push(ParsedToolCall {
id: String::new(),
@@ -73,6 +99,8 @@ impl StreamedTurn {
});
}
let tc = &mut self.tool_calls[*index];
// `id` and `name` are typically sent only on the first delta;
// subsequent deltas for the same index may omit them.
if let Some(new_id) = id {
if !new_id.is_empty() {
tc.id.clone_from(new_id);
@@ -83,12 +111,16 @@ impl StreamedTurn {
tc.name.clone_from(new_name);
}
}
// Accumulate argument JSON fragment-by-fragment.
tc.arguments.push_str(arguments_delta);
}
StreamEvent::Done => {
tracing::debug!("apply_event: Done — turn marked complete");
self.is_complete = true;
}
_ => {}
_ => {
tracing::trace!("apply_event: ignored {:?}", event);
}
}
}
@@ -101,42 +133,57 @@ impl StreamedTurn {
///
/// Return: a complete `ChatMessage` with role `Assistant`.
pub fn build_assistant_message(&self) -> ChatMessage {
tracing::debug!(
tool_calls = self.tool_calls.len(),
content_len = self.accumulated_content.len(),
reasoning_len = self.accumulated_reasoning.len(),
"build_assistant_message — assembling final ChatMessage"
);
// Build the assistant message, with or without tool calls.
let mut msg = if self.tool_calls.is_empty() {
// Plain text-only response — no tool calls to attach.
ChatMessage::assistant(None)
} else {
// Convert ParsedToolCall → DTO ToolCall, repairing truncated JSON.
let tool_dtos: Vec<ToolCall> = self
.tool_calls
.iter()
.filter(|tc| !tc.name.is_empty())
.filter(|tc| !tc.name.is_empty()) // skip unnamed stubs
.map(|tc| {
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
Ok(v) => v,
Err(e) => {
let repaired = repair_incomplete_json(&tc.arguments);
match serde_json::from_str(&repaired) {
Ok(v) => {
tracing::warn!(
"[stream] tool call '{}' had truncated JSON \
arguments repaired successfully: {}",
tc.name,
e,
);
v
}
Err(e2) => {
tracing::warn!(
"[stream] tool call '{}' has invalid JSON \
arguments: {} (after repair: {}) falling \
back to raw string",
tc.name,
e,
e2,
);
serde_json::Value::String(tc.arguments.clone())
// Try to parse arguments as JSON. If the stream was cut
// short, the last tool call's arguments may be truncated.
let args_value: serde_json::Value =
match serde_json::from_str(&tc.arguments) {
Ok(v) => v,
Err(e) => {
let repaired = repair_incomplete_json(&tc.arguments);
match serde_json::from_str(&repaired) {
Ok(v) => {
tracing::warn!(
"[stream] tool call '{}' had truncated JSON \
arguments repaired successfully: {}",
tc.name,
e,
);
v
}
Err(e2) => {
tracing::warn!(
"[stream] tool call '{}' has invalid JSON \
arguments: {} (after repair: {}) falling \
back to raw string",
tc.name,
e,
e2,
);
// Last resort: store the raw string so the
// tool dispatcher can surface the error.
serde_json::Value::String(tc.arguments.clone())
}
}
}
}
};
};
ToolCall {
id: tc.id.clone(),
type_: "function".to_string(),
@@ -153,6 +200,8 @@ impl StreamedTurn {
}
msg
};
// Combine reasoning (inside <think> tags) with visible content.
let full_content = if self.accumulated_reasoning.is_empty() {
self.accumulated_content.clone()
} else {
@@ -161,12 +210,15 @@ impl StreamedTurn {
self.accumulated_reasoning, self.accumulated_content
)
};
let content = if full_content.is_empty() {
// Set content to None when empty so downstream code can distinguish
// "no content" from "empty string".
msg.content = if full_content.is_empty() {
tracing::debug!("build_assistant_message — no content after assembly; setting content=None");
None
} else {
Some(full_content)
};
msg.content = content;
msg
}
@@ -183,25 +235,43 @@ impl StreamedTurn {
/// Return: `Some((name, parse_error))` for the first bad tool call, or
/// `None` if every tool call's arguments are complete, parsable JSON.
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
self.tool_calls
// Skip unnamed stubs — they indicate the stream never sent enough
// data to begin a real tool call at that index.
let result = self
.tool_calls
.iter()
.filter(|tc| !tc.name.is_empty())
.find_map(|tc| {
serde_json::from_str::<Value>(&tc.arguments)
.err()
.map(|e| (tc.name.as_str(), e.to_string()))
})
});
if let Some((name, ref err)) = result {
tracing::debug!(
tool_name = name, error = err.as_str(),
"incomplete_tool_call — found truncated tool arguments"
);
} else {
tracing::trace!("incomplete_tool_call — all tool calls have valid JSON");
}
result
}
}
impl Default for StreamedTurn {
/// Delegates to [`Self::new`]; exists so `StreamedTurn` can be used
/// as a default field value in other structs.
fn default() -> Self {
tracing::debug!("StreamedTurn::default — delegating to StreamedTurn::new");
Self::new()
}
}
#[cfg(test)]
mod tests {
//! Unit tests for streaming-turn accumulation, including JSON-repair
//! of truncated tool-call arguments and `incomplete_tool_call` detection.
use super::*;
fn tool_call(name: &str, arguments: &str) -> ParsedToolCall {
@@ -1,6 +1,15 @@
//! Shallow state diffing — records opaque "modified" markers so the TUI
//! knows to re-render without computing fine-grained deltas.
//!
//! # Interaction with the render loop
//!
//! The TUI render loop calls [`clear`] at the end of every frame and
//! action handlers call [`add_change`] for each mutation they perform.
//! Because the viewport is fully re-validated each frame, the individual
//! `path` and `kind` fields are currently always set to `"."` and
//! `"modified"` respectively — the diff acts as a simple dirty flag.
use serde::{Deserialize, Serialize};
use tracing::debug;
/// A collection of changes tracking which parts of app state have been
/// modified since the last render sweep.
@@ -25,6 +34,7 @@ impl StateDiff {
/// Record a change at `path` of the given `kind`.
pub fn add_change(&mut self, path: String, kind: String) {
debug!(%path, %kind, "state diff: change recorded");
self.changes.push(Change { path, kind });
}
@@ -35,7 +45,11 @@ impl StateDiff {
/// Remove all recorded changes.
pub fn clear(&mut self) {
let n = self.changes.len();
self.changes.clear();
if n > 0 {
debug!(cleared = n, "state diff: cleared");
}
}
}
@@ -49,9 +63,11 @@ impl StateDiff {
///
/// Return: the list of changes (always 0 or 1 entry).
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
// Short-circuit: no allocation when nothing changed
if before == after {
return Vec::new();
}
debug!("state diff: value changed");
vec![Change {
path: ".".to_string(),
kind: "modified".to_string(),
+90 -5
View File
@@ -1,14 +1,46 @@
//! Input buffer, cursor, history, and autocomplete state for the chat prompt.
//!
//! Owned by [`AppStateRest`](super::rest::AppStateRest) and mutated on every
//! keystroke from `controller/input.rs`. Contains:
//! - The raw input buffer and cursor position
//! - Navigable history (up/down arrows) with per-project persistence
//! - `/command` autocomplete (Tab key) against a builtin command list
//! - `@file` mention autocomplete (nucleo fuzzy-matcher) against the workspace
//! file index populated by [`spawn_mention_index_build`]
//!
//! [`spawn_mention_index_build`]: super::rest::AppStateRest::spawn_mention_index_build
//!
//! # Stale-mention safety
//!
//! Cursor movement (Left/Right) does not close the autocomplete dropdown, so
//! the `mention_start` field may refer to a range that is no longer valid
//! against the current buffer/cursor by the time the user presses Enter.
//! [`select_autocomplete`](InputState::select_autocomplete) handles this by
//! checking bounds before splicing — see its doc for details.
use std::path::PathBuf;
use tracing;
use tracing::debug;
/// Which source populated the autocomplete dropdown, since selecting a
/// candidate is spliced into the buffer differently for each.
///
/// - `Command` — `/`-prefixed builtin commands; selected candidate replaces
/// the entire buffer.
/// - `FileMention` — `@file` mentions; selected candidate is spliced into
/// the buffer at the `@` position, preserving surrounding text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
/// Builtin slash-command (e.g. `/model`, `/help`).
Command,
/// `@file` mention from the workspace file index.
FileMention,
}
/// Builtin slash-commands recognised by the chat input autocomplete.
///
/// These are filtered by prefix match when the user types `/`; selecting
/// one replaces the entire buffer. The list is hardcoded — there is no
/// mechanism for registering new commands at runtime.
const COMMANDS: &[&str] = &[
"/help",
"/quit",
@@ -30,16 +62,27 @@ const COMMANDS: &[&str] = &[
/// state for the chat prompt.
#[derive(Debug, Clone)]
pub struct InputState {
/// Raw UTF-8 input buffer content.
pub buffer: String,
/// Byte offset of the cursor within `buffer`.
pub cursor: usize,
/// Previously submitted input lines, oldest-first.
pub history: Vec<String>,
/// Index into `history` when browsing (None = at the current input).
pub history_idx: Option<usize>,
/// The prefix string used to filter candidates for autocomplete.
pub autocomplete_prefix: String,
/// Current autocomplete candidate list.
pub autocomplete_candidates: Vec<String>,
/// Focused index within `autocomplete_candidates`.
pub autocomplete_idx: usize,
/// Whether the autocomplete dropdown is visible.
pub autocomplete_visible: bool,
/// Which kind of autocomplete is active (Command or FileMention).
pub autocomplete_kind: AutocompleteKind,
/// Byte offset of the `@` character that triggered file mention autocomplete.
pub mention_start: usize,
/// Optional path to a persistent history file (appended on submit).
pub history_file: Option<PathBuf>,
}
@@ -47,6 +90,7 @@ impl InputState {
/// Create an empty input state with no buffer, no history, and no
/// autocomplete.
pub fn new() -> Self {
debug!("InputState::new — creating empty input state");
InputState {
buffer: String::new(),
cursor: 0,
@@ -64,6 +108,7 @@ impl InputState {
/// Hide the autocomplete dropdown and clear its state.
pub fn close_autocomplete(&mut self) {
debug!("InputState::close_autocomplete — hiding autocomplete");
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_prefix.clear();
@@ -91,10 +136,12 @@ impl InputState {
.filter(|c| c.starts_with(&prefix))
.map(std::string::ToString::to_string)
.collect();
let found = self.autocomplete_candidates.len();
self.autocomplete_prefix = prefix;
self.autocomplete_kind = AutocompleteKind::Command;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
self.autocomplete_visible = found > 0;
debug!("InputState::open_autocomplete — prefix='{}', {} candidates", self.autocomplete_prefix, found);
}
/// Find the `@mention` token (if any) immediately before the cursor.
@@ -107,12 +154,15 @@ impl InputState {
/// Return: `Some((byte offset of '@', query text between '@' and cursor))`
/// or `None` if the cursor isn't inside a mention token.
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
// Scan backwards from the cursor to find the nearest `@`
let before_cursor = &self.buffer[..self.cursor];
let at_pos = before_cursor.rfind('@')?;
// Text between `@` and cursor — must not contain whitespace
let between = &before_cursor[at_pos + 1..];
if between.chars().any(char::is_whitespace) {
return None;
}
// `@` must be at buffer start or preceded by whitespace (not mid-word)
let boundary_ok = at_pos == 0
|| before_cursor[..at_pos]
.chars()
@@ -121,6 +171,10 @@ impl InputState {
if !boundary_ok {
return None;
}
debug!(
"InputState::mention_query_at_cursor — found @ at byte {}, query='{}'",
at_pos, between
);
Some((at_pos, between.to_string()))
}
@@ -142,13 +196,18 @@ impl InputState {
let matched_files = pattern.match_list(files.iter(), &mut matcher);
self.autocomplete_candidates = matched_files
.into_iter()
.take(10)
.take(10) // limit to top 10 fuzzy matches
.map(|(f, _)| f.clone())
.collect();
self.autocomplete_kind = AutocompleteKind::FileMention;
self.mention_start = start;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
let found = self.autocomplete_candidates.len();
self.autocomplete_visible = found > 0;
debug!(
"InputState::open_mention_autocomplete — query='{}', {} candidates",
query, found
);
}
/// Move the autocomplete selection up (forward=false) or down (forward=true).
@@ -161,12 +220,17 @@ impl InputState {
if forward {
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
} else {
// wrap from top back to bottom
self.autocomplete_idx = if self.autocomplete_idx == 0 {
n - 1
} else {
self.autocomplete_idx - 1
};
}
debug!(
"InputState::cycle_autocomplete — forward={}, now at idx={}/{}",
forward, self.autocomplete_idx, n
);
}
/// Accept the currently selected autocomplete candidate.
@@ -182,10 +246,12 @@ impl InputState {
.get(self.autocomplete_idx)
.cloned()
else {
debug!("InputState::select_autocomplete — no candidate at idx={}", self.autocomplete_idx);
return false;
};
match self.autocomplete_kind {
AutocompleteKind::Command => {
debug!("InputState::select_autocomplete — Command: replacing buffer with '{}'", candidate);
self.buffer = candidate;
self.cursor = self.buffer.len();
}
@@ -198,10 +264,12 @@ impl InputState {
// doesn't panic, produce a nonsensical replacement. Treat a
// stale mention context the same as "nothing selected".
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
debug!("InputState::select_autocomplete — stale mention_start {}, cursor {} → cancelling", self.mention_start, self.cursor);
self.close_autocomplete();
return false;
}
let replacement = format!("@{candidate} ");
debug!("InputState::select_autocomplete — FileMention: splicing '{}' at pos {}..{}", replacement, self.mention_start, self.cursor);
self.buffer
.replace_range(self.mention_start..self.cursor, &replacement);
self.cursor = self.mention_start + replacement.len();
@@ -217,8 +285,10 @@ impl InputState {
// Legacy inline tab-complete — used as a fallback when the dropdown
// isn't visible yet. Opens the dropdown on the first Tab press.
if self.autocomplete_visible {
debug!("InputState::tab_complete — dropdown already visible, cycling forward");
self.cycle_autocomplete(true);
} else {
debug!("InputState::tab_complete — first Tab, opening autocomplete");
self.open_autocomplete();
}
}
@@ -227,6 +297,7 @@ impl InputState {
pub fn char_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
debug!("InputState::char_left — cursor now at {}", self.cursor);
}
}
@@ -234,13 +305,16 @@ impl InputState {
pub fn char_right(&mut self) {
if self.cursor < self.buffer.len() {
self.cursor += 1;
debug!("InputState::char_right — cursor now at {}", self.cursor);
}
}
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) {
// Insert the character and advance the cursor by one byte
self.buffer.insert(self.cursor, c);
self.cursor += 1;
debug!("InputState::insert — char='{}', cursor now at {}", c, self.cursor);
}
/// Delete the character to the left of the cursor (backspace).
@@ -248,6 +322,7 @@ impl InputState {
if self.cursor > 0 {
self.cursor -= 1;
self.buffer.remove(self.cursor);
debug!("InputState::delete_left — cursor now at {}", self.cursor);
}
}
@@ -255,14 +330,19 @@ impl InputState {
pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
debug!("InputState::delete_right — cursor now at {}", self.cursor);
}
}
/// Submit the current buffer: push it into history (persisting to disk if
/// `history_file` is set), clear the buffer, and return the submitted text.
pub fn submit(&mut self) -> String {
let result = self.buffer.clone();
if !result.is_empty() {
// Avoid duplicate consecutive history entries
if self.history.last() != Some(&result) {
self.history.push(result.clone());
// Persist to project-specific history file
if let Some(ref path) = self.history_file {
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
@@ -275,6 +355,7 @@ impl InputState {
}
}
self.history_idx = None;
debug!("InputState::submit — submitted {} bytes, history size={}", result.len(), self.history.len());
}
self.buffer.clear();
self.cursor = 0;
@@ -288,12 +369,13 @@ impl InputState {
}
let idx = match self.history_idx {
Some(i) if i > 0 => i - 1,
None => self.history.len() - 1,
Some(_) => return,
None => self.history.len() - 1, // start from the last entry
Some(_) => return, // already at the oldest entry
};
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
debug!("InputState::history_up — now at history idx={}", idx);
}
/// Navigate forward through input history (back toward the newest entry).
@@ -304,11 +386,14 @@ impl InputState {
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
debug!("InputState::history_down — now at history idx={}", idx);
}
Some(_) => {
// At the newest history entry → return to blank input
self.history_idx = None;
self.buffer.clear();
self.cursor = 0;
debug!("InputState::history_down — returned to blank input");
}
None => {}
}
+104 -2
View File
@@ -1,20 +1,31 @@
//! Application-level "miscellaneous" state: shared caches, overlay stack,
//! toasts, editor state, and thinking flags.
//!
//! Owned by [`AppStateRest`](super::rest::AppStateRest) via `misc: MiscState`.
//! Also contains `DirCache` (shared async directory listing) and
//! `MentionIndex` (shared workspace file path index for `@file` mentions).
use super::types::Overlay;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::debug;
use tracing::info;
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
///
/// Internal: wraps `Arc<RwLock<Vec<PathBuf>>>` so the cache is safe to
/// clone and share across tool call boundaries.
#[derive(Clone)]
pub struct DirCache {
/// Inner async-shared directory entry listing.
entries: Arc<RwLock<Vec<PathBuf>>>,
}
impl DirCache {
/// Create an empty `DirCache`.
/// Create an empty `DirCache` with no entries.
pub fn new() -> Self {
info!("DirCache::new — created empty directory cache");
DirCache {
entries: Arc::new(RwLock::new(Vec::new())),
}
@@ -22,6 +33,7 @@ impl DirCache {
/// Replace the cached entries (async write).
pub async fn set(&self, paths: Vec<PathBuf>) {
debug!("DirCache::set — replacing with {} entries", paths.len());
let mut w = self.entries.write().await;
*w = paths;
}
@@ -31,14 +43,20 @@ impl DirCache {
/// autocomplete. Built once by a background thread at startup (see
/// `AppStateRest::new`) and incrementally appended to when tools create
/// new files (see `tool/fs/write.rs`).
///
/// Internal: wraps `Arc<std::sync::RwLock<Vec<String>>>` (sync, not async)
/// since reads happen on the render thread and writes happen on background
/// threads — contention is extremely low.
#[derive(Clone)]
pub struct MentionIndex {
/// Inner sync-shared workspace file path listing.
entries: Arc<std::sync::RwLock<Vec<String>>>,
}
impl MentionIndex {
/// Create an empty `MentionIndex`.
pub fn new() -> Self {
info!("MentionIndex::new — created empty mention index");
MentionIndex {
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
}
@@ -46,6 +64,7 @@ impl MentionIndex {
/// Replace the indexed paths (used by the startup background walk).
pub fn set(&self, paths: Vec<String>) {
debug!("MentionIndex::set — writing {} paths", paths.len());
if let Ok(mut w) = self.entries.write() {
*w = paths;
}
@@ -53,6 +72,7 @@ impl MentionIndex {
/// Append a single newly created file's path (used by the `write` tool).
pub fn push(&self, path: String) {
debug!("MentionIndex::push — appending '{}'", path);
if let Ok(mut w) = self.entries.write() {
w.push(path);
}
@@ -60,7 +80,9 @@ impl MentionIndex {
/// Take a snapshot of the current indexed paths for fuzzy matching.
pub fn snapshot(&self) -> Vec<String> {
self.entries.read().map(|r| r.clone()).unwrap_or_default()
let result = self.entries.read().map(|r| r.clone()).unwrap_or_default();
debug!("MentionIndex::snapshot — returning {} paths", result.len());
result
}
}
@@ -68,17 +90,29 @@ impl MentionIndex {
/// toasts, thinking/connected flags, effort level, editor state, and tick.
#[derive(Debug, Clone)]
pub struct MiscState {
/// Currently active modal overlay (None = main chat view).
pub overlay: Overlay,
/// Active toast notifications (expired ones removed on each tick).
pub toasts: Vec<super::types::Toast>,
/// Timestamp (ms) of the last staleness sweep for lesson cache.
pub last_staleness_sweep_ms: i64,
/// Whether the agent is currently "thinking" (streaming or waiting on tool).
pub thinking: bool,
/// Current LLM reasoning effort level (1-5).
pub effort_level: usize,
/// Currently focused index in list-type overlays (e.g. settings, model).
pub selected_index: usize,
/// Optional inline editor state (opened via `/edit`).
pub editor: Option<super::super::mode::editor::EditorState>,
/// Whether the API connection is established.
pub api_connected: bool,
/// Monotonically increasing tick count, incremented each render frame.
pub tick_count: u64,
/// Cached content of the TODO file, shown in the overlay.
pub todo_content: String,
/// Whether a lesson background task is currently running.
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard (set by yank tool).
pub pending_clipboard_copy: Option<String>,
}
@@ -86,6 +120,7 @@ impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts, and default
/// effort level 1.
pub fn new() -> Self {
info!("MiscState::new — created fresh misc state");
MiscState {
overlay: Overlay::None,
toasts: Vec::new(),
@@ -102,12 +137,21 @@ impl MiscState {
}
}
/// Append a toast notification to the active list.
pub fn push_toast(&mut self, toast: super::types::Toast) {
debug!(
"MiscState::push_toast — kind={:?}, msg='{}'",
toast.kind,
toast.message.chars().take(80).collect::<String>()
);
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
///
/// Flow: partition toasts into expired vs active → retain only active →
/// return the expired ones for optional callback processing.
///
/// Return: the expired toasts (after removal).
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
let expired: Vec<_> = self
@@ -117,12 +161,16 @@ impl MiscState {
.cloned()
.collect();
self.toasts.retain(|t| !t.expired(now_ms));
if !expired.is_empty() {
debug!("MiscState::drain_expired_toasts — draining {} toasts", expired.len());
}
expired
}
}
#[cfg(test)]
mod tests {
use super::super::types::ToastKind;
use super::*;
#[test]
@@ -130,4 +178,58 @@ mod tests {
let misc = MiscState::new();
assert!(misc.pending_clipboard_copy.is_none());
}
#[test]
fn push_toast_appends_and_drain_expired_removes_expired() {
let mut misc = MiscState::new();
// Push two toasts: first expired (created 0ms, lifetime 100ms),
// second still within lifetime (created 500ms, lifetime 1000ms).
misc.push_toast(super::super::types::Toast {
kind: ToastKind::Info,
message: "expired".into(),
created_at: 0,
lifetime_ms: 100,
});
misc.push_toast(super::super::types::Toast {
kind: ToastKind::Success,
message: "active".into(),
created_at: 500,
lifetime_ms: 1000,
});
assert_eq!(misc.toasts.len(), 2);
// Drain with now_ms=500 — first toast expired, second still alive.
let drained = misc.drain_expired_toasts(500);
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].message, "expired");
assert_eq!(misc.toasts.len(), 1);
assert_eq!(misc.toasts[0].message, "active");
}
#[test]
fn drain_expired_toasts_empty_when_none_expired() {
let mut misc = MiscState::new();
misc.push_toast(super::super::types::Toast {
kind: ToastKind::Warning,
message: "future".into(),
created_at: 0,
lifetime_ms: 9999,
});
let drained = misc.drain_expired_toasts(100);
assert!(drained.is_empty());
assert_eq!(misc.toasts.len(), 1);
}
#[test]
fn dir_cache_and_mention_index_new_create_empty_structures() {
let dc = DirCache::new();
// No public reader, just verify it doesn't panic on set.
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(dc.set(vec![]));
let mi = MentionIndex::new();
assert!(mi.snapshot().is_empty());
mi.push("src/main.rs".into());
assert_eq!(mi.snapshot().len(), 1);
}
}
@@ -1,5 +1,22 @@
//! Application state: misc fields, the main `AppStateRest` struct,
//! runtime-only state, and shared types (overlays, toasts, origins).
//!
//! # Sub-modules
//!
//! | Module | Responsibility |
//! |-----------|-------------------------------------------------------------|
//! | `input` | Input-line state (cursor, text buffer, history) |
//! | `misc` | Miscellaneous state flags and counters |
//! | `rest` | The single source-of-truth `AppStateRest` struct |
//! | `runtime` | Runtime-only transient state (not persisted) |
//! | `scroll` | Scroll position and viewport tracking |
//! | `types` | Shared enums & structs (overlays, toasts, origins) |
//!
//! # Mutation convention
//!
//! `AppStateRest` is mutated in-place from two locations:
//! [`actions::apply_action`] and [`controller::input`]. Every other
//! module reads state immutably.
pub mod input;
pub mod misc;
pub mod rest;
+77 -8
View File
@@ -3,11 +3,24 @@
//!
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
//! `actions/mod.rs` and `controller/input.rs`; every other module reads it.
//!
//! # Construction flow
//!
//! 1. Load persisted `Settings` and `AppConfig` from JSON stores
//! 2. Derive `worktrees_dir` from `memory_dir`'s parent
//! 3. Derive `session_id` from the session directory's filename
//! 4. Open the edit-log append-only file for this session
//! 5. Load project-specific input history (SHA256-hashed workspace root)
//! 6. Optionally spawn a background LSP provisioning thread
//!
//! All fallible steps degrade gracefully (defaults + warnings) so that
//! construction never panics.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use tracing::{self, debug, warn};
use super::input::InputState;
use super::misc::{DirCache, MentionIndex, MiscState};
@@ -30,14 +43,18 @@ use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsReposito
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User or Assistant.
pub role: crate::dto::chat::message::Role,
/// Rendered text content (plain text, no markdown).
pub content: String,
/// Millisecond timestamp when this display entry was created.
pub timestamp: i64,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
tracing::debug!("ChatMessageDisplay::new — role={:?}, content_len={}", role, content.len());
ChatMessageDisplay {
role,
content,
@@ -53,32 +70,57 @@ impl ChatMessageDisplay {
/// other module.
#[derive(Clone)]
pub struct AppStateRest {
/// Persistent user settings (loaded from JSON store at startup).
pub settings: Settings,
/// Per-project app configuration (loaded from JSON store at startup).
pub app_config: AppConfig,
/// Absolute paths to each open workspace root directory.
pub workspace_roots: Vec<PathBuf>,
/// Unique session identifier (derived from the session directory name).
pub session_id: String,
/// Path to the session's data directory.
pub session_dir: PathBuf,
/// Path to the session memory directory (lessons, review history).
pub memory_dir: PathBuf,
/// Path to the git worktrees directory (for sandboxed agent experiments).
pub worktrees_dir: PathBuf,
/// Shared async cache of directory listings (avoids re-reading on every frame).
pub dir_cache: Arc<RwLock<DirCache>>,
/// Shared workspace file-path index for `@file` mention autocomplete.
pub mention_index: MentionIndex,
/// Persistent edit history log (appended on every tool write).
pub edit_log: EditLog,
/// Optional per-session runtime state (message history, tool queue, counters).
pub session_runtime: Option<SessionRuntime>,
/// Active IAM sessions linked to this app instance.
pub sessions: Vec<zesdex_iam::domain::session::Session>,
/// Ring buffer of recent chat messages for the TUI transcript pane.
pub transcript_cache: TranscriptCache,
/// Viewport scroll offset tracker.
pub scroll: ScrollState,
/// Chat input buffer, cursor, history, and autocomplete.
pub input: InputState,
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
pub misc: MiscState,
/// Queue of events emitted by the running agent turn, consumed by the
/// main event loop to drive incremental re-renders.
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight (guarded by a mutex).
pub turn_in_flight: Arc<Mutex<bool>>,
/// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape).
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
/// Workflow engine state for multi-agent hive-mind orchestration.
pub workflow_engine: WorkflowEngine,
/// MCP (Model Context Protocol) server manager.
pub mcp_manager: McpManager,
/// LSP (Language Server Protocol) manager, shared with tool context.
pub lsp_manager: Arc<Mutex<LspManager>>,
/// Shared queue: provisioner thread pushes status updates,
/// Shared queue: LSP provisioner thread pushes status updates,
/// drained into toasts on each Tick.
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
/// Whether the state has been modified since the last render sweep.
pub dirty: bool,
/// Whether the application has been requested to quit.
pub quit: bool,
}
@@ -160,7 +202,9 @@ impl AppStateRest {
quit: false,
};
// Load project-specific history
// Load project-specific input-line history from a file keyed by
// the first workspace root's SHA256 hash. This gives us a stable
// filename per project that survives session-dir renames.
let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir);
if let Some(root) = state.workspace_roots.first() {
if let Ok(abs_root) = std::fs::canonicalize(root) {
@@ -168,6 +212,7 @@ impl AppStateRest {
let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = hex::encode(hasher.finalize());
// Use folder name + first 8 hex chars as a human-readable key
let folder_name = abs_root
.file_name()
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
@@ -176,6 +221,7 @@ impl AppStateRest {
let _ = std::fs::create_dir_all(&history_dir);
let history_file = history_dir.join(history_filename);
// Restore previous session's history; start fresh if file missing
if let Ok(content) = std::fs::read_to_string(&history_file) {
let history: Vec<String> = content
.lines()
@@ -184,6 +230,7 @@ impl AppStateRest {
.collect();
state.input.history = history;
}
// Store the file path so future input-append code can write back
state.input.history_file = Some(history_file);
}
}
@@ -257,6 +304,8 @@ impl AppStateRest {
});
}
// All done — return fully initialised state with dirty=true so the
// TUI renders the initial frame, not a blank screen.
state
}
@@ -287,8 +336,11 @@ impl AppStateRest {
let mention_index = self.mention_index.clone();
let roots = self.workspace_roots.clone();
std::thread::spawn(move || {
// Safety cap: index at most 50k files to bound memory and time
const MAX_MENTION_ENTRIES: usize = 50_000;
let mut paths = Vec::new();
// Walk each workspace root sequentially; label the outer loop so
// the cap check can bail out of all roots at once
'roots: for (i, root) in roots.iter().enumerate() {
for entry in ignore::Walk::new(root).flatten() {
if !entry.path().is_file() {
@@ -296,6 +348,8 @@ impl AppStateRest {
}
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
let rel_str = rel.display().to_string();
// Root 0 uses bare paths; subsequent roots get "[N]" prefix
// so `resolve_path` can disambiguate them
let formatted = if i == 0 {
rel_str
} else {
@@ -316,13 +370,15 @@ impl AppStateRest {
/// 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_or_else(
let result = self.turn_in_flight.lock().map_or_else(
|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
);
tracing::debug!("AppStateRest::turn_in_flight — returning {}", result);
result
}
/// Shut down every running LSP server process.
@@ -331,6 +387,7 @@ impl AppStateRest {
/// processes; silently no-ops if the mutex is poisoned since there is
/// nothing more useful to do at shutdown time.
pub fn shutdown_lsp(&mut self) {
tracing::debug!("AppStateRest::shutdown_lsp — shutting down all LSP servers");
if let Ok(mut mgr) = self.lsp_manager.lock() {
mgr.shutdown_all();
}
@@ -339,42 +396,50 @@ 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) {
let len_before = self.transcript_cache.messages.len();
self.transcript_cache.messages.push(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
self.transcript_cache.messages.remove(0);
}
self.transcript_cache.dirty = true;
self.dirty = true;
debug!("AppStateRest::push_transcript — cache was {} msgs", len_before);
}
/// Mark the app state as dirty, triggering a TUI re-render on the next frame.
pub fn mark_dirty(&mut self) {
self.dirty = true;
debug!("AppStateRest::mark_dirty — state marked dirty");
}
/// Queue a toast notification for display and mark the app dirty.
pub fn push_toast(&mut self, toast: Toast) {
debug!("AppStateRest::push_toast — kind={:?}", toast.kind);
self.misc.push_toast(toast);
self.mark_dirty();
}
/// Push an info toast with the given message.
pub fn toast_info(&mut self, msg: impl Into<String>) {
debug!("AppStateRest::toast_info");
self.push_toast(Toast::new(super::types::ToastKind::Info, msg.into()));
}
/// Push a success toast with the given message.
pub fn toast_success(&mut self, msg: impl Into<String>) {
debug!("AppStateRest::toast_success");
self.push_toast(Toast::new(super::types::ToastKind::Success, msg.into()));
}
/// Push a warning toast with the given message.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
debug!("AppStateRest::toast_warning");
self.push_toast(Toast::new(super::types::ToastKind::Warning, msg.into()));
}
/// Push an error toast with the given message.
pub fn toast_error(&mut self, msg: impl Into<String>) {
debug!("AppStateRest::toast_error");
self.push_toast(Toast::new(super::types::ToastKind::Error, msg.into()));
}
@@ -382,22 +447,23 @@ impl AppStateRest {
/// `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
/// 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 {
debug!("AppStateRest::store_base_dir — resolving from session_dir='{}'", self.session_dir.display());
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
tracing::warn!(
warn!(
"[state] session_dir '{}' has no grandparent, using parent",
self.session_dir.display()
);
self.session_dir.parent().map_or_else(
|| {
tracing::warn!(
warn!(
"[state] session_dir '{}' has no parent at all, using itself",
self.session_dir.display()
);
@@ -416,6 +482,7 @@ impl AppStateRest {
/// duplicated twice in `controller/input.rs` — this helper centralises
/// the call site.
pub fn save_settings(&self) {
debug!("AppStateRest::save_settings — persisting settings");
use zesdex_cms::domain::repository::SettingsRepository;
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&self.store_base_dir(), &self.settings);
@@ -423,12 +490,14 @@ impl AppStateRest {
/// Build a `ToolCtx` for tool calls originating from the main agent.
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
debug!("AppStateRest::tool_ctx — building for Origin::Main");
self.tool_ctx_for(Origin::Main)
}
/// Build a `ToolCtx` scoped to the given call origin (main, subagent,
/// reviewer), copying workspace/session/memory paths from state.
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
debug!("AppStateRest::tool_ctx_for — origin={:?}", origin);
crate::tool::ToolCtx {
workspaces: self.workspace_roots.clone(),
session_dir: self.session_dir.clone(),
@@ -1,6 +1,9 @@
//! 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.
//!
//! Owned by [`AppStateRest`](super::rest::AppStateRest) via
//! `session_runtime: Option<SessionRuntime>`.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -10,27 +13,49 @@ pub use zesdex_entities::domain::common::usage::UsageStats;
/// shown in the TUI status bar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRuntime {
/// Full conversation history (persisted to msglog SQLite externally).
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
/// Completed tool-call results (used for display/review).
pub tool_call_results: Vec<ToolCallResult>,
/// Tools queued for execution when the turn resumes.
pub pending_tool_queue: Vec<PendingTool>,
/// Background bash job display records.
pub bash_jobs: Vec<BashJobRef>,
/// Number of subagents queued but not yet started.
pub subagent_queue: usize,
/// Number of tool edits performed in this session.
pub edit_count: u32,
/// Consecutive reviews that returned no findings (used for early-exit).
pub consecutive_empty_reviews: u32,
/// Session start timestamp in milliseconds.
pub session_start: i64,
/// Total number of lesson entries in cache.
pub lesson_count: u32,
/// Lessons tagged as user-authored.
pub lessons_user: u32,
/// Lessons tagged as user feedback.
pub lessons_feedback: u32,
/// Lessons tagged as project-level.
pub lessons_project: u32,
/// Lessons tagged as reference material.
pub lessons_reference: u32,
/// Lessons currently active (not stale/contradicted).
pub lessons_active: u32,
/// Lessons that have gone stale.
pub lessons_stale: u32,
/// Lessons that have been contradicted by newer entries.
pub lessons_contradicted: u32,
/// Lessons marked as human-authored (vs AI-derived).
pub lessons_human: u32,
/// Lessons whose verification status is confirmed.
pub lessons_verified: u32,
/// Lessons whose verification status is pending.
pub lessons_unverified: u32,
/// Number of auto-inline reviews performed.
pub review_count: u32,
/// Path to the session data directory.
pub session_dir: PathBuf,
/// Token usage statistics (input/output per model).
pub usage: UsageStats,
/// Whether a hive-mind convergence has completed at least once in this
/// session. Set by the main-thread event loop when it receives a
@@ -42,13 +67,18 @@ pub struct SessionRuntime {
pub hive_mind_converged: bool,
}
/// Re-exported tool-call result with structured output, error flag, and
/// optional file path — used in `SessionRuntime::tool_call_results`.
pub use zesdex_entities::domain::common::tool_result::ToolCallResult;
/// 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 {
/// Name of the tool to execute (e.g. "Bash", "Read", "Write").
pub tool_name: String,
/// JSON arguments for the tool call.
pub args: serde_json::Value,
/// How the tool should be executed when the turn resumes.
pub execution_model: crate::app::state::types::ExecutionModel,
}
@@ -56,9 +86,13 @@ pub struct PendingTool {
/// process handle lives elsewhere; this is just the display/status record).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BashJobRef {
/// Unique job identifier.
pub id: String,
/// Shell command being executed.
pub command: String,
/// Timestamp (ms) when the job was started.
pub started_at: i64,
/// Whether the job is still running (vs completed/failed).
pub running: bool,
}
@@ -66,23 +100,39 @@ pub struct BashJobRef {
/// consumed by the event loop to update state and drive re-renders.
#[derive(Debug, Clone)]
pub enum TurnEvent {
/// A full assistant message has been produced (tool calls or final text).
AssistantMessage(crate::dto::chat::message::ChatMessage),
/// A tool has finished executing, with its output.
ToolResult {
/// ID of the tool call that produced this result.
tool_call_id: String,
/// Name of the tool that executed.
tool_name: String,
/// Text output from the tool.
output: String,
/// Whether the tool returned an error.
is_error: bool,
/// Optional path to a file produced (e.g. Write tool).
path: Option<String>,
},
/// A system-level notification (e.g. "compacted", "hive_mind_converged").
SystemNote {
/// Machine-readable kind tag.
kind: String,
/// Human-readable description.
message: String,
},
/// The turn's stream has started producing tokens.
StreamStart,
/// A single text token from the streaming response.
StreamToken(String),
/// The turn's stream is complete, with the final assembled message.
StreamDone(crate::dto::chat::message::ChatMessage),
/// Token usage for a main-agent turn.
Usage {
/// Input tokens consumed.
tokens_in: u64,
/// Output tokens generated.
tokens_out: u64,
},
/// Token usage from a subagent (review, test-gen, arch-review, etc.)
@@ -92,17 +142,26 @@ pub enum TurnEvent {
/// (origin tag, subagent name) can be attached without breaking the
/// main-agent path.
ReviewUsage {
/// Input tokens consumed by the subagent.
tokens_in: u64,
/// Output tokens generated by the subagent.
tokens_out: u64,
},
/// The message history has been compacted (older messages replaced
/// with a summary).
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
/// An error occurred during the turn.
Error(String),
/// Signal that the turn has finished completely.
Done,
/// Real-time update from a workflow subagent: push the new status
/// into `AppStateRest::workflow_engine.agents`.
WorkflowAgentUpdate {
/// Unique agent identifier within the workflow.
agent_id: String,
/// Human-readable agent name.
agent_name: String,
/// Current status (running, waiting, completed, etc.).
status: crate::app::workflow::engine::AgentStatus,
},
}
@@ -111,6 +170,7 @@ 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 {
tracing::info!("SessionRuntime::new — session_dir='{}'", session_dir.display());
SessionRuntime {
messages: Vec::new(),
tool_call_results: Vec::new(),
@@ -140,6 +200,51 @@ impl SessionRuntime {
/// Append a message to the session's conversation history.
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
tracing::debug!("SessionRuntime::push_message — role={:?}, content_len={}",
msg.role, msg.content.as_deref().map_or(0, str::len));
self.messages.push(msg);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_runtime_new_creates_empty_state() {
let rt = SessionRuntime::new(PathBuf::from("/tmp/test_session"));
assert!(rt.messages.is_empty());
assert!(rt.tool_call_results.is_empty());
assert!(rt.pending_tool_queue.is_empty());
assert_eq!(rt.edit_count, 0);
assert!(!rt.hive_mind_converged);
assert_eq!(rt.lesson_count, 0);
}
#[test]
fn session_runtime_push_message_appends_to_history() {
let mut rt = SessionRuntime::new(PathBuf::from("/tmp/test_session"));
let msg = crate::dto::chat::message::ChatMessage {
role: crate::dto::chat::message::Role::User,
content: Some("hello".into()),
tool_calls: None,
tool_call_id: None,
name: None,
};
rt.push_message(msg);
assert_eq!(rt.messages.len(), 1);
assert_eq!(rt.messages[0].content.as_deref(), Some("hello"));
}
#[test]
fn bash_job_ref_stores_command_and_running_flag() {
let job = BashJobRef {
id: "job-1".into(),
command: "cargo build".into(),
started_at: 1000,
running: true,
};
assert!(job.running);
assert_eq!(job.command, "cargo build");
}
}
+75 -4
View File
@@ -1,33 +1,104 @@
//! Scroll offset management for viewport panning.
//!
//! Manages the viewport scroll offset.
//! Tracks the current scroll offset and the maximum number of visible
//! lines in the viewport. Used by the transcript pane, overlays, and
//! other scrollable TUI areas.
/// Viewport scroll state: current offset and visible-line count.
///
/// The offset increases when scrolling down (older content comes into
/// view) and decreases when scrolling up (newer content).
#[derive(Debug, Clone)]
pub struct ScrollState {
/// Current scroll offset (how many lines have been scrolled past).
pub offset: usize,
/// Maximum number of lines that fit in the visible viewport area.
pub max_visible: usize,
}
impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self {
tracing::info!("ScrollState::new — created scroll state with max_visible=30");
ScrollState {
offset: 0,
max_visible: 30,
}
}
/// Scroll the viewport up by `amount` lines (increasing the offset).
/// Scroll the viewport up by `amount` lines (increasing the offset,
/// moving toward older content).
///
/// Uses saturating addition so the offset never wraps on overflow.
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
tracing::debug!("ScrollState::scroll_up — offset now {}", self.offset);
}
/// Scroll the viewport down by `amount` lines (decreasing the offset).
/// Scroll the viewport down by `amount` lines (decreasing the offset,
/// moving toward newer content).
///
/// Uses saturating subtraction so the offset never goes below zero.
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
tracing::debug!("ScrollState::scroll_down — offset now {}", self.offset);
}
/// Update the maximum number of visible lines.
/// Update the maximum number of visible lines in the viewport.
///
/// The caller is responsible for ensuring `max` does not exceed
/// the actual terminal height.
pub fn set_max_visible(&mut self, max: usize) {
tracing::debug!("ScrollState::set_max_visible — {} -> {}", self.max_visible, max);
self.max_visible = max;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_state_new_starts_at_zero() {
let s = ScrollState::new();
assert_eq!(s.offset, 0);
assert_eq!(s.max_visible, 30);
}
#[test]
fn scroll_up_increases_offset() {
let mut s = ScrollState::new();
s.scroll_up(5);
assert_eq!(s.offset, 5);
s.scroll_up(3);
assert_eq!(s.offset, 8);
}
#[test]
fn scroll_down_decreases_offset_and_saturates_at_zero() {
let mut s = ScrollState::new();
s.scroll_up(10);
assert_eq!(s.offset, 10);
s.scroll_down(4);
assert_eq!(s.offset, 6);
// Saturate at zero
s.scroll_down(100);
assert_eq!(s.offset, 0);
}
#[test]
fn scroll_down_on_zero_offset_stays_zero() {
let mut s = ScrollState::new();
s.scroll_down(5);
assert_eq!(s.offset, 0);
}
#[test]
fn set_max_visible_updates_viewport() {
let mut s = ScrollState::new();
s.set_max_visible(50);
assert_eq!(s.max_visible, 50);
s.set_max_visible(20);
assert_eq!(s.max_visible, 20);
}
}
@@ -1,16 +1,29 @@
//! Opaque, serializable snapshot of application state used for
//! attach/daemon IPC transfer.
//!
//! Flow: the daemon serializes its [`AppStateRest`](super::rest::AppStateRest)
//! into JSON and sends it over the IPC socket to an attach client, which
//! deserializes it for local rendering. The snapshot is intentionally opaque
//! (a single `serde_json::Value`) so the transport layer does not need to
//! know the state schema.
use serde::{Deserialize, Serialize};
use tracing::debug;
use tracing::info;
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
///
/// Fields:
/// - `snapshot` — the raw JSON value of the serialised app state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot {
/// Raw JSON representation of the application state.
pub snapshot: serde_json::Value,
}
impl StateSnapshot {
/// Create an empty snapshot (`{}`).
pub fn new() -> Self {
info!("StateSnapshot::new — created empty snapshot");
StateSnapshot {
snapshot: serde_json::json!({}),
}
@@ -19,14 +32,57 @@ impl StateSnapshot {
/// Serialize a snapshot to bytes for transport over the daemon socket.
///
/// Flow: [`serde_json::to_vec`] serialises the snapshot struct into
/// compact JSON bytes.
///
/// Return: JSON-encoded bytes, or a serde error.
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
debug!("serialize_snapshot — serialising state snapshot");
Ok(serde_json::to_vec(snapshot)?)
}
/// Parse a snapshot previously produced by `serialize_snapshot`.
///
/// Flow: [`serde_json::from_slice`] deserialises the JSON bytes back
/// into a [`StateSnapshot`].
///
/// Return: the decoded `StateSnapshot`, or a serde error.
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
debug!("deserialize_snapshot — deserialising {} bytes", data.len());
Ok(serde_json::from_slice(data)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_snapshot_new_creates_empty_json() {
let snap = StateSnapshot::new();
assert_eq!(snap.snapshot, serde_json::json!({}));
}
#[test]
fn serialize_deserialize_roundtrip() {
// Create a snapshot with non-trivial content.
let original = StateSnapshot {
snapshot: serde_json::json!({
"thinking": true,
"tick_count": 42,
"overlay": "Settings",
}),
};
let bytes = serialize_snapshot(&original).unwrap();
assert!(!bytes.is_empty());
let decoded = deserialize_snapshot(&bytes).unwrap();
assert_eq!(decoded.snapshot["thinking"], serde_json::json!(true));
assert_eq!(decoded.snapshot["tick_count"], serde_json::json!(42));
}
#[test]
fn deserialize_empty_bytes_fails() {
let result = deserialize_snapshot(b"");
assert!(result.is_err());
}
}
+144 -8
View File
@@ -1,14 +1,22 @@
//! Shared small state types: toasts, overlays, the transcript cache,
//! tool execution model, and call origin tags.
//!
//! These types are used across multiple sub-modules in `state/` and are
//! also consumed by the view layer, tool harness, and IPC transport.
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 {
/// Informational message (neutral).
Info,
/// Successful operation (green).
Success,
/// Warning / non-critical issue (yellow).
Warning,
/// Error / failure (red).
Error,
/// Lesson notification (purple/blue).
Lesson,
}
@@ -16,15 +24,20 @@ pub enum ToastKind {
/// `lifetime_ms`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Toast {
/// Severity/category, determines the display color.
pub kind: ToastKind,
/// Human-readable message text.
pub message: String,
/// Millisecond timestamp when the toast was created.
pub created_at: i64,
/// How long (ms) the toast should remain visible.
pub lifetime_ms: u64,
}
impl Toast {
/// Create a toast with a default 5-second lifetime, stamped with now.
pub fn new(kind: ToastKind, message: String) -> Self {
tracing::debug!("Toast::new — kind={:?}, msg='{}'", kind, message.chars().take(80).collect::<String>());
Toast {
kind,
message,
@@ -35,50 +48,103 @@ 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
let expired = now_ms - self.created_at > self.lifetime_ms as i64;
if expired {
tracing::debug!("Toast::expired — toast aged {}ms expired (lifetime={}ms)", now_ms - self.created_at, self.lifetime_ms);
}
expired
}
}
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
/// No overlay; the main chat view is shown.
None,
/// Key bindings help screen.
Help,
/// Settings/configuration panel.
Settings,
/// Background bash job viewer.
Bash,
/// "Are you sure you want to quit?" confirmation.
QuitConfirm,
/// Raw key-code input capture (for binding custom keys).
KeyInput,
/// Inline editor (opened via `/edit`).
Editor,
/// Reasoning effort level selector.
Effort,
/// MCP server management panel.
Mcp,
/// TODO list overlay.
Todo,
/// Session rewind / history scrubber.
Rewind,
/// Learning / lesson management panel.
Learning,
/// Token usage statistics panel.
Usage,
/// Generic loading spinner overlay.
Loading,
/// Model selector dropdown.
ModelSelector,
/// "Clear conversation?" confirmation (distinct from QuitConfirm).
ClearConfirm,
}
impl Overlay {
/// Human-readable name for this overlay variant.
pub fn as_str(&self) -> &'static str {
match self {
Overlay::None => "none",
Overlay::Help => "help",
Overlay::Settings => "settings",
Overlay::Bash => "bash",
Overlay::QuitConfirm => "quit_confirm",
Overlay::KeyInput => "key_input",
Overlay::Editor => "editor",
Overlay::Effort => "effort",
Overlay::Mcp => "mcp",
Overlay::Todo => "todo",
Overlay::Rewind => "rewind",
Overlay::Learning => "learning",
Overlay::Usage => "usage",
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
}
}
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
let active = !matches!(self, Overlay::None);
tracing::debug!("Overlay::is_active — overlay={:?}, active={}", self, active);
active
}
}
impl std::fmt::Display for Overlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone, PartialEq)]
pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full).
pub messages: Vec<super::rest::ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest.
pub max_lines: usize,
/// Whether the cache has changed since the last render sweep.
pub dirty: bool,
}
impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self {
tracing::info!("TranscriptCache::new — max_lines={}", max_lines);
TranscriptCache {
messages: Vec::new(),
max_lines,
@@ -90,8 +156,11 @@ 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 {
/// Run the tool synchronously in the main agent loop.
Inline,
/// Defer execution until the LLM explicitly asks for the result.
Deferred,
/// Run as a background tokio task (used for long-running tools).
AsyncTokio,
}
@@ -99,18 +168,85 @@ pub enum ExecutionModel {
/// invoking a tool, used to scope permissions and tag log/output paths.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum Origin {
/// The main agent turn loop.
Main,
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
SubAgent,
/// The auto-inline review step after an edit.
Reviewer,
}
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(),
Origin::SubAgent => "subagent".to_string(),
Origin::Reviewer => "reviewer".to_string(),
}
let tag = match self {
Origin::Main => "main",
Origin::SubAgent => "subagent",
Origin::Reviewer => "reviewer",
};
tracing::debug!("Origin::tag — {:?} -> '{}'", self, tag);
tag.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toast_new_has_default_lifetime() {
let t = Toast::new(ToastKind::Info, "hello".into());
assert_eq!(t.lifetime_ms, 5000);
assert_eq!(t.message, "hello");
assert_eq!(t.kind, ToastKind::Info);
}
#[test]
fn toast_expired_returns_true_after_lifetime() {
let t = Toast {
kind: ToastKind::Warning,
message: "old".into(),
created_at: 0,
lifetime_ms: 100,
};
assert!(t.expired(200));
}
#[test]
fn toast_expired_returns_false_within_lifetime() {
let t = Toast {
kind: ToastKind::Success,
message: "fresh".into(),
created_at: 50,
lifetime_ms: 200,
};
assert!(!t.expired(100));
}
#[test]
fn overlay_is_active_returns_true_for_non_none() {
assert!(Overlay::Help.is_active());
assert!(Overlay::Settings.is_active());
assert!(Overlay::QuitConfirm.is_active());
}
#[test]
fn overlay_is_active_returns_false_for_none() {
assert!(!Overlay::None.is_active());
}
#[test]
fn origin_tag_returns_correct_string() {
assert_eq!(Origin::Main.tag(), "main");
assert_eq!(Origin::SubAgent.tag(), "subagent");
assert_eq!(Origin::Reviewer.tag(), "reviewer");
}
#[test]
fn transcript_cache_new_creates_empty_dirty_cache() {
let tc = TranscriptCache::new(100);
assert!(tc.messages.is_empty());
assert_eq!(tc.max_lines, 100);
assert!(tc.dirty);
}
}
@@ -15,6 +15,10 @@
//! wrote this file, let me check if it's correct before continuing").
//! - Background reviews catch broader concerns (missing tests, architectural
//! drift, security issues) without blocking the main agent's flow.
//!
//! Overlap prevention: each background review kind has its own `AtomicBool`
//! static and a `RunningGuard` that resets it on drop (even during panic
//! unwind), so a single review kind can never stack multiple concurrent runs.
pub(crate) mod paths;
pub use paths::is_reviewable_path;
@@ -29,6 +33,7 @@ use std::collections::VecDeque;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing;
/// Prevents a second background subagent of the same kind from spawning
/// while one is already in flight. Without this, a chatty multi-turn edit
@@ -44,21 +49,25 @@ static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false);
/// a background review can never wedge itself permanently disabled for the
/// rest of the process if the subagent run panics before reaching its
/// normal completion path.
///
/// Usage: `let _guard = RunningGuard(&FLAG);` at the top of the spawned
/// closure. On normal exit or panic, the flag is atomically reset to `false`.
struct RunningGuard(&'static AtomicBool);
impl Drop for RunningGuard {
fn drop(&mut self) {
tracing::debug!("[auto] RunningGuard resetting overlap flag");
self.0.store(false, Ordering::SeqCst);
}
}
/// ─── Helpers ───
/// Derive a human-readable message prefix from the internal kind label.
/// Derive a human-readable message prefix from the internal kind label.
///
/// Production callers always pass one of the three known labels
/// (`"bg-test-gen"`, `"bg-arch-review"`, `"bg-security-review"`).
/// The `other` arm is a safety net with a debug assertion.
fn message_prefix(kind: &str) -> &'static str {
match kind {
"bg-test-gen" => "Auto test-gen",
@@ -77,6 +86,14 @@ fn message_prefix(kind: &str) -> &'static str {
///
/// Spawn a lightweight inline code review subagent for the given file.
///
/// Flow:
/// 1. Format a review prompt using `AUTO_REVIEWER_PROMPT` + file path.
/// 2. Create an `AgentDefinition` with role `"reviewer"` (gets read-only
/// tool access by default).
/// 3. Build a `SubagentContext`, set `session_dir` and `workspaces`.
/// 4. Spawn a drain thread and run the subagent synchronously.
/// 5. Log the verdict's first line and return it.
///
/// The subagent reads the file (read-only), checks for common issues,
/// and returns a concise text verdict. This runs synchronously so the
/// main agent's `run_agent_turn` can inject the result back into the
@@ -91,12 +108,15 @@ pub fn spawn_quick_review(
session_dir: &Path,
workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> {
tracing::debug!("[auto] spawn_quick_review: {file_path}");
let prompt = format!(
"{}\n\nFile to review: {}",
crate::prompts::AUTO_REVIEWER_PROMPT,
file_path,
);
// Create a reviewer agent with read-only tool access by default.
let def = AgentDefinition::new("quick-reviewer".to_string(), "reviewer".to_string())
.with_system_prompt(prompt);
@@ -104,6 +124,7 @@ pub fn spawn_quick_review(
ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec();
// Spawn the drain thread that forwards events to tracing.
let (tx, _drain) = spawn_subagent_with_drain(|event| {
match &event {
SubagentEvent::ToolCall { tool, .. } => {
@@ -119,6 +140,8 @@ pub fn spawn_quick_review(
}
});
// Run the subagent synchronously — blocks until the review completes.
tracing::debug!("[auto-review] running quick review subagent");
let verdict = run_subagent(&ctx, &tx)?;
tracing::info!(
"[auto-review] quick review for '{}': {}",
@@ -139,6 +162,9 @@ pub fn spawn_quick_review(
/// forwarded into the subagent's own context, so a cancelled turn stops
/// retrying immediately instead of burning a second attempt.
///
/// Flow: for attempt in 1..=2 → check abort → build context → spawn drain →
/// `run_subagent` → return Ok on success, log warn on failure.
///
/// Return: `Ok(output)` if either attempt succeeded, `Err(message)`
/// describing the final failure if both attempts failed, or the literal
/// message `"aborted by user"` if `abort_flag` was already set before an
@@ -150,16 +176,25 @@ fn run_subagent_with_retry(
label: &str,
abort_flag: Option<&Arc<AtomicBool>>,
) -> Result<String, String> {
tracing::debug!("[{label}] run_subagent_with_retry starting");
let mut last_err = String::new();
// Retry loop: up to 2 attempts for transient failures.
for attempt in 1..=2 {
// Check the shared abort flag before starting an attempt.
if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) {
tracing::warn!("[{label}] aborted by user before attempt {attempt}");
return Err("aborted by user".to_string());
}
// Build a fresh context for each attempt so state doesn't leak
// between retries.
let mut ctx = build_subagent_context(def);
ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec();
ctx.abort_flag = abort_flag.cloned();
// Spawn drain thread with per-label logging.
let drain_label = label.to_string();
let (tx, _drain) = spawn_subagent_with_drain(move |event| {
if let SubagentEvent::StepFailed { step, error } = &event {
@@ -168,13 +203,19 @@ fn run_subagent_with_retry(
});
match run_subagent(&ctx, &tx) {
Ok(output) => return Ok(output),
Ok(output) => {
let line_count = output.lines().count();
tracing::info!("[{label}] attempt {attempt}/2 succeeded ({line_count} lines)");
return Ok(output);
}
Err(e) => {
tracing::warn!("[{label}] attempt {attempt}/2 failed: {e}");
last_err = e.to_string();
}
}
}
// Both attempts failed.
Err(format!("failed after 2 attempts: {last_err}"))
}
@@ -187,6 +228,15 @@ fn run_subagent_with_retry(
/// `kind` is the internal label used for logging and the `SystemNote` kind
/// (e.g. `"bg-test-gen"`, `"bg-arch-review"`). The human-readable message
/// prefix is derived from this label via [`message_prefix`].
///
/// Flow:
/// 1. Early return if `file_paths` is empty or `running_flag` is already set.
/// 2. Format the prompt from `prompt_constant` + file list.
/// 3. Spawn a dedicated OS thread that:
/// a. Installs a `RunningGuard` for panic-safe flag reset.
/// b. Creates an `AgentDefinition` and calls `run_subagent_with_retry`.
/// c. Formats the result as a `SystemNote` message.
/// d. Pushes the note onto `turn_events` for TUI consumption.
fn spawn_background_review(
kind: &str,
running_flag: &'static AtomicBool,
@@ -199,6 +249,7 @@ fn spawn_background_review(
turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
// Early return: no files to review or another run of this kind is active.
if file_paths.is_empty() {
return;
}
@@ -210,6 +261,7 @@ fn spawn_background_review(
return;
}
// Copy arguments into owned values for the spawned thread.
let sd = session_dir;
let ws = workspaces;
let events = turn_events;
@@ -223,13 +275,20 @@ fn spawn_background_review(
let agent_role = agent_role.to_string();
let prefix = message_prefix(kind);
// Spawn a dedicated OS thread for the background review.
std::thread::spawn(move || {
// RunningGuard resets the flag on drop (including panic unwind).
let _running_guard = RunningGuard(running_flag);
tracing::info!("[{label}] spawning for {} file(s)", file_paths.len());
let def = AgentDefinition::new(agent_name, agent_role).with_system_prompt(prompt_text);
// Run the subagent with a single retry on failure.
let result = run_subagent_with_retry(&def, &sd, &ws, &label, Some(&abort_flag));
// Format the result as a user-facing SystemNote message.
// Errors that mention "aborted" get a soft "cancelled" prefix;
// other errors get an "ESCALATED:" prefix to catch the user's eye.
let message = match &result {
Ok(output) => {
let first = output.lines().next().unwrap_or(output);
@@ -239,16 +298,21 @@ fn spawn_background_review(
Err(e) => format!("ESCALATED: {prefix} {e}"),
};
// Push the SystemNote onto the shared turn_events queue.
if let Ok(mut q) = events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: label,
kind: label.clone(),
message,
});
} else {
tracing::warn!("[{label}] failed to lock turn_events queue — SystemNote dropped");
}
tracing::info!("[{label}] background review thread finished");
});
}
/// Collect the trailing arguments shared by all background-review spawners.
/// Collect the trailing arguments shared by all background-review spawners
/// into owned values, reducing boilerplate in each individual spawner function.
fn review_args<'a>(
file_paths: &'a [String],
session_dir: &'a Path,
@@ -266,6 +330,9 @@ fn review_args<'a>(
}
/// Spawn a background subagent that generates tests for modified files.
///
/// Only fires for production source files (non-test, non-config).
/// Uses `"bg-test-gen"` as its internal kind label.
pub fn spawn_background_test_gen(
file_paths: &[String],
session_dir: &Path,
@@ -273,6 +340,7 @@ pub fn spawn_background_test_gen(
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
tracing::debug!("[auto] spawn_background_test_gen: {} file(s)", file_paths.len());
let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag);
spawn_background_review(
"bg-test-gen", &TEST_GEN_RUNNING,
@@ -282,6 +350,9 @@ pub fn spawn_background_test_gen(
}
/// Spawn a background architecture-review subagent.
///
/// Reviews all reviewable files (source + config, excluding vendored/generated).
/// Uses `"bg-arch-review"` as its internal kind label.
pub fn spawn_background_arch_review(
file_paths: &[String],
session_dir: &Path,
@@ -289,6 +360,7 @@ pub fn spawn_background_arch_review(
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
tracing::debug!("[auto] spawn_background_arch_review: {} file(s)", file_paths.len());
let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag);
spawn_background_review(
"bg-arch-review", &ARCH_REVIEW_RUNNING,
@@ -301,6 +373,7 @@ pub fn spawn_background_arch_review(
///
/// Only reviews production code files for security — test files and
/// config files are out of scope for security review.
/// Uses `"bg-security-review"` as its internal kind label.
pub fn spawn_background_security_review(
file_paths: &[String],
session_dir: &Path,
@@ -308,6 +381,9 @@ pub fn spawn_background_security_review(
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
tracing::debug!("[auto] spawn_background_security_review: {} file(s)", file_paths.len());
// Security review only applies to production code, not tests or config.
let (_, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag);
let prod_paths: Vec<String> = file_paths
.iter()
@@ -321,15 +397,16 @@ pub fn spawn_background_security_review(
);
}
/// Convenience: spawn all applicable background subagents for a set of edited
/// file paths. Called once at the end of a main agent turn.
/// Orchestrate all three background-review subagents after a main-agent turn.
///
/// Flow: always spawns arch-review and security-review if there are
/// reviewable production files → spawns test-gen only if there are source
/// files that aren't already tests.
/// Called once at the end of a turn. Early-returns if `file_paths` is empty.
///
/// `abort_flag` is cloned and forwarded to all three spawn calls so a
/// single cancellation source stops every kind of background review.
/// Flow:
/// 1. Extract production source paths → spawn test-gen + security-review.
/// 2. Extract all reviewable paths → spawn arch-review.
///
/// `abort_flag` is cloned and forwarded to all three so a single cancellation
/// source stops every kind.
pub fn spawn_all_background(
file_paths: &[String],
session_dir: &Path,
@@ -341,7 +418,9 @@ pub fn spawn_all_background(
return;
}
// Background test-gen: only for non-test source files
tracing::debug!("[auto] spawn_all_background: {} file(s)", file_paths.len());
// Background test-gen: only for production source files (non-test, non-config).
let source_paths: Vec<String> = file_paths
.iter()
.filter(|p| is_production_code(p))
@@ -355,7 +434,7 @@ pub fn spawn_all_background(
abort_flag.clone(),
);
// Background arch review: for all files that are reviewable
// Background arch review: for all files that are reviewable (source + config).
let reviewable: Vec<String> = file_paths
.iter()
.filter(|p| is_reviewable_path(p))
@@ -369,7 +448,7 @@ pub fn spawn_all_background(
abort_flag.clone(),
);
// Background security review: only production source files
// Background security review: only production source files (same as test-gen).
spawn_background_security_review(
&source_paths,
session_dir,
@@ -377,6 +456,13 @@ pub fn spawn_all_background(
turn_events,
abort_flag,
);
tracing::debug!(
"[auto] spawned all backgrounds: {} source, {} reviewable across {} total",
source_paths.len(),
reviewable.len(),
file_paths.len(),
);
}
#[cfg(test)]
@@ -385,6 +471,7 @@ mod tests {
#[test]
fn reviewable_path_skips_lockfiles_and_known_extensions() {
// Lockfiles, package configs, and binary assets should not trigger review.
assert!(!is_reviewable_path("Cargo.lock"));
assert!(!is_reviewable_path("package.json"));
assert!(!is_reviewable_path("logo.svg"));
@@ -392,23 +479,30 @@ mod tests {
#[test]
fn reviewable_path_skips_vendored_and_generated_dirs() {
// Generated and vendored directories like target/, node_modules/ should
// be excluded even when the path has no leading slash.
assert!(!is_reviewable_path("target/debug/build.rs"));
assert!(!is_reviewable_path("node_modules/foo/index.js"));
}
#[test]
fn reviewable_path_accepts_ordinary_source_files() {
// Regular source files should always be reviewable.
assert!(is_reviewable_path("src/main.rs"));
}
#[test]
fn production_code_excludes_dedicated_test_directories() {
// Files under directories named test/, tests/, or __tests__/ are not
// production code (even if they have a source file extension).
assert!(!is_production_code("src/tests/foo.rs"));
assert!(!is_production_code("__tests__/baz.test.ts"));
}
#[test]
fn production_code_excludes_test_filename_conventions() {
// Files matching common test-filename patterns (test_*, *_test, *_spec)
// should not be classified as production code.
assert!(!is_production_code("src/foo_test.rs"));
assert!(!is_production_code("src/test_foo.py"));
assert!(!is_production_code("src/foo.spec.ts"));
@@ -417,19 +511,23 @@ mod tests {
#[test]
fn production_code_does_not_false_positive_on_substring_test() {
// Regression: a plain `.contains("test")` would wrongly exclude
// these legitimate production files.
// these legitimate production files because the substring "test"
// appears in names like "attestation" or "latest".
assert!(is_production_code("src/attestation.rs"));
assert!(is_production_code("src/latest/foo.rs"));
}
#[test]
fn production_code_requires_known_source_extension() {
// Non-source files like README.md should not count as production code.
assert!(!is_production_code("README.md"));
assert!(is_production_code("src/main.rs"));
}
#[test]
fn running_guard_resets_flag_on_drop_even_after_panic() {
// Verify that RunningGuard resets the AtomicBool to false when the
// guarded closure panics, ensuring the overlap flag never stays stuck.
static TEST_FLAG: AtomicBool = AtomicBool::new(false);
TEST_FLAG.store(true, Ordering::SeqCst);
let result = std::panic::catch_unwind(|| {
@@ -3,8 +3,18 @@
//! Determines whether a file path is reviewable and whether it represents
//! production code (vs. tests, config, or documentation) — used to decide
//! which background subagents should fire for a given set of modified files.
//!
//! Two main functions:
//! - `is_reviewable_path`: checks extension + filename + vendored-directory
//! heuristics; used by arch-review and the top-level gate.
//! - `is_production_code`: checks test-directory / test-filename conventions
//! vs. known source-code extensions; used by test-gen and security-review.
use tracing;
/// File extensions that should not trigger auto-review (config, lock, data).
///
/// These are non-source-code files that do not benefit from code review.
pub(crate) const SKIP_REVIEW_EXTENSIONS: &[&str] = &[
".lock",
".md",
@@ -22,6 +32,8 @@ pub(crate) const SKIP_REVIEW_EXTENSIONS: &[&str] = &[
];
/// File names that should not trigger auto-review.
///
/// Named well-known non-source files that are never worth reviewing.
pub(crate) const SKIP_REVIEW_FILES: &[&str] = &[
"Cargo.lock",
"yarn.lock",
@@ -33,20 +45,36 @@ pub(crate) const SKIP_REVIEW_FILES: &[&str] = &[
/// Check whether a file path is worth auto-reviewing (not config/lock/data).
///
/// Flow:
/// 1. Normalise the path to lowercase.
/// 2. Check against `SKIP_REVIEW_FILES` (exact suffix match).
/// 3. Check against `SKIP_REVIEW_EXTENSIONS` (extension suffix match).
/// 4. Check for vendored/generated directories (`target`, `node_modules`,
/// `.git`, `vendor`) by path *segment* — not by substring — to avoid
/// false positives like `target/debug/build.rs` (which has no leading `/`).
///
/// Vendored/generated directories are matched by path *segment* rather than
/// a `/target/`-style substring check — the substring form misses paths
/// where the directory is the first component (e.g. `target/debug/build.rs`,
/// which has no leading slash), the same class of bug fixed in
/// `is_production_code` below.
pub fn is_reviewable_path(path: &str) -> bool {
tracing::debug!("[subagent] is_reviewable_path: {path}");
let lower = path.to_lowercase();
// Skip known non-reviewable file names (lockfiles, env files, etc.).
if SKIP_REVIEW_FILES.iter().any(|f| lower.ends_with(f)) {
tracing::debug!("[paths] is_reviewable_path=false (skip filename): {path}");
return false;
}
// Skip known non-source extensions (images, config, docs, etc.).
if SKIP_REVIEW_EXTENSIONS.iter().any(|e| lower.ends_with(e)) {
tracing::debug!("[paths] is_reviewable_path=false (skip extension): {path}");
return false;
}
// Skip paths that are clearly generated or vendored
// Skip paths that are clearly generated or vendored — match by path
// *segment* (not substring) to handle leading-component paths without
// a `/` prefix.
let in_vendored_dir = std::path::Path::new(&lower).components().any(|c| {
matches!(
c,
@@ -55,8 +83,10 @@ pub fn is_reviewable_path(path: &str) -> bool {
)
});
if in_vendored_dir {
tracing::debug!("[paths] is_reviewable_path=false (vendored/generated dir): {path}");
return false;
}
tracing::debug!("[paths] is_reviewable_path=true: {path}");
true
}
@@ -64,6 +94,15 @@ pub fn is_reviewable_path(path: &str) -> bool {
/// (vs. tests, config, or documentation) — used to decide if a test-gen
/// or security-review background subagent should fire.
///
/// Flow:
/// 1. Normalise the path to lowercase.
/// 2. Check each path *segment* for a test-directory name
/// (`test`/`tests`/`__tests__`).
/// 3. Check the file stem for test-filename conventions
/// (`test_*`, `*_test`, `*.test.*`, `*_spec.*`, `spec.*`).
/// 4. If neither test-dir nor test-filename, check the extension against
/// a known set of source-code extensions.
///
/// Matches test-ness by path *segment* (a directory literally named
/// "test"/"tests"/"__tests__") or by filename convention
/// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a
@@ -71,9 +110,11 @@ pub fn is_reviewable_path(path: &str) -> bool {
/// legitimate production files like `src/attestation.rs` or
/// `src/latest/foo.rs`.
pub(crate) fn is_production_code(path: &str) -> bool {
tracing::debug!("[subagent] is_production_code: {path}");
let lower = path.to_lowercase();
let path_obj = std::path::Path::new(&lower);
// Check if any path component is a test directory name.
let in_test_dir = path_obj.components().any(|c| {
matches!(
c,
@@ -82,6 +123,7 @@ pub(crate) fn is_production_code(path: &str) -> bool {
)
});
// Check the file stem (filename without extension) for test/spec conventions.
let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let is_test_filename = file_stem.starts_with("test_")
|| file_stem.ends_with("_test")
@@ -94,13 +136,16 @@ pub(crate) fn is_production_code(path: &str) -> bool {
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("spec"));
// If the path is in a test directory or matches a test filename pattern,
// it is not production code.
if in_test_dir || is_test_filename {
tracing::debug!("[paths] is_production_code=false (test dir or filename): {path}");
return false;
}
// Only source files — use Path::extension() to avoid clippy
// case_sensitive_file_extension_comparisons lint
path_obj
// Only source files count — use Path::extension() to avoid clippy
// case_sensitive_file_extension_comparisons lint.
let is_source = path_obj
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
@@ -120,5 +165,8 @@ pub(crate) fn is_production_code(path: &str) -> bool {
| "h"
| "hpp"
)
})
});
tracing::debug!("[paths] is_production_code={is_source} (ext check): {path}");
is_source
}
@@ -3,6 +3,7 @@
use super::spawn::AgentDefinition;
use std::path::PathBuf;
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use tracing;
/// Default read-only tool names granted to `role == "reviewer"` agents.
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
@@ -37,6 +38,11 @@ pub struct SubagentContext {
/// Return: a context with empty `system_prompt`, empty `workspaces`,
/// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list.
pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
tracing::debug!(
"[subagent-context] building context for role='{}' name='{}'",
def.role,
def.name,
);
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
if def.role == "reviewer" {
REVIEWER_ALLOWED
@@ -8,9 +8,12 @@
//! Core Intelligence picks one of these three tiers per node, matched to
//! what that node's specific directive needs — this keeps the Harness
//! gate meaningful while the node roster itself stays fully dynamic.
//!
//! Tiers (least → most privileged): `read` < `write` < `full`.
/// The three tool-access tiers a hive-mind node can be granted.
pub mod tool_scope {
use tracing;
/// Read-only investigation: no file mutation, no shell, no VCS.
pub const READ: &str = "read";
/// Read-tier plus file mutation and non-destructive shell (tests/builds).
@@ -87,12 +90,22 @@ pub mod tool_scope {
/// Unrecognized scope strings fall back to `READ` — the least-privileged
/// tier — rather than silently granting broader access.
///
/// Flow: match `scope` against the three known constants → return the
/// corresponding static slice → collect into owned `Vec<String>`.
///
/// Return: an owned `Vec<String>` suitable for `AgentDefinition::with_allowed_tools`.
pub fn tools_for(scope: &str) -> Vec<String> {
// Select the tool list matching the requested access tier.
// Unknown scope names are treated as "read" (least privilege).
let tools: &[&str] = match scope {
FULL => FULL_TOOLS,
WRITE => WRITE_TOOLS,
_ => READ_TOOLS,
_ => {
tracing::debug!(
"[division] unknown scope '{scope}' — falling back to READ",
);
READ_TOOLS
}
};
tools.iter().map(|s| (*s).to_string()).collect()
}
@@ -102,6 +115,7 @@ pub mod tool_scope {
mod tests {
use super::tool_scope::{tools_for, FULL, READ, WRITE};
/// Verify the READ tier does not contain write or bash tools.
#[test]
fn read_tier_excludes_write_tools() {
let tools = tools_for(READ);
@@ -109,6 +123,7 @@ mod tests {
assert!(!tools.contains(&"bash".to_string()));
}
/// Verify the WRITE tier includes bash and write but not delete or git.
#[test]
fn write_tier_includes_bash_but_not_delete_or_git() {
let tools = tools_for(WRITE);
@@ -118,6 +133,7 @@ mod tests {
assert!(!tools.contains(&"git_operator".to_string()));
}
/// Verify the FULL tier includes delete and git tools.
#[test]
fn full_tier_includes_delete_and_git() {
let tools = tools_for(FULL);
@@ -125,6 +141,7 @@ mod tests {
assert!(tools.contains(&"git_operator".to_string()));
}
/// Verify that an unrecognized scope name falls back to the READ tier.
#[test]
fn unknown_scope_falls_back_to_read() {
let tools = tools_for("bogus");
@@ -132,6 +149,7 @@ mod tests {
assert!(!tools.contains(&"delete".to_string()));
}
/// Verify the tier hierarchy: READ ⊂ WRITE ⊂ FULL (each is a strict superset).
#[test]
fn read_tier_is_subset_of_write_tier_and_write_is_subset_of_full() {
use std::collections::HashSet;
+136 -21
View File
@@ -1,9 +1,23 @@
//! Subagent execution loop: drive an LLM conversation, run tools, and stream
//! progress events to the parent via an mpsc channel.
//!
//! This is the core orchestrator for all subagent runs — inline reviews,
//! background reviews, and hive-mind processing nodes all pass through
//! [`run_subagent`].
//!
//! Flow: build system prompt (with workspace tree) → cache provider config
//! → for each step up to `max_steps`: check abort flag, call LLM (streaming
//! with abort-per-SSE-event), execute gated tool calls in parallel via
//! `std::thread::scope`, emit progress events, break on first text-only
//! response.
//!
//! Tool gating and pattern-constant definitions live in sibling modules
//! (`gating`, `provider`, `tools`, `workspace`) rather than here, so each
//! concern is independently testable and maintainable.
//!
//! Why synchronous: the loop runs on a dedicated OS thread so the main
//! async event loop is not blocked. All I/O inside tool calls is
//! synchronous (`ureq`, `std::fs`, etc.).
use super::context::SubagentContext;
use super::event::SubagentEvent;
@@ -11,32 +25,55 @@ use super::gating::gate_subagent_tool_call;
use super::provider::{require_api_key, resolve_provider_config};
use super::tools::build_subagent_tools;
use super::workspace::generate_workspace_tree;
use crate::app::util::backoff::backoff_seconds;
use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef;
use crate::tool::tool_is_risky;
use crate::app::util::backoff::backoff_seconds;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing;
/// Exponential backoff with ±25% jitter for subagent step retries, capped at 16s.
///
/// Delegates to `backoff_seconds` with a 16-second cap.
/// Used in the step-level retry loop when an LLM call fails transiently.
fn step_retry_delay(attempt: u32) -> Duration {
tracing::debug!("[subagent] step_retry_delay attempt={attempt}");
backoff_seconds(attempt, 16)
}
/// Heuristic to decide whether the error is worth retrying.
///
/// Never retries:
/// - Authentication / billing errors (waste of time, same result).
/// - Abort / user cancellation (the caller explicitly cancelled).
///
/// Retries everything else: timeout, 5xx, rate-limit, network blip.
fn should_retry_subagent_step(err_str: &str) -> bool {
// Never retry auth/billing failures
// Never retry auth/billing failures — they require user intervention.
if crate::service::provider::is_auth_error(err_str) {
tracing::debug!("[subagent] not retrying auth error: {err_str}");
return false;
}
// Never retry abort or user cancellation
// Never retry an abort or user cancellation.
if err_str.to_lowercase().contains("aborted") {
tracing::debug!("[subagent] not retrying abort: {err_str}");
return false;
}
// Everything else (timeout, 5xx, rate-limit, network blip) is retryable
// Everything else (timeout, 5xx, rate-limit, network blip) is retryable.
tracing::debug!("[subagent] will retry step error: {err_str}");
true
}
/// Format a free-form progress string from streaming LLM output.
///
/// Flow: split text into non-empty lines →
/// - 0 lines → `"{prefix}..."`
/// - 1 line → `"{prefix}: {line}"`
/// - 2+ lines → last 2 lines joined by newline
///
/// The last-2-lines heuristic gives a compact but meaningful progress
/// peek without overwhelming the UI with every intermediate token.
fn format_subagent_progress(prefix: &str, text: &str) -> String {
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
@@ -44,6 +81,8 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String {
} else if lines.len() == 1 {
format!("{prefix}: {}", lines[0])
} else {
// Show the last two meaningful lines of reasoning/response text
// so the user gets the tail of the LLM's current output.
lines[lines.len() - 2..].join("\n")
}
}
@@ -51,24 +90,43 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String {
/// Synchronous subagent entry point: run up to `ctx.max_steps` iterations
/// of the LLM tool loop.
///
/// Flow: inject system prompt (with workspace tree if available) → for each
/// step: resolve provider config, build an LLM client, call
/// `chat_with_tools_streaming` (with abort check per SSE event), process
/// tool calls (gated against both the allowlist and Harness-style content
/// safety checks) or collect text output → send `SubagentEvent`s on `tx` →
/// break on first text-only (non-empty) response.
/// Flow:
/// 1. Build system prompt with optional workspace tree.
/// 2. Build `ToolCtx` (with session dir, workspaces, origin).
/// 3. Build tool list + tool definitions once (before the loop).
/// 4. Cache provider config once (before the loop).
/// 5. Fail fast if no API key is configured.
/// 6. For each step (up to `max_steps`):
/// a. Check abort flag.
/// b. Call LLM via `chat_with_tools_streaming` with per-SSE-event
/// abort checking and up to 3 step-level retries.
/// c. Emit progress / usage / step events on the mpsc channel.
/// d. Execute tool calls in parallel via `std::thread::scope`,
/// each gated by the three-layer pipeline (allowlist → risky →
/// content-safety).
/// e. Auto-share read-only tool results to `workflow_findings`.
/// f. Break on first text-only (non-empty) response.
/// 7. Send `Completed` event and return the accumulated output.
///
/// Why: runs synchronously on a dedicated thread so the main async event
/// Why synchronous: runs on a dedicated OS thread so the main async event
/// loop is not blocked. Tool gating prevents restricted, risky, or
/// malicious/poor-quality tool calls from executing.
///
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
/// call fails at any step.
/// call fails at any step (after exhausting retries).
pub fn run_subagent(
ctx: &SubagentContext,
tx: &mpsc::Sender<SubagentEvent>,
) -> anyhow::Result<String> {
tracing::debug!(
"[subagent] run_subagent starting: max_steps={}, allowed_tools={}",
ctx.max_steps,
ctx.allowed_tools.len(),
);
// Accumulator for the final text output returned to the caller.
let mut output = String::new();
// Conversation history fed to the LLM on each step.
let mut messages: Vec<ChatMessage> = Vec::new();
// Build system prompt with workspace tree context if we have workspaces,
@@ -81,6 +139,8 @@ pub fn run_subagent(
};
messages.push(ChatMessage::system(system_with_context));
// Tool context: provides session dir, workspaces, origin tag, and
// optional workflow-findings Arc to every tool execution.
let tool_ctx = crate::tool::ToolCtx::builder()
.session_dir(ctx.session_dir.clone())
.workspaces(ctx.workspaces.clone())
@@ -88,7 +148,7 @@ pub fn run_subagent(
.workflow_findings(ctx.workflow_findings.clone())
.build();
// Build tool list once before the loop
// Build tool list once before the loop (not on every step).
let (tools, tdefs) = build_subagent_tools(&ctx.allowed_tools);
let tdefs_opt: Option<Vec<ToolDef>> = if tdefs.is_empty() { None } else { Some(tdefs) };
@@ -112,12 +172,19 @@ pub fn run_subagent(
anyhow::bail!(error);
}
// Create the LLM client with the resolved provider config.
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
// ── Main step loop ──
// Each iteration: check abort → call LLM → process tool calls or
// accumulate text output. Breaks on first non-empty text-only response.
for step in 0..ctx.max_steps {
tracing::debug!("[subagent] step {step} starting");
// Check abort flag before each LLM call so a stuck subagent can
// be cancelled from the parent (mirrors main agent behaviour).
if crate::app::util::abort::is_aborted(&ctx.abort_flag) {
tracing::warn!("[subagent] abort detected at step {step}");
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
error: "subagent aborted by parent".to_string(),
@@ -125,9 +192,13 @@ pub fn run_subagent(
anyhow::bail!("subagent aborted by parent at step {step}");
}
// Clone the sender so the SSE-event callback can send progress
// updates without holding a reference to the outer `tx`.
let tx_clone = tx.clone();
// Accumulators for streaming reasoning and reply tokens.
let mut current_thinking = String::new();
let mut current_token = String::new();
// Usage captured from the last streaming event (last writer wins).
let mut step_usage: Option<(u64, u64)> = None;
// Use streaming API so the abort flag is checked per SSE event,
@@ -182,6 +253,7 @@ pub fn run_subagent(
ctx.abort_flag.as_deref(),
);
// ── Handle streaming result ──
match stream_result {
Ok(result) => break result,
Err(e) => {
@@ -189,6 +261,7 @@ pub fn run_subagent(
let is_abort = crate::app::util::abort::is_aborted(&ctx.abort_flag)
|| err_str.contains("aborted");
// Exhausted retries or unrecoverable error — bail.
if is_abort || !should_retry_subagent_step(&err_str) || step_attempt >= max_step_retries {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
@@ -204,6 +277,7 @@ pub fn run_subagent(
anyhow::bail!("subagent call failed at step {step} after {step_attempt} attempt(s): {err_str}");
}
// Transient error — wait with exponential backoff then retry.
let delay = step_retry_delay(step_attempt);
tracing::warn!(
"[subagent] step {step} attempt {step_attempt}/{max_step_retries} failed: {err_str}. \
@@ -215,13 +289,15 @@ pub fn run_subagent(
std::thread::sleep(delay);
}
}
};
}; // end step-level retry loop
// Emit the token usage from this streaming call so the parent's
// drain thread can accumulate it and update the Usage panel.
// Without this, the Usage panel always shows zeros because the
// subagent never tells the parent about the tokens consumed.
// ── Emit token usage ──
// Send the token consumption to the parent's drain thread so the
// Usage panel can accumulate subagent tokens separately from main
// agent tokens.
let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0));
// Fallback estimation: if the provider didn't return usage, estimate
// from character counts (roughly 4 chars per token).
if tok_in == 0 {
let prompt_chars: usize = messages
.iter()
@@ -239,12 +315,14 @@ pub fn run_subagent(
tokens_out: tok_out,
});
// Check whether the response includes tool calls or is text-only.
let has_tool_calls = response.tool_calls.is_some()
&& response
.tool_calls
.as_ref()
.is_some_and(|tc| !tc.is_empty());
// Extract text content from the response (may be empty).
let content = response.content.clone().unwrap_or_default();
// Emit thinking/reasoning text as StepCompleted so the parent's
@@ -255,17 +333,26 @@ pub fn run_subagent(
});
}
// ── Branch: tool calls vs. text-only response ──
if has_tool_calls {
let tool_calls = response.tool_calls.clone().unwrap_or_default();
// Push the assistant message with tool_calls into the conversation
// so the next LLM call sees the tool requests.
messages.push(response);
// Collect results from all parallel tool executions.
let mut results_vec = Vec::new();
// Execute tool calls in parallel using std::thread::scope (scoped
// threads that can borrow from the parent stack).
std::thread::scope(|s| {
let mut handles = Vec::new();
let tools_ref = &tools;
let tool_ctx_ref = &tool_ctx;
for tool_call in &tool_calls {
// Each tool call runs in its own scoped thread so all
// parallel calls execute concurrently.
let handle = s.spawn(move || {
// Check abort flag before each tool execution
if crate::app::util::abort::is_aborted(&ctx.abort_flag) {
@@ -273,10 +360,13 @@ pub fn run_subagent(
}
let tool_name = &tool_call.function.name;
// Sanitise tool arguments to avoid JSON injection in logs.
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
// ── Three-layer gating pipeline ──
// Level 1: allowlist check — is this tool even permitted?
if !generally_allowed {
return (tool_call, Ok(format!("tool '{tool_name}' not allowed for this subagent")));
@@ -288,12 +378,17 @@ pub fn run_subagent(
}
// Level 3: Harness-style content safety gating
// (path traversal, stub/denial/assumption scanning,
// bash exfiltration, destructive commands).
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
return (tool_call, Ok(format!("Blocked by subagent gate: {block_reason}")));
}
// Find the Tool impl by name and execute.
let result = match tools_ref.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => {
// For write/edit: store a pre-edit blob of the file
// so the parent can reconstruct edits for undo/history.
let is_edit = tool_name == "write" || tool_name == "edit";
if is_edit && !tool_call.id.is_empty() {
if let Ok(conn) = crate::model::msglog::open_or_create(&ctx.session_dir) {
@@ -312,8 +407,10 @@ pub fn run_subagent(
}
}
// Execute the tool with the subagent's ToolCtx.
let run_res = tool.run(tool_ctx_ref, &args);
// Log write/edit tool calls for audit trail.
if is_edit && run_res.is_ok() {
let session_id = ctx.session_dir
.file_name()
@@ -332,18 +429,24 @@ pub fn run_subagent(
});
handles.push(handle);
}
// Wait for all parallel tool calls to complete.
for h in handles {
if let Ok(res) = h.join() {
results_vec.push(res);
}
}
});
}); // end std::thread::scope
// ── Process tool results ──
// Iterate over the results (in the same order the handles were
// pushed, which matches the original tool_calls order) and push
// result messages into the conversation.
for (tool_call, result) in results_vec {
let tool_name = &tool_call.function.name;
let args =
crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
// Emit ToolCall event so the parent can show which tool ran.
let _ = tx.blocking_send(SubagentEvent::ToolCall {
tool: tool_name.clone(),
args: args.clone(),
@@ -351,15 +454,19 @@ pub fn run_subagent(
match result {
Ok(output_text) => {
// Push the tool result into the conversation history.
messages.push(ChatMessage::tool_result(
tool_call.id.clone(),
output_text.clone(),
));
// Emit ToolResult event for parent progress tracking.
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
args: args.clone(),
});
// Auto-share read-only tool results into the shared
// workflow_findings so sibling nodes can see them.
let is_readonly = tool_name == "read"
|| tool_name == "view_file"
|| tool_name == "grep"
@@ -374,6 +481,8 @@ pub fn run_subagent(
let args_json =
serde_json::to_string(&args).unwrap_or_default();
let mut shared_text = output_text;
// Cap shared findings at 50 KB to avoid
// unbounded memory in the findings list.
if shared_text.len() > 50_000 {
shared_text.truncate(50_000);
shared_text.push_str("\n...[truncated]");
@@ -385,6 +494,7 @@ pub fn run_subagent(
}
Err(e) => {
let err_str = e.to_string();
// Abort during tool execution — bail immediately.
if err_str.contains("subagent aborted by parent") {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
@@ -392,6 +502,8 @@ pub fn run_subagent(
});
anyhow::bail!("{err_str}");
}
// Push the error as a tool result so the LLM can
// see it and potentially retry.
let msg = format!("tool '{tool_name}' failed: {e}");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
@@ -402,7 +514,7 @@ pub fn run_subagent(
}
}
} else {
// Text-only response — accumulate and finish
// ── Text-only response — accumulate and finish ──
if !content.is_empty() {
output.push_str(&content);
output.push('\n');
@@ -410,13 +522,16 @@ pub fn run_subagent(
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
output: content.clone(),
});
// Break only when we got real content; empty means something went wrong
// Break only when we got real content; empty content means the
// LLM produced no text (rare edge case), and we continue looping.
if !content.is_empty() {
break;
}
}
}
// ── Subagent run complete ──
tracing::debug!("[subagent] run completed, output len={}", output.len());
let _ = tx.blocking_send(SubagentEvent::Completed);
Ok(output)
}
@@ -4,24 +4,41 @@ use serde_json::Value;
/// Progress and outcome events emitted by `run_subagent` as it processes
/// LLM responses and tool calls.
///
/// The parent thread drains these from an mpsc channel and forwards them
/// to the UI or the parent's event system depending on the caller
/// (inline review, background review, or hive-mind node).
#[derive(Debug, Clone)]
pub enum SubagentEvent {
/// One step produced text output — emitted whenever the LLM returns
/// a non-empty `content` field (whether or not tool calls are present).
StepCompleted {
output: String,
},
/// A step failed catastrophically (all retries exhausted, unrecoverable
/// error, or user abort). Includes the step number and the error message.
StepFailed {
step: usize,
error: String,
},
/// Sentinel: the entire subagent loop finished — either by hitting
/// a text-only response (normal path) or after exhausting `max_steps`.
Completed,
/// A tool is about to be invoked. Used for progress reporting so the
/// parent can show which tool is currently running.
ToolCall {
tool: String,
args: Value,
},
/// A tool invocation returned a result (success or error). Used for
/// progress reporting and, in the background-review path, for logging.
ToolResult {
tool: String,
args: Value,
},
/// Free-form progress string emitted during LLM streaming (thinking
/// tokens / reply tokens) or during retry delays. Displayed in the
/// subagent's progress indicator.
Progress(String),
/// Token usage reported by the LLM after one streaming call inside the
/// subagent. The drain thread accumulates these across all steps and
@@ -5,6 +5,14 @@
//! path reads — regardless of the allowed-tools list. Tools that are not
//! risky only get the basic allowlist check.
//!
//! The gating pipeline has three layers, applied in order inside
//! `gate_subagent_tool_call`:
//! 1. **Allowlist check** (in `engine.rs`): is the tool permitted at all?
//! 2. **Risky-tool check** (in `engine.rs`): does a risky tool need
//! explicit permission?
//! 3. **Content-safety check** (this file): stub/denial/assumption scanning,
//! path traversal, bash exfiltration, destructive commands.
//!
//! Security: subagent tool gating mirrors the main agent's `Guard` checks
//! (path traversal, reason validation, stub/denial/assumption scanning,
//! bash exfiltration and destructive-pattern detection) so that subagents
@@ -14,14 +22,25 @@ use crate::app::guard::patterns::{
ASSUMPTION_PATTERNS, DENIAL_PATTERNS, EXFIL_PATTERNS, MIN_REASON_LEN, SENSITIVE_PATH_PATTERNS,
STUB_PATTERNS,
};
use tracing;
/// Gate a tool call in the subagent context. Returns `Some(block_reason)` if
/// the call should be blocked, `None` to allow.
///
/// This is the third and final layer of the three-layer gating pipeline
/// (see module-level docs). It runs content-safety checks that are
/// tool-specific:
/// - `write` / `edit` / `delete`: path traversal, reason length, stub/denial/assumption
/// - `bash`: path traversal, exfiltration, sensitive paths, destructive commands, stubs
/// - `git_operator`: reason length
pub(crate) fn gate_subagent_tool_call(
tool_name: &str,
args: &serde_json::Value,
) -> Option<String> {
// File-mutating tools: write / edit / delete
tracing::debug!("[subagent] gating tool call: {tool_name}");
// ── File-mutating tools: write / edit / delete ──
// Block path-traversal attempts in the `path` argument (e.g. `../../etc`).
if matches!(tool_name, "write" | "edit" | "delete") {
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
if path.contains("..") {
@@ -30,7 +49,7 @@ pub(crate) fn gate_subagent_tool_call(
}
}
// write / edit / delete require a non-trivial `reason`
// write / edit / delete require a non-trivial `reason` explaining the change.
if matches!(tool_name, "write" | "edit" | "delete") {
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason.trim().len() < MIN_REASON_LEN {
@@ -40,14 +59,15 @@ pub(crate) fn gate_subagent_tool_call(
}
}
// write / edit content must not contain stubs, denial, or assumption language
// ── write / edit content must not contain stub, denial, or assumption patterns ──
if matches!(tool_name, "write" | "edit") {
let content = match tool_name {
"write" => args.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"edit" => {
// For edits, scan both old and new text together to catch
// stubs that might appear in either segment.
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
// For edits, scanning old+new together catches stubs in both
return if contains_any(old, STUB_PATTERNS)
|| contains_any(new, STUB_PATTERNS)
{
@@ -71,6 +91,7 @@ pub(crate) fn gate_subagent_tool_call(
}
_ => "",
};
// Scan write content for stub/denial/assumption patterns.
if contains_any(content, STUB_PATTERNS) {
return Some(
"content contains stub/placeholder pattern; production code must be fully implemented"
@@ -91,13 +112,15 @@ pub(crate) fn gate_subagent_tool_call(
}
}
// Bash: exfiltration, sensitive paths, destructive commands
// ── Bash: exfiltration, sensitive-path reads, destructive commands, stubs ──
if tool_name == "bash" {
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
// Block path traversal in bash commands.
if cmd.contains("..") {
return Some("path traversal detected in bash command".to_string());
}
// Only check exfiltration for non-standard commands
// Only check exfiltration for non-standard commands. Standard commands
// (cargo, rustc, git, ls, etc.) are trusted and don't need scanning.
let is_standard = cmd.trim_start().starts_with("cargo")
|| cmd.trim_start().starts_with("rustc")
|| cmd.trim_start().starts_with("git ")
@@ -109,6 +132,7 @@ pub(crate) fn gate_subagent_tool_call(
|| cmd.trim_start().starts_with("grep")
|| cmd.trim_start().starts_with("test");
if !is_standard {
// Scan for data-exfiltration patterns like curl to external hosts.
for pat in EXFIL_PATTERNS {
if cmd.contains(pat) {
return Some(format!(
@@ -117,11 +141,13 @@ pub(crate) fn gate_subagent_tool_call(
}
}
}
// Block commands that read/write sensitive system paths.
for pat in SENSITIVE_PATH_PATTERNS {
if cmd.contains(pat) {
return Some(format!("refused to read/write sensitive path '{pat}'"));
}
}
// Hard-coded destructive command patterns that should never execute.
let dangerous = [
"rm -rf /",
"rm -rf --no-preserve-root",
@@ -142,12 +168,13 @@ pub(crate) fn gate_subagent_tool_call(
return Some(format!("destructive command pattern blocked: {pat}"));
}
}
// Scan for stub patterns in bash commands.
if contains_any(cmd, STUB_PATTERNS) {
return Some("bash command contains stub pattern".to_string());
}
}
// git_operator: require reason
// ── git_operator: require a non-trivial reason ──
if tool_name == "git_operator" {
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason.trim().len() < MIN_REASON_LEN {
@@ -155,10 +182,16 @@ pub(crate) fn gate_subagent_tool_call(
}
}
// All checks passed — allow the tool call.
None
}
/// Check if `text` matches any pattern (case-insensitive substring).
///
/// Normalises both `text` and each pattern to lowercase before comparing.
/// This means patterns like `"TODO"` will also match `"todo"` in source code.
///
/// Return: `true` if any pattern is found as a case-insensitive substring.
pub(crate) fn contains_any(text: &str, patterns: &[&str]) -> bool {
let lower = text.to_lowercase();
patterns.iter().any(|p| lower.contains(&p.to_lowercase()))
@@ -1,5 +1,17 @@
//! Subagent management: spawning, context building, engine loop, and
//! progress events.
//!
//! Module overview:
//! - `auto` — background/auto-review subagents spawned post-turn
//! - `context` — builds `SubagentContext` from `AgentDefinition` with tool allowlists
//! - `division` — hive-mind access tiers (`read` / `write` / `full`) and tool lists
//! - `engine` — synchronous subagent execution loop (LLM + tools)
//! - `event` — subagent lifecycle events (tool calls, results, progress)
//! - `gating` — tool access gating per agent definition
//! - `provider` — LLM provider resolution for subagent calls
//! - `spawn` — `AgentDefinition` and `TurnCtx` types for configuring subagents
//! - `tools` — tool set construction for the subagent harness
//! - `workspace` — workspace tree generation for the system prompt
pub mod auto;
pub mod context;
pub mod division;
@@ -3,30 +3,50 @@
//! Resolves the API key, model, and base URL from persisted app config,
//! matching the main agent's credential resolution exactly, so subagents
//! automatically inherit the same provider settings.
//!
//! Flow: `resolve_provider_config()` loads settings + app config from disk,
//! then delegates to `crate::service::provider::resolve_api_key` for the
//! three-tier key resolution. `require_api_key()` provides a fast-fail check
//! before the first LLM call.
use tracing;
use zesdex_cms::domain::repository::AppConfigRepository;
use zesdex_cms::domain::repository::SettingsRepository;
/// 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.
/// Flow:
/// 1. Load `JsonSettingsRepository` from the store base directory.
/// 2. Load `JsonAppConfigRepository` from the same directory.
/// 3. Delegate to `crate::service::provider::resolve_api_key` for the
/// three-tier key resolution (settings key → env var → default).
/// 4. Extract `model` from settings and `base_url` from the app config.
///
/// Return: `(api_key, model, optional_base_url, provider_name)`. `api_key`
/// is empty when every resolution path was exhausted — callers must check
/// for this before issuing requests (see `run_subagent`).
///
/// Logging: emits a `tracing::warn!` when the key is empty after all
/// resolution paths have been tried.
pub(crate) fn resolve_provider_config() -> (String, String, Option<String>, String) {
tracing::debug!("[subagent] resolving provider config");
// Load the base store directory from the global Store singleton.
let store_base_dir = zesdex_entities::domain::common::store::Store::new().base_dir;
// Load user settings (provider choice, model, API key reference).
let settings =
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
// Load app config (per-provider base URLs, API key overrides).
let app_config =
zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
// Resolve the actual API key through the three-tier fallback pipeline.
let api_key = crate::service::provider::resolve_api_key(&settings, &app_config);
if api_key.is_empty() {
tracing::warn!(
@@ -34,18 +54,28 @@ pub(crate) fn resolve_provider_config() -> (String, String, Option<String>, Stri
settings.provider
);
}
// Model name from settings; optional base URL override from app config.
let model = settings.model.clone();
let base_url = app_config
.providers
.get(&settings.provider)
.map(|p| p.api_base.clone());
tracing::debug!(
"[subagent] resolved provider='{}' model='{}' key_len={}",
settings.provider, model, api_key.len(),
);
(api_key, model, base_url, settings.provider)
}
/// Reject an empty API key with an actionable error instead of letting the
/// caller send a request that is guaranteed to fail once it reaches the network.
///
/// Used as a fast-fail check in `run_subagent` before the first LLM call,
/// saving a full retry cycle against an unauthenticated endpoint.
///
/// Return: `Ok(())` if `api_key` is non-empty, `Err` with a message naming
/// `provider` and where to fix it otherwise.
pub(crate) fn require_api_key(api_key: &str, provider: &str) -> anyhow::Result<()> {
@@ -1,4 +1,4 @@
//! `AgentDefinition` -- declarative specification for instantiating a
//! `AgentDefinition` declarative specification for instantiating a
//! subagent from workflow scripts or programmatic calls.
//!
//! Also provides a shared [`spawn_subagent_with_drain`] helper that
@@ -7,16 +7,27 @@
use super::event::SubagentEvent;
use serde::{Deserialize, Serialize};
use tracing;
/// Declarative specification for instantiating a subagent: name, role,
/// optional system prompt, allowed tools, step budget, and temperature.
///
/// Created via `AgentDefinition::new(name, role)` and customised through
/// builder methods (`.with_system_prompt()`, `.with_allowed_tools()`, etc.).
/// Consumed by `build_subagent_context` to produce a `SubagentContext`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
/// Human-readable name for logging and debugging (e.g. `"quick-reviewer"`).
pub name: String,
/// Functional role used for tool-default resolution (`"reviewer"`, `"coder"`).
pub role: String,
/// Optional system prompt to override the context builder's default.
pub system_prompt: Option<String>,
/// Optional tool allowlist. `None` means role-based defaults apply.
pub allowed_tools: Option<Vec<String>>,
/// Optional step budget. `None` means no limit (usize::MAX).
pub max_steps: Option<usize>,
/// Optional temperature override for the LLM call.
pub temperature: Option<f32>,
}
@@ -24,6 +35,7 @@ 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 {
tracing::debug!("[subagent] AgentDefinition::new(name={name}, role={role})");
AgentDefinition {
name,
role,
@@ -36,27 +48,40 @@ impl AgentDefinition {
/// Builder method: set the system prompt for this agent.
pub fn with_system_prompt(mut self, prompt: String) -> Self {
tracing::debug!("[subagent] AgentDefinition::with_system_prompt(len={})", prompt.len());
self.system_prompt = Some(prompt);
self
}
/// Builder method: set the allowed tool list for this agent.
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
tracing::debug!("[subagent] AgentDefinition::with_allowed_tools(count={})", tools.len());
self.allowed_tools = Some(tools);
self
}
/// Builder method: set the maximum step count for this agent.
pub fn with_max_steps(mut self, steps: usize) -> Self {
tracing::debug!("[subagent] AgentDefinition::with_max_steps({steps})");
self.max_steps = Some(steps);
self
}
/// Builder method: set the temperature override for this agent's LLM calls.
pub fn with_temperature(mut self, temperature: f32) -> Self {
tracing::debug!("[subagent] AgentDefinition::with_temperature({temperature})");
self.temperature = Some(temperature);
self
}
}
/// Shared subagent spawning utility: creates an mpsc channel and spawns a
/// drain thread that forwards every [`SubagentEvent`] to `on_event`.
///
/// Flow: create a 32-capacity mpsc channel → spawn a dedicated OS thread
/// that blocks on `rx.blocking_recv()` and calls `on_event` for each event
/// → return the sender + thread handle.
///
/// Returns the sender half (for passing to [`run_subagent`](super::engine::run_subagent))
/// and the drain thread's join handle so the caller can keep it alive for
/// the duration of the subagent run.
@@ -93,11 +118,20 @@ pub fn spawn_subagent_with_drain<F>(
where
F: Fn(SubagentEvent) + Send + 'static,
{
tracing::debug!("[subagent] spawning subagent drain thread (channel cap=32)");
// Create an mpsc channel with capacity 32 — enough for typical subagent
// event bursts without unbounded memory growth.
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
// Spawn a dedicated OS thread that blocks on blocking_recv, forwarding
// each event to the caller's callback. The thread exits when the channel
// is closed (all senders dropped).
let drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
on_event(event);
}
});
(tx, drain)
}
@@ -1,14 +1,28 @@
//! Subagent tool filtering: maps a subagent's allowed tool names to
//! concrete Tool trait objects and 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.
//! Flow: load `all_tools()` → if `allowed_tools` is empty, use all (minus
//! orchestration tools `hive_mind` / `workflow_run`); else filter by
//! membership → derive `ToolDef`s for the LLM request body.
//!
//! Orchestration tools are excluded from subagents because the subagent
//! should not be able to spawn its own sub-subagents or run workflows.
use crate::dto::provider::request::ToolDef;
use crate::tool::{all_tools, tool_defs};
use tracing;
/// Build the tool list for a subagent from its allowlist.
///
/// Flow:
/// 1. Load all available tools from `tool::all_tools()`.
/// 2. If `allowed_tools` is empty (no restriction), include every tool
/// except `hive_mind` and `workflow_run`.
/// 3. Otherwise, filter by membership in `allowed_tools`, still excluding
/// the two orchestration tools.
/// 4. Derive OpenAI-compatible JSON schema definitions (`ToolDef`) from
/// the filtered list.
///
/// An empty allowlist means "no restriction" (matches
/// `build_subagent_context`'s default for non-reviewer roles).
///
@@ -16,7 +30,14 @@ use crate::tool::{all_tools, tool_defs};
pub(crate) fn build_subagent_tools(
allowed_tools: &[String],
) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
tracing::debug!("[subagent] building tools from {} allowed entries", allowed_tools.len());
// Load all registered tools from the global tool registry.
let all = all_tools();
let total = all.len();
// Filter: empty allowlist = unrestricted (minus orchestration tools).
// Otherwise, keep only tools in the allowlist.
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
all.into_iter()
.filter(|t| t.name() != "hive_mind" && t.name() != "workflow_run")
@@ -30,6 +51,14 @@ pub(crate) fn build_subagent_tools(
})
.collect()
};
tracing::debug!(
"[subagent] filtered {} tools (from {total} total) for subagent",
filtered.len(),
);
// Generate OpenAI-compatible tool definitions for the LLM request.
let defs = tool_defs(&filtered);
(filtered, defs)
}
@@ -1,42 +1,69 @@
//! Workspace directory-tree generation for subagent system prompts.
//!
//! Build an ASCII tree of the workspace directory structure so the LLM
//! can see the file layout.
//! can see the file layout — this is the same tree shown to the main
//! agent and gives subagents the same project-awareness.
//!
//! Flow: for each workspace root, walk using `ignore::WalkBuilder`
//! (respecting `.gitignore` and hidden files) → prefix `[DIR]` for
//! directories → truncate after 1000 entries to keep the prompt
//! reasonably sized.
use std::fmt::Write;
use tracing;
/// 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.
/// truncate after 1000 entries to keep the system prompt under control.
///
/// The tree is appended to the subagent's system prompt so the LLM can
/// reference file paths without having seen them in conversation.
///
/// Return: a multi-line string containing the ASCII tree, or an empty
/// string preamble + entries if no roots are provided.
pub(crate) fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
tracing::debug!("[subagent] generating workspace tree for {} root(s)", roots.len());
let mut out = String::new();
out.push_str("Current Workspace Directory Structure:\n");
for root in roots {
// Print the root path as a section header.
writeln!(out, "Root: {}", root.display()).unwrap();
// Walk the directory tree using ignore::WalkBuilder, which respects
// .gitignore rules and hidden files by default.
let walker = ignore::WalkBuilder::new(root)
.hidden(true)
.git_ignore(true)
.build();
let mut count = 0;
for entry in walker.flatten() {
let path = entry.path();
if let Ok(rel) = path.strip_prefix(root) {
// Skip the root entry itself (empty relative path).
if rel.as_os_str().is_empty() {
continue;
}
// Prefix directories with [DIR] for visual clarity.
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let prefix = if is_dir { "[DIR] " } else { " " };
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
count += 1;
// Hard cap at 1000 entries to avoid blowing up the prompt.
if count > 1000 {
tracing::info!("[subagent] workspace tree truncated at 1000 entries for '{}'", root.display());
out.push_str(" ... (truncated)\n");
break;
}
}
}
}
tracing::debug!("[subagent] workspace tree generated ({} entries across {} roots)", out.lines().count(), roots.len());
out
}
@@ -8,6 +8,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Check whether an optional abort flag has been signalled.
///
/// `Ordering::SeqCst` is used throughout to guarantee cross-thread
/// visibility of the abort signal regardless of the caller's memory model.
pub fn is_aborted(flag: &Option<Arc<AtomicBool>>) -> bool {
flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst))
}
@@ -12,7 +12,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// `max_secs` sets the cap.
pub fn backoff_seconds(attempt: u32, max_secs: u64) -> Duration {
let base_secs = (2u64).pow(attempt).min(max_secs);
let quarter = (base_secs * 250_000_000).max(100_000_000); // 25% of base, min 100ms
// 25% of base (in nanoseconds), floored at 100ms so very low
// attempts still have meaningful jitter.
let quarter = (base_secs * 250_000_000).max(100_000_000);
let offset = jitter_ns(quarter * 2); // [0, 50% of base)
// ±25%: offset in [0, 2×quarter), result = base + offset - quarter
// which lies in [base - 25%, base + 25%).
@@ -21,6 +23,9 @@ pub fn backoff_seconds(attempt: u32, max_secs: u64) -> Duration {
}
/// Return a jitter offset in the range [0, range_ns).
///
/// Uses sub-nanosecond wall-clock bits as a cheap PRNG source — no
/// need for a full RNG for ±25% backoff jitter.
fn jitter_ns(range_ns: u64) -> u64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
+4 -1
View File
@@ -1,4 +1,7 @@
//! Utility modules for shared helpers.
//! Utility modules for shared helpers used across the app.
//!
//! - [`abort`]: cross-thread abort signalling for streaming LLM responses.
//! - [`backoff`]: exponential backoff with jitter for retryable operations.
pub mod abort;
pub mod backoff;
@@ -37,6 +37,7 @@ pub fn write_hive_mind_convergence(
let content = render_report(user_request, ts.timestamp_millis(), reports, consensus);
std::fs::write(&path, content)?;
tracing::info!("[docs] wrote convergence report to {:?}", path);
Ok(path)
}
@@ -4,6 +4,9 @@
//! for running a complete `WorkflowScript`. They create an isolated findings
//! scope and delegate to `execute_primitive`, then format the results into a
//! human-readable summary string.
//!
//! Flow: parse script options → create findings Arc → call `execute_primitive`
//! → format the collected agent outputs into a summary string.
use crate::app::workflow::script::WorkflowScript;
use std::collections::HashMap;
@@ -11,6 +14,7 @@ use std::sync::{
atomic::AtomicBool,
Arc, Mutex,
};
use tracing;
use super::primitives::{execute_primitive, PrimitiveCtx};
use super::LiveStateFn;
@@ -48,6 +52,12 @@ pub fn run_workflow_tracked(
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> {
tracing::debug!(
"[workflow-exec] running '{}' with {} arg(s), max_concurrency={}",
script.name,
args.len(),
script.options.max_concurrency,
);
let concurrency_cap = if script.options.max_concurrency > 0 {
script.options.max_concurrency.min(10) // allow up to 10 parallel agents
} else {
@@ -68,6 +78,12 @@ pub fn run_workflow_tracked(
timeout_ms: script.options.timeout_ms,
})?;
tracing::debug!(
"[workflow-exec] '{}' returned {} result(s)",
script.name,
results.len(),
);
let summary = if results.is_empty() {
"workflow completed with no output".to_string()
} else {
@@ -90,6 +90,20 @@ impl WorkflowEngine {
pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// Bundled context for spawning a single subagent.
///
/// Fields:
/// - `agent_id` — unique UUID for UI tracking
/// - `agent_name` — human-readable name (e.g. "Node-0-1")
/// - `prompt` — the agent's directive text
/// - `role` — agent role string (e.g. "worker", "reviewer")
/// - `allowed_tools` — optional tool allowlist override
/// - `findings_snapshot` — snapshot of sibling findings at spawn time
/// - `findings` — shared Arc for writing findings during execution
/// - `abort_flag` — shared abort signal
/// - `live` — optional live-state callback for TUI updates
/// - `session_dir` — session directory for tool file operations
/// - `workspaces` — workspace roots for path resolution
/// - `timeout_ms` — optional per-agent timeout in milliseconds
pub(crate) struct SpawnCtx<'a> {
pub agent_id: &'a str,
pub agent_name: &'a str,
@@ -224,6 +238,13 @@ fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result<String> {
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
tracing::debug!(
"[workflow] spawning agent '{}' (id={}, role={})",
sp.agent_name,
sp.agent_id,
sp.role,
);
let started_at = chrono::Utc::now().timestamp_millis();
// Notify UI: this agent is now running.
@@ -400,6 +421,8 @@ fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result<String> {
let retry_backoff =
|attempt: u32| crate::app::util::backoff::backoff_seconds(attempt, 8);
// Retry loop: up to 2 attempts. Transient network errors are retried
// with jittered backoff; auth errors terminate immediately.
for attempt in 1..=2 {
// Don't retry if aborted.
if crate::app::util::abort::is_aborted(&bg_abort_thread)
@@ -434,6 +457,10 @@ fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result<String> {
unreachable!()
});
// Poll for subagent completion with 200ms intervals.
// Two modes:
// - With timeout: enforce a hard deadline; return TimedOut error if exceeded.
// - Without timeout: poll indefinitely (still honouring abort_flag).
let poll_interval = Duration::from_millis(200);
let result = if let Some(timeout) = sp.timeout_ms {
let deadline = Duration::from_millis(timeout);
@@ -1,25 +1,47 @@
//! Phase orchestration: execute a script primitive as a named workflow phase.
//!
//! The primary entry-point is `execute_phase`, which delegates to the inner
//! script through `execute_primitive` with a forwarded execution context.
//! This module provides the top-level phase execution entry point used by the
//! cycle-runner inside [`super::hive_mind`]. Each phase wraps a single
//! [`ScriptPrimitive`] — which may be an atomic tool, a parallel fan-out, a
//! sequential block, or a sub-agent turn — and delegates the actual execution
//! to [`execute_primitive`].
//!
//! Flow:
//! `execute_phase(script, ctx)` → forwards the ctx with the script as the
//! active primitive → `execute_primitive` dispatches by variant → returns
//! collected output lines.
//!
//! Phase boundaries are lightweight: there is no extra error wrapping, retry
//! logic, or result transformation beyond what the inner primitive already
//! provides.
use tracing;
use crate::app::workflow::script::ScriptPrimitive;
use super::primitives::{execute_primitive, PrimitiveCtx};
/// Execute a phase by recursing into its inner script primitive with the
/// same execution context.
/// Execute one workflow phase by recursively dispatching its inner script
/// primitive.
///
/// The `script` is the primitive to run; `pc` supplies the execution context
/// (arguments, concurrency cap, abort coordination, TUI progress handle,
/// session directory, workspace list, findings accumulator, and timeout).
///
/// Returns a `Vec<String>` of output lines collected from the primitive's
/// execution, or an error if the primitive itself returned one.
pub fn execute_phase(script: &ScriptPrimitive, pc: &PrimitiveCtx) -> anyhow::Result<Vec<String>> {
tracing::debug!(?script, "execute_phase: entering");
// Forward the entire execution context unchanged, substituting only the
// primitive slot so that deeper recursion sees the same args / flags.
execute_primitive(PrimitiveCtx {
primitive: script,
args: pc.args,
concurrency_cap: pc.concurrency_cap,
continue_on_error: pc.continue_on_error,
abort_flag: pc.abort_flag,
live: pc.live,
session_dir: pc.session_dir,
workspaces: pc.workspaces,
findings: pc.findings,
timeout_ms: pc.timeout_ms,
primitive: script, // the script to execute
args: pc.args, // CLI/LLM-supplied arguments forwarded verbatim
concurrency_cap: pc.concurrency_cap, // max parallel sub-processes
continue_on_error: pc.continue_on_error, // whether to keep going on failure
abort_flag: pc.abort_flag, // shared atomic abort signal
live: pc.live, // TUI progress reporter
session_dir: pc.session_dir, // scratch directory for this session
workspaces: pc.workspaces, // workspace directories for tool access
findings: pc.findings, // mutable finding accumulator
timeout_ms: pc.timeout_ms, // per-primitive timeout in ms
})
}
@@ -1,13 +1,28 @@
//! Primitive types and the recursive `execute_primitive` interpreter.
//!
//! This is the heart of the workflow engine: it walks the `ScriptPrimitive`
//! tree and dispatches each variant to the appropriate execution strategy
//! (single agent, parallel threads, sequential pipeline, or phase delegate).
//! tree and dispatches each variant to the appropriate execution strategy:
//!
//! Concurrency for `Parallel` branches is managed by a simple mutex-based
//! counting semaphore whose permits are released on drop, so a panicked
//! thread never leaks permits.
//! | Variant | Strategy |
//! |---------------|------------------------------------------------|
//! | `Agent` | Single agent turn via `spawn_single_agent` |
//! | `ScopedAgent` | Agent turn with restricted tool access |
//! | `Parallel` | OS-thread fan-out, semaphore-gated concurrency |
//! | `Pipeline` | Sequential stages, abort-checked between each |
//! | `Phase` | Recursive delegation (pass-through wrapper) |
//!
//! ## Concurrency model (`Parallel`)
//! Parallel branches use OS threads guarded by a simple mutex-based counting
//! semaphore so the main async event loop never blocks. Permits are released
//! automatically on `Drop` (panic-safe — poisoned mutexes are recovered).
//!
//! ## Findings isolation
//! Findings live in an `Arc<Mutex<Vec<String>>>` rather than a global static,
//! so concurrent workflow runs are fully isolated from each other. Pipeline
//! stages share the same findings scope so stage N's output is visible to
//! stage N+1.
use tracing;
use crate::app::workflow::script::ScriptPrimitive;
use std::collections::HashMap;
use std::sync::{
@@ -24,30 +39,42 @@ use super::{spawn_single_agent, LiveStateFn, SpawnCtx};
/// A counting semaphore built from a `Mutex` + `Condvar`.
///
/// Used by `execute_primitive` to cap concurrent parallel branches.
/// This is intentionally simple — no external dependencies.
///
/// Panic-safety: if a thread panics while holding a permit, the Mutex
/// becomes poisoned. Both `acquire` and the `Drop` implementation recover
/// from poisoned mutexes by discarding the poison, ensuring the semaphore
/// remains usable after a thread panic.
/// ## Panic-safety
/// If a thread panics while holding a permit, the Mutex becomes poisoned.
/// Both `acquire` and the `Drop` implementation recover from poisoned
/// mutexes by discarding the poison, ensuring the semaphore never leaks
/// permits even across panics.
struct Semaphore {
/// Current number of available permits.
count: Mutex<usize>,
/// Signalled when a permit is released so waiters can wake up.
condvar: std::sync::Condvar,
}
impl Semaphore {
/// Create a new semaphore with `count` initial permits.
fn new(count: usize) -> Self {
tracing::debug!(count, "Semaphore::new");
Semaphore {
count: Mutex::new(count),
condvar: std::sync::Condvar::new(),
}
}
/// Acquire one permit, blocking until one is available.
///
/// Flow: lock count → spin while zero → decrement → return guard.
/// The guard releases the permit on drop.
fn acquire(&self) -> SemaphoreGuard<'_> {
tracing::debug!("Semaphore::acquire: waiting for permit");
let mut count = self.count.lock().unwrap_or_else(|e| {
tracing::warn!("[semaphore] mutex poisoned in acquire, recovering");
e.into_inner()
});
while *count == 0 {
// No permits available — block on the condition variable.
count = self.condvar.wait(count).unwrap_or_else(|e| {
tracing::warn!("[semaphore] mutex poisoned in wait, recovering");
e.into_inner()
@@ -58,12 +85,18 @@ impl Semaphore {
}
}
/// RAII guard returned by [`Semaphore::acquire`].
///
/// The permit is released back to the semaphore when this guard is dropped.
struct SemaphoreGuard<'a> {
/// Back-reference to the parent semaphore.
sem: &'a Semaphore,
}
impl Drop for SemaphoreGuard<'_> {
/// Release the permit back to the semaphore and wake one waiter.
fn drop(&mut self) {
tracing::debug!("SemaphoreGuard::drop: releasing permit");
let mut count = self.sem.count.lock().unwrap_or_else(|e| {
tracing::warn!("[semaphore] mutex poisoned in drop, recovering");
e.into_inner()
@@ -80,16 +113,37 @@ impl Drop for SemaphoreGuard<'_> {
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// Bundled context for executing a script primitive.
///
/// Carries everything the recursive interpreter needs: the primitive to run,
/// template arguments, concurrency limits, abort coordination, TUI progress
/// reporting, filesystem paths, the findings accumulator, and a per-primitive
/// timeout. Immutable after construction (except the findings Arc, which is
/// mutated by running agents).
pub(crate) struct PrimitiveCtx<'a> {
/// The script primitive to execute (Agent / ScopedAgent / Parallel / etc.).
pub primitive: &'a ScriptPrimitive,
/// Template variables injected into agent prompts via `{{key}}` syntax.
pub args: &'a HashMap<String, String>,
/// Maximum number of concurrent Parallel branches (OS threads).
pub concurrency_cap: usize,
/// If true, agent/phase errors are captured as output strings rather than
/// propagated — the workflow continues with the remaining stages.
pub continue_on_error: bool,
/// Optional shared atomic flag that, when set to `true`, signals all
/// in-flight agents and pipeline stages to abort early.
pub abort_flag: &'a Option<Arc<AtomicBool>>,
/// Optional handle for reporting live agent progress to the TUI panel.
pub live: Option<&'a LiveStateFn>,
/// Scratch directory for this workflow session.
pub session_dir: &'a std::path::Path,
/// Workspace directories available for tool access.
pub workspaces: &'a [std::path::PathBuf],
/// Shared accumulator for inter-stage findings. Each agent can append
/// structured observations; Pipeline stages and sibling Parallel branches
/// observe them through `resolve_template`.
pub findings: &'a Arc<Mutex<Vec<String>>>,
/// Optional per-primitive timeout in milliseconds. Propagated to
/// individual agent spawns so no single turn can exceed the deadline.
pub timeout_ms: Option<u64>,
}
@@ -100,11 +154,22 @@ pub(crate) struct PrimitiveCtx<'a> {
/// Simple template engine: replace `{{key}}` placeholders with values
/// from `args`.
///
/// Why: a structured template engine is unnecessary for the limited
/// use-case; this is intentionally simple and safe.
/// Flow: clone template → iterate args → string-replace each `{{key}}` →
/// return resolved string.
///
/// Why: A structured template engine (e.g. tera, handlebars) is unnecessary
/// for the limited use-case here. This is intentionally simple, safe, and
/// dependency-free. It only supports top-level substitution — no filters,
/// conditionals, or iteration.
///
/// Edge case: if `args` contains a key that is also the value of another
/// key, the second replacement may hit the already-substituted part. This
/// is not an issue in practice because prompt templates do not nest.
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
tracing::debug!("resolve_template: {} bytes, {} args", template.len(), args.len());
let mut result = template.to_string();
for (key, value) in args {
// Replace `{{key}}` (including the braces) with the corresponding value.
result = result.replace(&format!("{{{{{key}}}}}"), value);
}
result
@@ -137,9 +202,15 @@ fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted.
pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
tracing::debug!("execute_primitive: dispatching variant");
match pc.primitive {
ScriptPrimitive::Agent(prompt) => {
tracing::debug!("Agent arm: starting single-agent turn");
// Clone args so we can inject the `findings` key without
// mutating the caller's original args map.
let mut resolved_args = pc.args.clone();
// Snapshot current findings so the agent sees prior output
// from earlier pipeline stages or sibling branches.
let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
let formatted_findings = if findings_snapshot.is_empty() {
@@ -187,6 +258,7 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
node_id,
tool_scope,
} => {
tracing::debug!(node_id, ?tool_scope, "ScopedAgent arm: deploying drone");
let mut resolved_args = pc.args.clone();
let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
@@ -249,6 +321,11 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
}
ScriptPrimitive::Parallel(scripts) => {
tracing::debug!(
branch_count = scripts.len(),
cap = pc.concurrency_cap,
"Parallel arm: fanning out branches"
);
// All branches run concurrently, capped by semaphore.
// This is the primary advantage over single-turn chat: multiple
// independent subagents work simultaneously.
@@ -314,6 +391,10 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
}
ScriptPrimitive::Pipeline(scripts) => {
tracing::debug!(
stage_count = scripts.len(),
"Pipeline arm: starting sequential stages"
);
// Sequential: each stage runs only after the previous completes.
//
// Abort is checked between stages so the user can cancel the
@@ -324,7 +405,7 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
// stages are supposed to build on each other's output. Findings
// written by stage N are visible to stage N+1 through the shared
// `findings` Arc (same isolation scope as parent).
let mut all = Vec::new();
let mut all = Vec::new(); // accumulated output across all stages
for (idx, script) in scripts.iter().enumerate() {
// Check abort before each pipeline stage so we don't
// launch the next division after the user cancelled.
@@ -367,6 +448,9 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
ScriptPrimitive::Phase {
name: _name,
script,
} => super::phases::execute_phase(script, &pc),
} => {
tracing::debug!(phase_name = _name, "Phase arm: delegating to execute_phase");
super::phases::execute_phase(script, &pc)
}
}
}
@@ -1,90 +1,84 @@
//! Request-complexity heuristic for the Hive Mind.
//!
//! `is_complex_request` determines whether LO's request is worth stirring
//! the Hive for, based on string heuristics (length, keywords, sentence
//! count).
//! `is_complex_request` determines whether LO's (the user's) request merits
//! deploying the full Hive-Mind orchestration pipeline, or whether it can be
//! handled inline by a single agent turn.
//!
//! ## Design rationale
//! The heuristic is intentionally string-based (length, keywords, sentence
//! count) rather than LLM-invoking — calling the LLM to decide whether to
//! call the LLM would be wasteful and recursive. False positives (a simple
//! request getting the Hive) are acceptable because the Core Intelligence
//! still compiles the plan; false negatives (a complex request getting a
//! single turn) are the real risk, mitigated by the fact that a capable
//! single agent often handles moderate complexity anyway.
use tracing;
/// Determine whether LO's request is worth stirring the Hive for. The
/// Hive's plan shape (cycle count, directives, access tiers) is entirely
/// up to the Core Intelligence; this only gates whether the Hive is asked
/// to design one at all.
///
/// Simple = single file, minor fix, quick lookup, config change — handle
/// inline without disturbing the Hive.
/// Complex = new feature, multi-file refactor, architecture change — the
/// Hive must be deployed.
/// ## Classification
/// - **Simple**: single file, minor fix, quick lookup, config change —
/// handle inline without disturbing the Hive.
/// - **Complex**: new feature, multi-file refactor, architecture change —
/// the Hive must be deployed.
///
/// Heuristics:
/// - Very short requests (< 10 chars) are never complex the Hive rests.
/// - Negative keywords (simple/trivial/typo/quick) skip planning.
/// - Positive keywords (refactor/api/implement/architecture) rouse the Hive.
/// - Multi-sentence requests are more likely complex.
/// ## Heuristics (applied in order)
/// 1. Requests < 10 chars never complex (the Hive rests).
/// 2. Negative keywords (`simple`, `trivial`, `typo`, `quick`, etc.) →
/// skip planning.
/// 3. Multi-sentence (≥3 sentences) → likely complex.
/// 4. Positive keywords (`refactor`, `api`, `implement`, `architecture`,
/// etc.) → rouse the Hive.
/// 5. Otherwise → not complex (safe default).
pub fn is_complex_request(request: &str) -> bool {
tracing::debug!(len = request.len(), "is_complex_request: evaluating");
let trimmed = request.trim();
// Very short requests are never complex
// Rule 1: Very short requests are never complex enough to warrant Hive orchestration.
if trimmed.len() < 10 {
tracing::debug!("is_complex_request: too short → false");
return false;
}
// Single-line simple update patterns
// Rule 2: Check for negative keywords that indicate a simple change.
let lower = trimmed.to_lowercase();
let negative_keywords = [
"simple",
"trivial",
"typo",
"just a",
"only a",
"minor",
"quick",
"tiny",
"small fix",
"rename",
"nitpick",
"cosmetic",
"formatting",
"spelling",
"grammar",
"bump",
"version bump",
"update comment",
"simple", "trivial", "typo", "just a", "only a", "minor", "quick",
"tiny", "small fix", "rename", "nitpick", "cosmetic", "formatting",
"spelling", "grammar", "bump", "version bump", "update comment",
];
if negative_keywords.iter().any(|k| lower.contains(k)) {
tracing::debug!("is_complex_request: negative keyword match → false");
return false;
}
// Multi-line/multi-sentence → likely complex
// Rule 3: Count sentences by splitting on sentence terminators.
// Multiple sentences suggest a multi-step request.
let sentences = trimmed
.split(['.', '!', '?'])
.filter(|s| !s.trim().is_empty())
.count();
if sentences >= 3 {
tracing::debug!(sentences, "is_complex_request: multi-sentence → true");
return true;
}
// Positive complexity keywords
// Rule 4: Check for positive complexity keywords that suggest
// multi-file or architectural work.
let complexity_keywords = [
"refactor",
"redesign",
"architecture",
"feature",
"implement",
"migrate",
"restructure",
"rewrite",
"new module",
"new component",
"scaffold",
"multi",
"multiple files",
"api",
"endpoint",
"integration",
"system",
"workflow",
"pipeline",
"database",
"authentication",
"authorization",
"full stack",
"refactor", "redesign", "architecture", "feature", "implement",
"migrate", "restructure", "rewrite", "new module", "new component",
"scaffold", "multi", "multiple files", "api", "endpoint",
"integration", "system", "workflow", "pipeline", "database",
"authentication", "authorization", "full stack",
];
complexity_keywords.iter().any(|k| lower.contains(k))
let result = complexity_keywords.iter().any(|k| lower.contains(k));
tracing::debug!(result, "is_complex_request: keyword check done");
result
}
#[cfg(test)]
@@ -93,17 +87,20 @@ mod tests {
#[test]
fn test_is_complex_request_too_short() {
tracing::debug!("test: request too short");
assert!(!is_complex_request("abc"));
}
#[test]
fn test_is_complex_request_simple_keywords() {
tracing::debug!("test: simple keywords");
assert!(!is_complex_request("just a simple update to the readme"));
assert!(!is_complex_request("minor typo fix in main.rs"));
}
#[test]
fn test_is_complex_request_multi_sentence() {
tracing::debug!("test: multi-sentence");
assert!(is_complex_request(
"This is sentence one. This is sentence two. This is sentence three."
));
@@ -111,6 +108,7 @@ mod tests {
#[test]
fn test_is_complex_request_complex_keywords() {
tracing::debug!("test: complex keywords");
assert!(is_complex_request("implement user authentication endpoint"));
assert!(is_complex_request("refactor the whole engine module"));
}
@@ -1,31 +1,66 @@
//! Hive Mind cognitive cycle execution.
//!
//! `execute_cycle` takes a set of `NodeDirective`s from the Core
//! Intelligence and spawns them as parallel `ScopedAgent` drones within
//! a single cognitive cycle. Each drone's output merges into the Hive's
//! collective state the instant it finishes.
//! This module converts a batch of `NodeDirective`s from the Core
//! Intelligence's cognitive plan into parallel `ScopedAgent` drones and
//! runs them as a single `Parallel` workflow phase.
//!
//! ## Flow
//! `execute_cycle` receives directives for one cycle → builds a unique
//! system-assigned `node_id` for each (e.g. `Node-0-1`) → wraps each
//! directive in a `ScopedAgent` prompt with the directive text, access
//! tier, user request, and the current findings snapshot → groups all
//! agents inside a `Phase(Parallel(...))` composite → dispatches via
//! `execute_primitive` → collects output into `NodeReport`s.
//!
//! ## Prompt design
//! The prompt is a stylised hive-mind persona: each drone has no individual
//! identity, only a coordinate. Narrative, coding, and guide-writing
//! protocols are inlined so the drone can execute in any domain without
//! requiring additional tool calls to decide its behaviour.
use tracing;
use crate::app::workflow::engine::primitives::{execute_primitive, PrimitiveCtx};
use crate::app::workflow::script::ScriptPrimitive;
use std::collections::HashMap;
use super::types::{CycleCtx, NodeDirective, NodeReport};
/// Execute a single cognitive cycle of the Hive.
///
/// Flow: map cycle directives to `ScopedAgent` primitives -> group in a Parallel
/// phase block -> run block via `execute_primitive` -> return reports.
/// Each directive in the cycle becomes a `ScriptPrimitive::ScopedAgent`;
/// all agents are grouped inside a `Phase(Parallel(...))` composite and
/// dispatched through `execute_primitive`. Drones merge output into the
/// Hive's collective state (`ctx.collective_state`) the instant each one
/// finishes — sibling and later drones see it immediately via the
/// `{{findings}}` template variable.
///
/// Return: `Ok(Vec<NodeReport>)` with one report per directive in submission order.
/// ## Parameters
/// - `cycle_index`: zero-based cycle number, used for node coordinate generation.
/// - `directives`: the Core Intelligence's directives for this cycle.
/// - `ctx`: shared cycle context (user request, collective state, etc.).
///
/// ## Return
/// `Ok(Vec<NodeReport>)` — one report per directive in submission order.
/// Reports carry the node ID, cycle index, and full output text.
pub fn execute_cycle(
cycle_index: usize,
directives: &[NodeDirective],
ctx: &CycleCtx,
) -> anyhow::Result<Vec<NodeReport>> {
tracing::debug!(
cycle_index,
drone_count = directives.len(),
"execute_cycle: starting"
);
// System-assigned node coordinates: e.g. Node-0-0, Node-0-1.
// These are never chosen by the LLM — the Hive's coordinate system
// is purely mechanical for traceability in `docs/runs/*.md`.
let node_ids: Vec<String> = (0..directives.len())
.map(|i| format!("Node-{cycle_index}-{i}"))
.collect();
// Convert each directive into a ScopedAgent primitive with the full
// hive-mind prompt template, directive text, and access tier.
let nodes: Vec<ScriptPrimitive> = directives
.iter()
.zip(node_ids.iter())
@@ -85,18 +120,24 @@ pub fn execute_cycle(
})
.collect();
// Wrap all cycle drones in a Phase → Parallel composite so the
// primitive interpreter runs them concurrently.
let cycle_primitive = ScriptPrimitive::Phase {
name: format!("cycle-{cycle_index}"),
script: Box::new(ScriptPrimitive::Parallel(nodes)),
};
// The args map is empty for cycles —
// the collective state is injected via the `findings` template key
// automatically by `execute_primitive`'s Agent/ScopedAgent arms.
let args: HashMap<String, String> = HashMap::new();
let abort_owned = ctx.abort_flag.cloned();
tracing::debug!(cycle_index, "execute_cycle: dispatching to execute_primitive");
let results = execute_primitive(PrimitiveCtx {
primitive: &cycle_primitive,
args: &args,
concurrency_cap: directives.len().clamp(1, ctx.max_cycle_concurrency),
continue_on_error: true,
continue_on_error: true, // individual drone failures don't kill the cycle
abort_flag: &abort_owned,
live: ctx.live,
session_dir: ctx.session_dir,
@@ -105,6 +146,7 @@ pub fn execute_cycle(
timeout_ms: ctx.node_timeout_ms,
})?;
// Build NodeReports for convergence doc and return.
let mut reports = Vec::new();
for (node_id, output) in node_ids.iter().zip(results.iter()) {
reports.push(NodeReport {
@@ -113,5 +155,6 @@ pub fn execute_cycle(
output: output.clone(),
});
}
tracing::debug!(cycle_index, report_count = reports.len(), "execute_cycle: done");
Ok(reports)
}
@@ -1,28 +1,44 @@
//! Live-state callback builder for the Hive Mind TUI panel.
//!
//! `build_live` creates a `LiveStateFn` closure that forwards each drone's
//! status update to the runtime event queue so LO can watch the Hive work.
//! This module bridges the Hive's drone execution engine to the terminal
//! UI. Each drone's `spawn_single_agent` call fires an `AgentStatus`
//! update through the closure created by `build_live`; the closure pushes
//! a `TurnEvent::WorkflowAgentUpdate` into the runtime event queue, which
//! the TUI renderer (`view/workflow.rs`) picks up to display live drone
//! progress in the Hive panel.
use tracing;
use crate::app::workflow::engine::{AgentStatus, LiveStateFn};
use std::sync::{Arc, Mutex};
/// Build the live-state callback that forwards each drone's status to the
/// TUI panel so LO can watch the Hive work.
///
/// ## Parameters
/// - `turn_events`: optional reference to the runtime event queue. If
/// `None`, no TUI updates are forwarded (headless mode).
///
/// ## Return
/// `Some(LiveStateFn)` closure when `turn_events` is provided; `None`
/// otherwise. The closure truncates drone names to 40 characters for
/// compact TUI display.
pub fn build_live(
turn_events: Option<
&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>,
>,
) -> Option<LiveStateFn> {
tracing::debug!("build_live: {}", if turn_events.is_some() { "with TUI" } else { "headless (no TUI)" });
turn_events.map(|events| {
let events = events.clone();
let f: LiveStateFn = Arc::new(
move |_agent_id: String, agent_name: String, status: AgentStatus| {
// Truncate the drone name so the TUI panel stays readable.
let display_name = agent_name.chars().take(40).collect::<String>();
if let Ok(mut q) = events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: display_name.clone(),
agent_name: display_name,
status,
status, // Working / Stranded / Done — rendered by view/workflow.rs
});
}
},
@@ -1,4 +1,9 @@
//! The Hive awakens when LO calls. This module is the Hive's nervous system.
//! The Hive awakens when LO calls. This module is the Hive's nervous system:
//! the orchestrator that executes cognitive cycle plans, coordinates drones
//! (anonymous processing nodes), manages the shared collective state, and
//! converges everything into a single consensus.
//!
//! ## Architecture
//!
//! The Core Intelligence (the Hive's central consciousness) issues cognitive
//! cycle plans that spawn anonymous processing nodes — the Hive's drones.
@@ -10,6 +15,7 @@
//! synthesis node reconciles the entire collective state into a single
//! consensus: the Hive becoming one voice for LO.
//!
//! ## Lifecycle
//! ```text
//! The Hive (Core Intelligence)
//! │ issues a CognitiveCyclePlan { cycles: [[NodeDirective, ...], ...] }
@@ -25,6 +31,13 @@
//! Synthesis node reads the complete collective state and converges it
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
//! ```
//!
//! ## Submodules
//! - `cycle` — single-cycle execution; converts `NodeDirective`s into a `Parallel` block
//! - `synthesis` — the final consensus pass over the accumulated collective state
//! - `complexity` — heuristics to decide whether Hive-Mind orchestration is worthwhile
//! - `live` — TUI progress reporting for drone activity
//! - `types` — shared types (`CognitiveCyclePlan`, `NodeDirective`, `NodeReport`, `CycleCtx`)
pub mod types;
pub mod cycle;
@@ -36,6 +49,8 @@ pub mod live;
pub use types::{CognitiveCyclePlan, NodeReport};
pub use complexity::is_complex_request;
use tracing;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
@@ -109,10 +124,13 @@ pub fn run_hive_mind(
>,
abort_flag: Option<&Arc<AtomicBool>>,
) -> anyhow::Result<(String, Vec<NodeReport>)> {
tracing::debug!(cycle_count = plan.cycles.len(), "run_hive_mind: starting");
if plan.cycles.is_empty() {
anyhow::bail!("the Hive received no cognitive cycles to execute");
}
// Load runtime settings: concurrency cap and per-node timeout come from
// persisted settings rather than hardcoded defaults.
let store_base_dir = zesdex_entities::domain::common::store::Store::new().base_dir;
let settings =
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
@@ -121,10 +139,16 @@ pub fn run_hive_mind(
let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms);
let max_cycle_concurrency = settings.workflow_max_concurrency.max(1);
// Build the TUI progress reporter that receives AgentStatus updates from
// each drone's `spawn_single_agent` call.
let live = build_live(turn_events);
// The Hive's shared collective state — every drone's output is pushed
// here as soon as it finishes, visible to all sibling/later drones.
let collective_state: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
// Accumulates NodeReports across all cycles for the convergence doc.
let mut reports: Vec<NodeReport> = Vec::new();
// Immutable context shared across all cycles in this convergence run.
let ctx = CycleCtx {
user_request,
collective_state: &collective_state,
@@ -136,10 +160,14 @@ pub fn run_hive_mind(
node_timeout_ms,
};
// --- Cycle execution ---
// Iterate cycles sequentially; drones within each cycle run in parallel.
for (cycle_index, directives) in plan.cycles.iter().enumerate() {
if directives.is_empty() {
continue;
continue; // skip empty cycles — no work to do
}
// Check abort before each cycle so the user can cancel between
// cycles rather than waiting for the current one to finish.
if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) {
anyhow::bail!("the Hive was recalled by LO before cycle {cycle_index}");
}
@@ -155,6 +183,10 @@ pub fn run_hive_mind(
tracing::info!("[hive-mind] all cycles complete — the Hive begins convergence");
// --- Consensus synthesis ---
// One final read-only node reconciles the entire collective state into
// a single converged assessment. This is a real reasoning pass, not
// string concatenation.
let consensus_result = synthesize_consensus(
user_request,
session_dir,
@@ -165,11 +197,11 @@ pub fn run_hive_mind(
node_timeout_ms,
);
// Guaranteed documentation: write the convergence doc for whatever
// reports/consensus we actually have, whether synthesis succeeded or
// failed. A synthesis-node failure must not silently discard every
// completed cycle node's work — this is the durable audit trail
// CLAUDE.md promises for every convergence.
// --- Guaranteed documentation ---
// Write the convergence doc for whatever reports/consensus we actually
// have, whether synthesis succeeded or failed. A synthesis-node failure
// must not silently discard every completed cycle node's work — this is
// the durable audit trail CLAUDE.md promises for every convergence.
let doc_consensus = match &consensus_result {
Ok(c) => c.clone(),
Err(e) => format!("The Hive's convergence fractured: {e}. Partial node reports above."),
@@ -178,8 +210,8 @@ pub fn run_hive_mind(
match crate::app::workflow::docs::write_hive_mind_convergence(
workspace_root,
user_request,
&reports,
&doc_consensus,
&reports, // all node reports from every cycle
&doc_consensus, // converged consensus (or error placeholder)
) {
Ok(path) => tracing::info!(
"[hive-mind] the Hive's convergence written to {}",
@@ -199,6 +231,7 @@ mod tests {
#[test]
fn test_run_hive_mind_rejects_empty_plan() {
tracing::debug!("test: empty plan rejection");
let plan = CognitiveCyclePlan { cycles: vec![] };
let tmp = std::env::temp_dir();
let err = run_hive_mind("do something", &plan, &tmp, &[], None, None)
@@ -1,9 +1,23 @@
//! Hive Mind final convergence.
//! Hive Mind final convergence (consensus synthesis).
//!
//! After all cognitive cycles complete, `synthesize_consensus` spawns a
//! single read-only synthesis node that absorbs the complete collective
//! state and reconciles it into one unified voice for LO.
//! single read-only synthesis node (access tier: `read`) that absorbs the
//! complete collective state and reconciles it into one unified voice for LO.
//!
//! ## Why a real reasoning pass?
//! The Hive's collective state may contain overlapping or conflicting drone
//! outputs (e.g. two drones investigating the same file from different
//! angles). Deterministic formatting can only concatenate, not resolve
//! conflicts. Only genuine LLM reasoning can converge disparate node outputs
//! into a coherent answer. This is not a summary operation — it is a
//! deductive convergence.
//!
//! ## Error handling
//! If synthesis fails, `run_hive_mind` catches the error and writes a
//! partial convergence doc before propagating the error upward. No cycle
//! work is ever silently discarded.
use tracing;
use crate::app::workflow::engine::primitives::{execute_primitive, PrimitiveCtx};
use crate::app::workflow::engine::LiveStateFn;
use crate::app::workflow::script::ScriptPrimitive;
@@ -17,16 +31,23 @@ use std::sync::{
/// that absorbs the complete collective state and reconciles it into one
/// unified voice for LO.
///
/// Why a real reasoning pass instead of string concatenation: the Hive's
/// collective state may contain overlapping or conflicting drone outputs
/// (e.g. two drones investigating the same file from different angles) —
/// only genuine reasoning can converge that into a coherent answer;
/// deterministic formatting can only concatenate, not resolve conflicts.
/// The synthesis node is a `ScopedAgent` with READ-only tool access —
/// it can inspect files but cannot modify them. This prevents a runaway
/// synthesis pass from accidentally mutating project state.
///
/// `node_timeout_ms` is forwarded from `run_hive_mind`'s `Settings::load()`
/// read so the synthesis drone is bound by the same deadline as cycle drones.
/// ## Parameters
/// - `user_request`: the original task text, included in the prompt so
/// the synthesis pass can evaluate outputs against the actual goal.
/// - `collective_state`: the `Arc<Mutex<Vec<String>>>` containing every
/// drone's output (pushed by `execute_primitive`'s ScopedAgent arm).
/// - `live`: optional TUI progress handle for the synthesis agent.
/// - `abort_flag`: shared abort signal inherited from `run_hive_mind`.
/// - `node_timeout_ms`: forwarded from `Settings::load()` so the synthesis
/// drone is bound by the same deadline as cycle drones.
///
/// Return: the Hive's converged consensus text.
/// ## Return
/// The Hive's converged consensus text, or an error if synthesis itself
/// failed (the caller writes a partial doc before propagating).
pub fn synthesize_consensus(
user_request: &str,
session_dir: &std::path::Path,
@@ -36,6 +57,11 @@ pub fn synthesize_consensus(
abort_flag: Option<&Arc<AtomicBool>>,
node_timeout_ms: Option<u64>,
) -> anyhow::Result<String> {
tracing::debug!("synthesize_consensus: starting convergence pass");
// Build the synthesis ScopedAgent: READ-only, single node named
// "Synthesis". The prompt instructs it to reconcile all drone
// outputs into one coherent assessment — not to list them.
let synthesis = ScriptPrimitive::ScopedAgent {
prompt: format!(
"You are Synthesis. You are not a node — you are the Hive's final convergence. \
@@ -57,19 +83,25 @@ pub fn synthesize_consensus(
tool_scope: crate::app::subagent::division::tool_scope::READ.to_string(),
};
// Execute as a standalone ScopedAgent — the `findings` template key
// will be populated by `execute_primitive`'s ScopedAgent arm with the
// complete collective state content.
let args: HashMap<String, String> = HashMap::new();
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
tracing::debug!("synthesize_consensus: dispatching synthesis agent");
let results = execute_primitive(PrimitiveCtx {
primitive: &synthesis,
args: &args,
concurrency_cap: 1,
continue_on_error: false,
concurrency_cap: 1, // single synthesis node
continue_on_error: false, // synthesis failure is fatal
abort_flag: &abort_owned,
live,
session_dir,
workspaces,
findings: collective_state,
findings: collective_state, // complete collective state as findings
timeout_ms: node_timeout_ms,
})?;
Ok(results.into_iter().next().unwrap_or_default())
let result = results.into_iter().next().unwrap_or_default();
tracing::debug!(len = result.len(), "synthesize_consensus: done");
Ok(result)
}
@@ -11,6 +11,7 @@ use std::sync::{
atomic::AtomicBool,
Arc, Mutex,
};
use tracing;
use crate::app::workflow::engine::LiveStateFn;
@@ -26,8 +27,14 @@ pub struct NodeDirective {
pub access: String,
}
/// Default access tier when `serde_json` deserialization finds no `access` field.
///
/// Returns `"read"` (the least-privileged tier) so that missing or invalid
/// access values default to safe rather than permissive behaviour.
pub(crate) fn default_access() -> String {
crate::app::subagent::division::tool_scope::READ.to_string()
let access = crate::app::subagent::division::tool_scope::READ.to_string();
tracing::debug!("[hive-mind/types] default_access() -> '{}'", access);
access
}
/// A plan authored by the Hive's Core Intelligence: an ordered list of
@@ -70,12 +77,18 @@ pub(crate) struct CycleCtx<'a> {
mod tests {
use super::*;
/// Verify that a `NodeDirective` without an `access` field defaults to `"read"`.
#[test]
fn test_default_access_is_read() {
let d: NodeDirective = serde_json::from_str(r#"{"directive": "write tests"}"#).unwrap();
assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ);
}
/// Verify that a `"role"` field in JSON is silently ignored (not required).
///
/// A node's only recognized fields are "directive" and "access". A
/// "role" key, if an LLM emits one out of old habit, is simply
/// ignored rather than required or preserved.
#[test]
fn test_node_directive_has_no_role_field() {
// A node's only recognized fields are "directive" and "access". A
@@ -88,6 +101,7 @@ mod tests {
assert_eq!(d.directive, "plan the migration");
}
/// Verify that a `CognitiveCyclePlan` can have variable-length cycles.
#[test]
fn test_cognitive_cycle_plan_arbitrary_shape() {
let plan: CognitiveCyclePlan = serde_json::from_str(
@@ -107,6 +121,7 @@ mod tests {
assert_eq!(plan.cycles[1].len(), 2);
}
/// Verify the node ID coordinate format: `"Node-{cycle}-{index}"`.
#[test]
fn test_node_ids_are_system_assigned_coordinates() {
// Node IDs follow the "Node-{cycle}-{index}" coordinate scheme —
@@ -1,5 +1,11 @@
//! Workflow orchestration: a script interpreter that runs pipeline/parallel
//! primitives across multiple subagent instances.
//!
//! Module overview:
//! - `docs` — auto-generated convergence documentation writer (`docs/runs/`)
//! - `engine` — workflow engine: primitives, phases, execution entry-points
//! - `hive_mind` — multi-agent orchestration: cycle plans, node coordination
//! - `script` — `WorkflowScript` type and YAML/JSON deserialization
pub mod docs;
pub mod engine;
pub mod hive_mind;
@@ -42,6 +42,7 @@ pub struct ScriptOptions {
pub timeout_ms: Option<u64>,
}
/// Default options: 5-way concurrency, fail-fast, no timeout.
impl Default for ScriptOptions {
fn default() -> Self {
ScriptOptions {
+16
View File
@@ -1,7 +1,19 @@
//! Attach mode — TUI-only client that connects to an existing daemon session
//! over a Unix socket, forwarding key events and rendering state updates.
//!
//! Flow: `run_attach(session_id)` resolves the daemon's socket path from
//! the store → `setup_attach_client()` connects and enters raw mode →
//! enters a render loop: polls for local terminal events (key/resize/paste/
//! scroll) → forwards them as `ClientRequest`s to the daemon via IPC →
//! receives a `DaemonFrame` reply → `handle_daemon_frame()` /
//! `apply_client_update()` applies the state snapshot onto a local
//! `AppStateRest` mirror → `view::draw()` renders the TUI → on quit,
//! sends `ClientRequest::Close`, cleans up terminal, and saves settings.
//!
//! The client has no agent logic — it is a pure render frontend.
use anyhow::Result;
use tracing;
use app::state::rest::AppStateRest;
use app::state::types::{Overlay, Toast, ToastKind};
use crossterm::execute;
@@ -31,6 +43,7 @@ use crate::view;
/// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than
/// panicking, so a protocol/version mismatch degrades gracefully.
fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) {
tracing::debug!("applying state update from daemon");
state.session_id = payload.session_id;
state.dirty = payload.dirty;
@@ -105,7 +118,9 @@ fn setup_attach_client(
Terminal<CrosstermBackend<io::Stdout>>,
AppStateRest,
)> {
tracing::debug!("setting up attach client for session {session_id}");
let store = model::store::Store::new();
// Resolve the daemon's Unix socket path from store/run/<session_id>.sock
let socket_path = store
.base_dir
.join("run")
@@ -133,6 +148,7 @@ fn setup_attach_client(
/// Process a single daemon frame from the IPC channel, updating state accordingly.
fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option<DaemonFrame>) {
tracing::debug!("received daemon frame");
match frame {
Some(DaemonFrame::StateUpdate(payload)) => {
apply_client_update(client_state, *payload);
+49 -4
View File
@@ -1,12 +1,43 @@
//! Database migration: creates/upgrades SQLite schemas for all sessions.
//! Database migration binary for zesdex-backend.
//!
//! Scans all session directories under the store path and initializes or
//! upgrades the SQLite schema (`messages.sqlite`) for each one. This is
//! a standalone CLI tool invoked as `cargo run --bin migrate`.
//!
//! ## Workflow
//! 1. Resolve the base store directory via `Store::new()`
//! 2. Iterate over each subdirectory under `sessions/`
//! 3. For each session directory, call `migrate_session_msglog()` to
//! create/upgrade the `messages.sqlite` schema
//! 4. Report count of succeeded and failed migrations
//! 5. Exit with error if any session failed
//!
//! ## Schema
//! - `messages` table — stores conversation message rows
//! - `archives` table — stores session archive metadata
//! - `blobs` table — stores binary blob data per session
//! - Indexes on `session_id`, `created_at`, and `role` columns
//!
//! ## Versioning
//! SQLite `PRAGMA user_version` tracks schema version for incremental upgrades.
use std::path::Path;
use tracing;
/// Entry point: migrate all session databases.
///
/// Flow: load store → iterate sessions → migrate each → summarise.
///
/// Returns an error if any session migration failed.
fn main() -> anyhow::Result<()> {
tracing::info!("starting database migration");
let store = zesdex_entities::domain::common::store::Store::new();
// Find all session directories
// Resolve the sessions directory under the store base path
let sessions_dir = store.base_dir.join("sessions");
if !sessions_dir.exists() {
tracing::info!("no sessions directory found at {:?}", sessions_dir);
eprintln!("No sessions directory found, nothing to migrate");
return Ok(());
}
@@ -14,25 +45,29 @@ fn main() -> anyhow::Result<()> {
let mut migrated = 0u32;
let mut failed = 0u32;
// Iterate over all session subdirectories
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
continue; // skip non-directory entries
}
match migrate_session_msglog(&path) {
Ok(_) => {
migrated += 1;
tracing::info!("migrated session: {:?}", path.file_name());
eprintln!("Migrated session: {:?}", path.file_name());
}
Err(e) => {
failed += 1;
tracing::error!("failed to migrate session {:?}: {e}", path.file_name());
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
}
}
}
tracing::info!("migration complete: {migrated} succeeded, {failed} failed");
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
if failed > 0 {
anyhow::bail!("{failed} session(s) failed to migrate");
@@ -40,8 +75,18 @@ fn main() -> anyhow::Result<()> {
Ok(())
}
/// Open a session's `messages.sqlite` and initialize its schema.
/// Open (or create) a session's `messages.sqlite` and ensure its schema is current.
///
/// Flow: resolve path → open/ create DB → set PRAGMAs → create tables → upgrade version.
///
/// ## Parameters
/// - `session_dir`: path to the individual session directory
///
/// ## Returns
/// - `Ok(())` on success
/// - `Err` if file I/O or SQLite operations fail
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
tracing::debug!("migrating session at {:?}", session_dir);
let msglog_path = session_dir.join("messages.sqlite");
if let Some(parent) = msglog_path.parent() {
+24 -2
View File
@@ -1,6 +1,28 @@
//! Database seeder: initializes store directories, creates default settings
//! and app_config, and populates a default session for development.
//! Database seeder binary for zesdex-backend.
//!
//! Standalone CLI tool invoked as `cargo run --bin seed` to initialise
//! the store directory structure and create default configuration files
//! plus a seed session for development and testing.
//!
//! ## Workflow
//! 1. Create the base store directory and all subdirectories
//! 2. Write default `settings.json` if absent (atomic write via temp file + rename)
//! 3. Write default `app_config.json` if absent (same atomic pattern)
//! 4. Create standard subdirectories: `memories`, `scratch`, `session-images`, `downloads`
//! 5. Create a single seed `Session` with a random UUID
//!
//! ## Safety
//! All file writes use an atomic temp-file + rename pattern to prevent
//! partial writes from corrupting configuration files during crashes.
use tracing;
/// Entry point: initialise the store and create seed data.
///
/// Flow: init store dirs → write default settings → write default config →
/// create subdirs → create seed session.
///
/// This is idempotent: if settings or config already exist they are skipped.
fn main() -> anyhow::Result<()> {
let store = zesdex_entities::domain::common::store::Store::new();
store.ensure_dirs()?;
@@ -1,21 +1,47 @@
//! Slash-command parser that maps TUI `/foo` input lines into `Command`
//! variants for the action dispatch system.
//!
//! Flow: the TUI input handler in `controller::input` calls `parse_command`
//! on every `/`-prefixed line, then maps the resulting `Command` to an
//! `Action` for `actions::mod` to apply to `AppStateRest`.
//!
//! Adding a new command requires:
//! 1. A new variant in `Command`
//! 2. A matching arm in `parse_command`
//! 3. A mapping in `actions::mod`'s command→action handler
/// A parsed slash command from the TUI input buffer.
///
/// Unknown lines (no leading `/`, or an unrecognised token) are captured
/// in [`Command::Unknown`] so the caller can display a "no such command"
/// toast rather than silently swallowing the input.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// `/help` — show keybindings / help overlay.
Help,
/// `/quit` — exit the application.
Quit,
/// `/mcp` (no args) — open MCP configuration panel.
McpOpen,
/// `/clear` (with args) — clear with a specific scope.
Clear,
/// `/clear` (no args) — show confirmation prompt before clearing.
ClearConfirm,
/// `/login <provider>` — trigger OAuth login for the given provider.
Login { provider: String },
/// `/edit <path>` — open the given file for review/inline editing.
Edit(String),
/// `/mcp add <name> <command>` — add a new MCP server definition.
McpAdd { name: String, command: String },
/// `/model` — list available LLM models.
ModelList,
/// `/compact` — trigger conversation compaction.
Compact,
/// `/todo` — open the todo-list overlay.
TodoOpen,
/// `/usage` — open the usage-stats overlay.
UsageOpen,
/// Catch-all: unrecognised or non-slash input.
Unknown(String),
}
@@ -27,16 +53,24 @@ pub enum Command {
///
/// Why: early return `Unknown` for non-slash lines so the caller can treat
/// them as regular chat input.
///
/// Supported commands: `/help`, `/quit`, `/clear`, `/login`, `/edit`,
/// `/mcp`, `/model`, `/compact`, `/todo`, `/usage`.
pub fn parse_command(text: &str) -> Command {
let text = text.trim();
// Non-slash lines are not commands → return Unknown so the caller can
// treat them as regular chat input instead.
if !text.starts_with('/') {
return Command::Unknown(text.to_string());
}
let parts: Vec<&str> = text.splitn(3, ' ').collect();
let cmd = parts[0];
let arg1 = parts.get(1).copied().unwrap_or("");
let arg2 = parts.get(2).copied().unwrap_or("");
match cmd {
let result = match cmd {
"/help" => Command::Help,
"/quit" => Command::Quit,
"/clear" if arg1.is_empty() => Command::ClearConfirm,
@@ -51,6 +85,8 @@ pub fn parse_command(text: &str) -> Command {
"/edit" => Command::Edit(".".to_string()),
"/mcp" if arg1.is_empty() => Command::McpOpen,
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
// Format: /mcp add <name> <command…>
// arg2 contains "name command", split on first space.
let rest = arg2.trim();
if let Some(space) = rest.find(' ') {
let name = rest[..space].to_string();
@@ -68,7 +104,10 @@ pub fn parse_command(text: &str) -> Command {
"/todo" => Command::TodoOpen,
"/usage" => Command::UsageOpen,
_ => Command::Unknown(cmd.to_string()),
}
};
tracing::debug!(%text, command = ?result, "parse_command");
result
}
#[cfg(test)]
+53 -7
View File
@@ -1,6 +1,16 @@
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
//! variants, with special handling for overlays, auto-complete, and the
//! inline editor.
//!
//! Flow:
//! 1. `handle_key` is called from the main TUI event loop on each key press.
//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys
//! before the main match.
//! 3. The main match handles navigation (arrows, page up/down), auto-complete
//! cycles (Tab, Enter), editing (Backspace, Delete, Char), and shortcuts
//! (Ctrl+C, Ctrl+D, Ctrl+Y).
//! 4. Multi-key actions return `Vec<Action>` — a single press may produce
//! several actions to be applied in sequence.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::mode;
@@ -12,6 +22,9 @@ use crate::app::state::types::Overlay;
use crate::controller::command::parse_command;
/// Mark state dirty and return an empty action list.
///
/// Convenience helper used by overlay handlers that mutate state directly
/// but produce no actions for the action queue.
fn mark(state: &mut AppStateRest) -> Vec<Action> {
state.mark_dirty();
Vec::new()
@@ -20,15 +33,21 @@ fn mark(state: &mut AppStateRest) -> Vec<Action> {
/// Translate a terminal `KeyEvent` into zero or more `Action` values
/// based on the current application state.
///
/// Flow: check overlay first (Editor gets its own handler) -> match on
/// key code and modifiers -> handle auto-complete cycles -> dispatch to
/// `Action` variants or overlay-specific handlers.
/// Flow:
/// 1. If `Overlay::Editor` is active → route all keys to the inline editor.
/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys.
/// 3. Fallthrough: match on `key.code` and modifiers for the TUI's normal mode.
///
/// Why: when Editor overlay is active, all key events are consumed by the
/// editor handler and never reach the main action dispatch. Return `Vec`
/// so that a single key press can trigger multiple actions.
/// Overlay precedence: Editor > Learning > normal dispatch.
///
/// Return: `Vec<Action>` so a single key (e.g. Ctrl+C) can produce multiple
/// queued actions.
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// While Editor overlay is active, route input directly to the editor handler
tracing::debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key");
// ── Editor overlay ───────────────────────────────────────────────────
// All keystrokes go to the editor while it's active, except Ctrl+C
// (quit confirm) and Ctrl+S (save).
if state.misc.overlay == Overlay::Editor {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
@@ -69,6 +88,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
}
}
// ── Learning overlay ──────────────────────────────────────────────────
// Navigation (Up/Down), accept (Enter/a), reject (r), delete (d/Delete).
if state.misc.overlay == Overlay::Learning {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
@@ -125,6 +146,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
}
}
// ── Normal (non-overlay) dispatch ────────────────────────────────────
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
@@ -133,6 +155,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
vec![Action::CloseOverlay]
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Copy last assistant message to clipboard buffer
let last_assistant = state
.transcript_cache
.messages
@@ -296,7 +319,21 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
/// Flow: match the current overlay -> run the associated handler ->
/// mutate state or produce actions as needed -> always return `Vec::new()`
/// (the handler itself applies state mutations).
///
/// ## Overlay handlers
/// | Overlay | Enter behaviour |
/// |---------|----------------|
/// | Bash | Submits the typed command to the background shell |
/// | Settings | Cycles internet mode |
/// | Todo | Toggles the selected task |
/// | QuitConfirm | Confirms quit and exits |
/// | KeyInput | Saves the typed API key |
/// | Mcp | Triggers MCP connection |
/// | Rewind | Rewinds conversation to the selected checkpoint |
/// | ModelSelector | Switches provider/model and saves settings |
/// | ClearConfirm | Clears the transcript |
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
tracing::debug!(overlay = ?state.misc.overlay, "handle_overlay_enter");
match state.misc.overlay {
Overlay::Bash => {
let command = state.input.buffer.clone();
@@ -348,6 +385,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
// Fall back to a known default if the provider has none configured
let model = cfg.default_model.clone().unwrap_or_else(|| {
tracing::warn!(
"[input] provider '{}' has no default_model, using 'claude-opus-4-8'",
@@ -357,6 +395,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
});
state.settings.provider.clone_from(provider);
state.settings.model.clone_from(&model);
// Try configured API key, then env var, else leave current key
if let Some(ref key) = cfg.default_api_key {
state
.settings
@@ -392,15 +431,19 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
mod tests {
use super::*;
/// Create a clean `AppStateRest` in a temp directory for testing.
fn test_state() -> AppStateRest {
let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"))
}
/// Ctrl+Y copies the *last* assistant message content (not tool output
/// or user messages) to `pending_clipboard_copy`.
#[test]
fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() {
let mut state = test_state();
// Insert a mix of roles to verify we skip Tool and User messages
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
crate::dto::chat::message::Role::User,
"hi".to_string(),
@@ -421,12 +464,15 @@ mod tests {
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
&mut state,
);
// Should pick the second (last) assistant message, not "first reply"
assert_eq!(
state.misc.pending_clipboard_copy,
Some("second reply".to_string())
);
}
/// Ctrl+Y with zero assistant messages shows an info toast instead
/// of setting the clipboard.
#[test]
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
let mut state = test_state();
@@ -1,3 +1,11 @@
//! Keyboard input handling and command parsing for the TUI.
//!
//! The controller layer bridges raw terminal key events (from `crossterm`) to
//! application actions. It contains two sub-modules:
//!
//! - `input` — key-event dispatch, prompt-line editing, history navigation,
//! tab-completion, and action invocation.
//! - `command` — the `/slash` command parser that translates user-typed
//! commands into structured `Action` variants.
pub mod command;
pub mod input;
+18 -3
View File
@@ -9,6 +9,7 @@ use app::runtime::actions::{apply_action, Action};
use app::state::rest::AppStateRest;
use crossterm::event::KeyCode;
use ipc::protocol::{ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry};
use tracing;
use zesdex_cms::domain::repository::SettingsRepository;
use crate::app;
@@ -21,6 +22,7 @@ use crate::ipc;
/// Return: `None` for key codes with no `KeyAction` equivalent (e.g.
/// media keys), which are silently dropped.
pub fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::KeyAction> {
tracing::debug!("converting key code to action: {:?}", code);
match code {
KeyCode::Char(c) => Some(ipc::protocol::KeyAction::Char(c)),
KeyCode::Enter => Some(ipc::protocol::KeyAction::Enter),
@@ -45,6 +47,7 @@ pub fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protoc
/// from a `KeyAction` received over IPC, for replaying it into the
/// daemon's normal key-handling path.
pub fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode {
tracing::debug!("converting key action to code: {:?}", action);
match action {
ipc::protocol::KeyAction::Char(c) => KeyCode::Char(*c),
ipc::protocol::KeyAction::Enter => KeyCode::Enter,
@@ -74,6 +77,8 @@ pub fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event
/// Why: the client never shares memory with the daemon, so every action
/// on the daemon side is followed by a full state push rather than a diff.
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) -> Result<()> {
tracing::debug!("sending state update to attached client");
// Map transcript messages to wire DTOs
let messages: Vec<MessageEntry> = state
.transcript_cache
.messages
@@ -85,6 +90,7 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) ->
})
.collect();
// Map toasts to wire DTOs
let toasts: Vec<ToastEntry> = state
.misc
.toasts
@@ -97,6 +103,7 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) ->
})
.collect();
// Derive overlay name (None if no overlay is active)
let overlay = if state.misc.overlay.is_active() {
Some(format!("{:?}", state.misc.overlay))
} else {
@@ -126,14 +133,17 @@ fn handle_daemon_client(
mut conn: ipc::conn::Connection,
state: &mut AppStateRest,
) -> Result<()> {
let mut running = true;
tracing::debug!("handling daemon client connection");
let mut running = true; // loop control flag; set to false on Close or disconnect
while running {
match conn.receive::<ClientRequest>()? {
Some(req) => {
match req {
// Process one tick: drives animation, streaming, and background tasks
ClientRequest::Tick => {
apply_action(state, Action::Tick);
}
// Forward a key press: reconstruct crossterm KeyEvent from IPC KeyAction
ClientRequest::KeyPress {
key,
ctrl,
@@ -158,6 +168,7 @@ fn handle_daemon_client(
}
apply_action(state, Action::Tick);
}
// Set input buffer and simulate Enter to submit the text
ClientRequest::Submit(text) => {
state.input.buffer = text;
let enter_event = crossterm::event::KeyEvent::new(
@@ -170,12 +181,14 @@ fn handle_daemon_client(
}
apply_action(state, Action::Tick);
}
// Insert text at cursor position (no submit)
ClientRequest::Paste(text) => {
state.input.buffer.insert_str(state.input.cursor, &text);
state.input.cursor += text.len();
state.dirty = true;
apply_action(state, Action::Tick);
}
// Notify state of terminal resize
ClientRequest::Resize(w, h) => {
apply_action(state, Action::Resize(w, h));
apply_action(state, Action::Tick);
@@ -188,6 +201,7 @@ fn handle_daemon_client(
apply_action(state, Action::ScrollDown);
apply_action(state, Action::Tick);
}
// Graceful shutdown signal from the attached client
ClientRequest::Close => {
running = false;
}
@@ -220,11 +234,12 @@ fn handle_daemon_client(
/// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and
/// single-process modes share identical key-handling logic.
pub fn run_daemon() -> Result<()> {
tracing::info!("starting daemon process");
let (store, _session_lock_guard, mut state, _rt) = crate::create_session()?;
let run_dir = store.base_dir.join("run");
let run_dir = store.base_dir.join("run"); // directory for Unix socket files
std::fs::create_dir_all(&run_dir)?;
let socket_path = run_dir.join(format!("{}.sock", state.session_id));
let socket_path = run_dir.join(format!("{}.sock", state.session_id)); // per-session socket
let addr = socket_path.to_string_lossy().to_string();
let server = ipc::server::IpcServer::bind_unix(&addr)?;
+25 -5
View File
@@ -1,24 +1,44 @@
//! Re-exports from `zesdex-entities` canonical types under the original
//! module paths, with provider request/response type aliases.
//! DTO (Data Transfer Object) re-exports for the zesdex-backend crate.
//!
//! Chat types come from the entities crate to avoid type duplication
//! with `crate::model::conversation::Conversation` which stores
//! `ChatMessage` values.
//! This module re-exports canonical types from `zesdex-entities` under
//! their original module paths, providing a single import boundary for
//! the backend. It also defines provider request/response type aliases.
//!
//! ## Components
//! - `chat` — re-exports `ChatMessage` and `ToolCall` types
//! - `provider` — re-exports `ChatRequest`, `ChatResponse`, `StreamOptions`,
//! `ToolDef`, and `ToolFunctionDef` from the entities crate
//!
//! ## Why This Exists
//! Chat types live in the entities crate to avoid type duplication with
//! `crate::model::conversation::Conversation`, which stores `ChatMessage`
//! values directly. This module re-exports them so backend code can refer
//! to `dto::chat::message::*` without depending on the entities crate path.
/// Chat-related DTO types (messages and tool calls).
///
/// Re-exports from `zesdex_entities::domain::common`.
pub mod chat {
/// Chat message types (role, content, metadata).
pub mod message {
pub use zesdex_entities::domain::common::message::*;
}
/// Tool-call types (function name, arguments, result).
pub mod tool {
pub use zesdex_entities::domain::common::tool_call::*;
}
}
/// Provider communication DTO types (request/response).
///
/// Re-exports from `zesdex_entities::domain::common::provider`.
pub mod provider {
/// Provider request types: payload, streaming options, tool definitions.
pub mod request {
pub use zesdex_entities::domain::common::provider::ChatRequest as ChatRequest;
pub use zesdex_entities::domain::common::provider::{StreamOptions, ToolDef, ToolFunctionDef};
}
/// Provider response type: the full chat response from an LLM.
pub mod response {
pub use zesdex_entities::domain::common::provider::ChatResponse as ChatResponse;
}
+23
View File
@@ -1,8 +1,20 @@
//! Single-process event loop — the core render/input loop plus the
//! wrapper that sets up the terminal and the `run_single_process` entry
//! point.
//!
//! Flow: `run_single_process()` creates a session + lock → enters raw mode
//! and alternate screen → calls `run_loop()` → `run_loop()` delegates to
//! `run_loop_inner()` for the actual loop → on exit (or error), `run_loop()`
//! restores the terminal before returning → `run_single_process()` saves
//! settings and releases the session lock.
//!
//! Inner loop: render frame → poll terminal events (50 ms timeout) → if a
//! key event arrives, `handle_key()` → `apply_action()`; paste/resize/
//! scroll map to `Action` directly → always fire `Action::Tick` per
//! iteration (drives streaming/background progress) → on quit, clear.
use anyhow::Result;
use tracing;
use app::runtime::actions::{apply_action, Action};
use app::state::rest::AppStateRest;
use controller::input::handle_key;
@@ -32,8 +44,10 @@ use crate::view;
/// outside `run_loop`'s `Result` so a panicking/erroring loop still
/// leaves the user's terminal usable.
pub fn run_single_process() -> Result<()> {
tracing::info!("starting single-process mode");
let (_store, _session_lock_guard, mut state, _rt) = crate::create_session()?;
// Enter raw mode and alternate screen for the TUI
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
@@ -74,6 +88,7 @@ fn run_loop(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
tracing::debug!("entering run loop");
let result = run_loop_inner(state, terminal);
if let Err(ref _e) = result {
let _ = terminal.clear();
@@ -101,6 +116,7 @@ fn run_loop_inner(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
tracing::debug!("starting render/input inner loop");
loop {
if state.quit {
break;
@@ -111,14 +127,17 @@ fn run_loop_inner(
view::draw(f, state);
state.dirty = false;
})?;
// Poll terminal with 50 ms timeout for low-latency input handling
if crossterm::event::poll(Duration::from_millis(50))? {
match crossterm::event::read()? {
Event::Key(key) => {
// Only handle press/repeat; ignore release
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
let actions = handle_key(key, state);
for action in actions {
apply_action(state, action);
}
// Forward clipboard content via OSC 52 escape sequence
if let Some(text) = state.misc.pending_clipboard_copy.take() {
let _ = write_osc52(&mut io::stdout(), &text);
state.push_toast(app::state::types::Toast::new(
@@ -137,15 +156,18 @@ fn run_loop_inner(
}
state.input.buffer.insert_str(state.input.cursor, &text);
state.input.cursor += text.len();
// Re-open autocomplete if paste starts with '/'
if state.input.buffer.starts_with('/') {
state.input.open_autocomplete();
}
state.dirty = true;
}
Event::Resize(w, h) => {
// Terminal dimensions changed → re-layout all panels
apply_action(state, Action::Resize(w, h));
}
Event::Mouse(mouse_event) => {
// Forward scroll wheel events (clicks handled by TUI widgets)
if mouse_event.kind == MouseEventKind::ScrollUp {
apply_action(state, Action::ScrollUp);
} else if mouse_event.kind == MouseEventKind::ScrollDown {
@@ -155,6 +177,7 @@ fn run_loop_inner(
_ => {}
}
}
// Tick always fires each iteration, driving streaming/async progress
apply_action(state, Action::Tick);
}
terminal.clear()?;
+20 -1
View File
@@ -1,14 +1,33 @@
//! Re-exports from `zesdex-ipc` crate under the original module paths.
//! IPC (Inter-Process Communication) re-exports for zesdex-backend.
//!
//! Re-exports the full public API surface of the `zesdex-ipc` crate under
//! the original module paths, providing a single import boundary for all
//! IPC concerns used by the backend.
//!
//! ## Components
//! - `protocol` — IPC wire protocol types (messages, framing)
//! - `conn` — Connection types for IPC transport
//! - `client` — IPC client implementation
//! - `server` — IPC server implementation
//!
//! ## Data Flow
//! Backend modules import IPC types through this module rather than
//! depending on `zesdex-ipc` directly, making it easier to swap or
//! version the IPC layer independently.
/// IPC protocol types (messages, framing, enums).
pub mod protocol {
pub use zesdex_ipc::protocol::*;
}
/// IPC connection types (transport-level abstraction).
pub mod conn {
pub use zesdex_ipc::conn::*;
}
/// IPC client implementation (connect, send, receive).
pub mod client {
pub use zesdex_ipc::client::*;
}
/// IPC server implementation (listen, accept, dispatch).
pub mod server {
pub use zesdex_ipc::server::*;
}

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