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 std::sync::OnceLock;
use super::job::BashJob; use super::job::BashJob;
use tracing::debug;
/// Lazily-initialised, process-wide registry of background bash jobs keyed /// Lazily-initialised, process-wide registry of background bash jobs keyed
/// by job id. /// 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 /// Return: a reference to the static `Mutex<HashMap<...>>`, created on
/// first access. /// first access.
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> { pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new(); static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
JOBS.get_or_init(|| Mutex::new(HashMap::new())) JOBS.get_or_init(|| {
debug!("bash_jobs_map initialised");
Mutex::new(HashMap::new())
})
} }
/// Drain any newly available output lines from a background bash job. /// 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); lines.push(line);
} }
if lines.is_empty() { if lines.is_empty() {
debug!(%id, "bash_output: no new lines");
None None
} else { } else {
debug!(%id, count = lines.len(), "bash_output: new lines drained");
Some(lines) 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 /// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
/// with that id exists. /// with that id exists.
pub fn bash_kill(id: &str) -> anyhow::Result<()> { pub fn bash_kill(id: &str) -> anyhow::Result<()> {
debug!(%id, "bash_kill called");
let mut map = bash_jobs_map() let mut map = bash_jobs_map()
.lock() .lock()
.map_err(|e| anyhow::anyhow!("lock error: {e}"))?; .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 // Actually terminate the child process via its PID
if job.child_pid > 0 { if job.child_pid > 0 {
#[cfg(unix)] #[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 { unsafe {
libc::kill(job.child_pid as i32, libc::SIGTERM); libc::kill(job.child_pid as i32, libc::SIGTERM);
} }
debug!(%id, pid = job.child_pid, "bash_kill: SIGTERM sent");
} }
Ok(()) Ok(())
} }
+12 -3
View File
@@ -13,6 +13,7 @@ use std::io::BufRead;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use tracing::{debug, warn};
/// Maximum number of output lines buffered in memory per background job. /// Maximum number of output lines buffered in memory per background job.
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770). /// 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. /// synchronously, so the TUI can poll for new lines without blocking.
/// The bounded channel prevents OOM from fast producers (e.g. `yes`). /// The bounded channel prevents OOM from fast producers (e.g. `yes`).
pub struct BashJob { pub struct BashJob {
/// Unique identifier for this job (UUID v4).
pub id: String, pub id: String,
/// OS process ID of the spawned child, used by `bash_kill` to send SIGTERM.
pub child_pid: u32, 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>, 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>, pub exit_code: Option<i32>,
} }
@@ -71,7 +78,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
}) })
.is_err() .is_err()
{ {
tracing::warn!( warn!(
"[bgbash:{}] failed to spawn named thread, using unnamed fallback", "[bgbash:{}] failed to spawn named thread, using unnamed fallback",
id_for_log id_for_log
); );
@@ -130,7 +137,7 @@ fn spawn_bash_thread_body(
let reader = std::io::BufReader::new(stderr); let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
if stderr_tx.try_send(format!("[stderr] {line}")).is_err() { 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; break;
} }
} }
@@ -142,7 +149,7 @@ fn spawn_bash_thread_body(
let reader = std::io::BufReader::new(stdout); let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
if output_tx.try_send(line).is_err() { if output_tx.try_send(line).is_err() {
tracing::debug!( debug!(
"[bgbash:{}] output buffer full ({} lines), discarding remaining output", "[bgbash:{}] output buffer full ({} lines), discarding remaining output",
id_for_log, id_for_log,
MAX_OUTPUT_LINES, MAX_OUTPUT_LINES,
@@ -171,11 +178,13 @@ impl BashJob {
Ok(line) => { Ok(line) => {
if line.starts_with("__exit:") { if line.starts_with("__exit:") {
self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok()); 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 None
} else { } else {
Some(line) Some(line)
} }
} }
// Channel empty or disconnected — no new output yet.
Err(_) => None, Err(_) => None,
} }
} }
@@ -1,4 +1,9 @@
//! Background bash: run shell commands off the main thread, poll their //! Background bash: run shell commands off the main thread, poll their
//! output non-blockingly, and terminate them on demand. //! 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 control;
pub mod job; pub mod job;
@@ -6,6 +6,7 @@
pub mod patterns; pub mod patterns;
use patterns::*; use patterns::*;
use tracing::debug;
/// Outcome of gating a tool call: whether it's allowed to run. /// Outcome of gating a tool call: whether it's allowed to run.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -33,39 +34,47 @@ impl Guard {
) -> Verdict { ) -> Verdict {
let is_risky = crate::tool::tool_is_risky(tool_name); let is_risky = crate::tool::tool_is_risky(tool_name);
let is_mcp = tool_name.starts_with("mcp__"); let is_mcp = tool_name.starts_with("mcp__");
debug!(tool_name, is_risky, is_mcp, "gating tool call");
// Universal checks applied to EVERY tool. // 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; return v;
} }
if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) { if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) {
debug!(tool_name, "blocked by output-path check");
return v; return v;
} }
// Non-risky, non-MCP tools pass after universal checks. // Non-risky, non-MCP tools pass after universal checks.
if !is_risky && !is_mcp { if !is_risky && !is_mcp {
debug!(tool_name, "non-risky non-MCP tool allowed after universal checks");
return Verdict::Allow; return Verdict::Allow;
} }
// File-mutating tools: require a meaningful reason. // File-mutating tools: require a meaningful reason.
if matches!(tool_name, "write" | "edit" | "delete") { if matches!(tool_name, "write" | "edit" | "delete") {
if let Err(msg) = Self::validate_reason(tool_name, args) { if let Err(msg) = Self::validate_reason(tool_name, args) {
debug!(tool_name, "blocked by reason validation");
return Verdict::Block(msg); return Verdict::Block(msg);
} }
} }
// write / edit content scanning for stub/denial/assumption patterns. // write / edit content scanning for stub/denial/assumption patterns.
if let Some(v) = Self::check_content_safety(tool_name, args) { if let Some(v) = Self::check_content_safety(tool_name, args) {
debug!(tool_name, "blocked by content-safety check");
return v; return v;
} }
// Bash-specific destructive / exfiltration checks. // Bash-specific destructive / exfiltration checks.
if let Some(v) = Self::check_bash_safety(args) { if let Some(v) = Self::check_bash_safety(args) {
debug!(tool_name, "blocked by bash-safety check");
return v; return v;
} }
// git_operator: require a non-trivial reason. // git_operator: require a non-trivial reason.
if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) { 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() { if args.get("reason").and_then(|v| v.as_str()).is_some() {
return Verdict::Block(format!( return Verdict::Block(format!(
"git_operator requires a non-trivial 'reason' \ "git_operator requires a non-trivial 'reason' \
@@ -81,12 +90,14 @@ impl Guard {
if is_mcp { if is_mcp {
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) { if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
if reason.trim().len() < MIN_REASON_LEN { if reason.trim().len() < MIN_REASON_LEN {
debug!(tool_name, "blocked by MCP reason length");
return Verdict::Block(format!( return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a non-trivial 'reason' \ "MCP tool '{tool_name}' requires a non-trivial 'reason' \
(>= {MIN_REASON_LEN} chars) explaining why it is needed" (>= {MIN_REASON_LEN} chars) explaining why it is needed"
)); ));
} }
} else if args.as_object().is_some_and(|m| !m.is_empty()) { } else if args.as_object().is_some_and(|m| !m.is_empty()) {
debug!(tool_name, "blocked by missing MCP reason");
return Verdict::Block(format!( return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a 'reason' argument \ "MCP tool '{tool_name}' requires a 'reason' argument \
explaining the operation" explaining the operation"
@@ -94,6 +105,7 @@ impl Guard {
} }
} }
debug!(tool_name, "tool call allowed");
Verdict::Allow Verdict::Allow
} }
@@ -374,9 +386,17 @@ impl Default for Guard {
#[cfg(test)] #[cfg(test)]
mod tests { 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 super::*;
use serde_json::json; 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> { fn parse_verdict(text: &str) -> Option<Verdict> {
let trimmed = text.trim(); let trimmed = text.trim();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) { 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 /// Stub / placeholder / denial / assumption patterns that should never reach
/// a file in real code. Detected in write/edit content and bash heredocs. /// a file in real code. Detected in write/edit content and bash heredocs.
pub const STUB_PATTERNS: &[&str] = &[ pub const STUB_PATTERNS: &[&str] = &[
// Rust macro stubs
"todo!()", "todo!()",
"todo!(", "todo!(",
"unimplemented!()", "unimplemented!()",
"unimplemented!(", "unimplemented!(",
"todo_macro", "todo_macro",
// Review markers left by the AI
"FIXME", "FIXME",
"fixme:", "fixme:",
"XXX:", "XXX:",
// Explicit placeholder tokens
"PLACEHOLDER", "PLACEHOLDER",
"REPLACE_ME", "REPLACE_ME",
"stub_value", "stub_value",
"stub_function", "stub_function",
"fake_response", "fake_response",
"fake_data", "fake_data",
// Admission that work was deferred
"not implemented", "not implemented",
"not yet implemented", "not yet implemented",
"to be implemented", "to be implemented",
@@ -30,36 +34,43 @@ pub const STUB_PATTERNS: &[&str] = &[
/// Language patterns indicating the AI is denying responsibility or /// Language patterns indicating the AI is denying responsibility or
/// punting the work ("I'll skip this", "for now just", etc). /// punting the work ("I'll skip this", "for now just", etc).
pub const DENIAL_PATTERNS: &[&str] = &[ pub const DENIAL_PATTERNS: &[&str] = &[
// Explicit skip/punt
"// skip", "// skip",
"// skipping", "// skipping",
"// skipping for now", "// skipping for now",
"// for now just", "// for now just",
"// punt", "// punt",
"// punted", "// punted",
// Hack / workaround framing
"// hack:", "// hack:",
"// hacky", "// hacky",
"// hack workaround", "// hack workaround",
"// workaround:", "// workaround:",
"// cba", "// cba",
// Deferral language
"// later", "// later",
"// do later", "// do later",
"// ignore for now", "// ignore for now",
"// disable", "// disable",
"// disabled", "// disabled",
"// bypass", "// bypass",
// Temporary / quick-fix framing (likely will never be revisited)
"// quick fix", "// quick fix",
"// temp fix", "// temp fix",
"// temporary fix", "// temporary fix",
"// temp:", "// temp:",
"// temporary:", "// temporary:",
// No-op placeholder
"// noop", "// noop",
]; ];
/// Assumption-language patterns: words/phrases that indicate the code is /// Assumption-language patterns: words/phrases that indicate the code is
/// reasoning based on guesswork rather than data. /// reasoning based on guesswork rather than data.
pub const ASSUMPTION_PATTERNS: &[&str] = &[ pub const ASSUMPTION_PATTERNS: &[&str] = &[
// Assertions without evidence
"// assume", "// assume",
"// assuming", "// assuming",
// Speculative qualification
"// probably", "// probably",
"// maybe", "// maybe",
"// might", "// might",
@@ -75,14 +86,18 @@ pub const ASSUMPTION_PATTERNS: &[&str] = &[
/// Network-exfiltration and credential-disclosure patterns for bash. /// Network-exfiltration and credential-disclosure patterns for bash.
pub const EXFIL_PATTERNS: &[&str] = &[ pub const EXFIL_PATTERNS: &[&str] = &[
// Network data-transfer tools
"curl ", "curl ",
"wget ", "wget ",
// Reverse shells / netcat
"nc -e ", "nc -e ",
"ncat ", "ncat ",
"/dev/tcp/", "/dev/tcp/",
// Obfuscated payloads
"base64 -d |", "base64 -d |",
"base64 --decode |", "base64 --decode |",
"openssl s_client", "openssl s_client",
// SSH and file-transfer exfiltration
"ssh -R ", "ssh -R ",
"scp /", "scp /",
"rsync /", "rsync /",
@@ -90,17 +105,22 @@ pub const EXFIL_PATTERNS: &[&str] = &[
/// Substrings of well-known credential / secret files that bash must not read. /// Substrings of well-known credential / secret files that bash must not read.
pub const SENSITIVE_PATH_PATTERNS: &[&str] = &[ pub const SENSITIVE_PATH_PATTERNS: &[&str] = &[
// SSH private keys and auth
".ssh/id_rsa", ".ssh/id_rsa",
".ssh/id_ed25519", ".ssh/id_ed25519",
".ssh/authorized_keys", ".ssh/authorized_keys",
// Cloud / package-manager credentials
".aws/credentials", ".aws/credentials",
".aws/config", ".aws/config",
".netrc", ".netrc",
".pypirc", ".pypirc",
".npmrc", ".npmrc",
// Container orchestration secrets
".kube/config", ".kube/config",
".docker/config.json", ".docker/config.json",
// GPG keys
".gnupg/", ".gnupg/",
// System-level secrets
"/etc/shadow", "/etc/shadow",
"/etc/passwd", "/etc/passwd",
"/proc/self/environ", "/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::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::{debug, info};
/// Timeout for the `initialize` handshake (60 s).
const LSP_INIT_TIMEOUT_MS: u64 = 60_000; const LSP_INIT_TIMEOUT_MS: u64 = 60_000;
/// Timeout for regular LSP method calls (30 s).
const LSP_CALL_TIMEOUT_MS: u64 = 30_000; 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; 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 { pub struct LspClient {
/// Write end of the child's stdin pipe.
stdin: std::process::ChildStdin, stdin: std::process::ChildStdin,
/// Buffered read end of the child's stdout pipe.
stdout: BufReader<std::process::ChildStdout>, stdout: BufReader<std::process::ChildStdout>,
/// Monotonically increasing request id for JSON-RPC calls.
next_id: u64, next_id: u64,
/// The `capabilities` blob returned by the server's `initialize` response.
server_capabilities: Value, 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 { fn file_path_to_uri(path: &str) -> String {
let abs_path = std::path::Path::new(path); let abs_path = std::path::Path::new(path);
let abs_path = if abs_path.is_relative() { let abs_path = if abs_path.is_relative() {
@@ -40,7 +68,20 @@ fn file_path_to_uri(path: &str) -> String {
} }
impl LspClient { 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> { pub fn spawn(command: &str, args: &[String]) -> anyhow::Result<Self> {
info!(command = command, "LspClient::spawn");
let mut cmd = Command::new(command); let mut cmd = Command::new(command);
cmd.args(args); cmd.args(args);
cmd.stdin(Stdio::piped()); cmd.stdin(Stdio::piped());
@@ -69,6 +110,7 @@ impl LspClient {
server_capabilities: Value::Null, server_capabilities: Value::Null,
}; };
// Build the `initialize` params with client capabilities.
let init_params = json!({ let init_params = json!({
"processId": std::process::id(), "processId": std::process::id(),
"clientInfo": { "clientInfo": {
@@ -118,21 +160,29 @@ impl LspClient {
&init_params, &init_params,
Duration::from_millis(LSP_INIT_TIMEOUT_MS), 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.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
client.notify("initialized", &json!({}))?; client.notify("initialized", &json!({}))?;
info!(command = command, "LSP client initialized");
Ok(client) Ok(client)
} }
/// Return the server capabilities blob from the `initialize` response.
pub fn server_capabilities(&self) -> &Value { pub fn server_capabilities(&self) -> &Value {
&self.server_capabilities &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> { 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)) 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( fn call_with_timeout(
&mut self, &mut self,
method: &str, method: &str,
@@ -140,26 +190,33 @@ impl LspClient {
timeout: Duration, timeout: Duration,
) -> anyhow::Result<Value> { ) -> anyhow::Result<Value> {
self.next_id += 1; self.next_id += 1;
let id = self.next_id; let id = self.next_id; // unique id for this request
let req = json!({ let req = json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": id, "id": id,
"method": method, "method": method,
"params": params "params": params
}); });
debug!(method = method, id = id, "LSP call");
self.send_frame(&req)?; self.send_frame(&req)?;
self.read_response(id, timeout) self.read_response(id, timeout)
} }
/// Send a JSON-RPC notification (no response expected).
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> { pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
let req = json!({ let req = json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": method, "method": method,
"params": params "params": params
}); });
debug!(method = method, "LSP notify");
self.send_frame(&req) 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<()> { fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
let body = serde_json::to_string(msg) let body = serde_json::to_string(msg)
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
@@ -176,6 +233,11 @@ impl LspClient {
Ok(()) 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> { fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { 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> { pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { 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> { fn read_frame(&mut self) -> anyhow::Result<Value> {
let mut content_length: Option<usize> = None; let mut content_length: Option<usize> = None;
// Read header lines until a blank line.
loop { loop {
let mut line = String::new(); let mut line = String::new();
match self.stdout.read_line(&mut line) { match self.stdout.read_line(&mut line) {
@@ -224,7 +298,7 @@ impl LspClient {
} }
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
break; break; // end of headers
} }
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") { if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
// Cap Content-Length at 64 MiB to prevent OOM from a // 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}")) .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( pub fn did_open(
&mut self, &mut self,
uri: &str, uri: &str,
@@ -264,6 +339,7 @@ impl LspClient {
version: i32, version: i32,
text: &str, text: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
debug!(uri = uri, version = version, "LSP didOpen");
self.notify( self.notify(
"textDocument/didOpen", "textDocument/didOpen",
&json!({ &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<()> { pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
debug!(uri = uri, version = version, "LSP didChange");
self.notify( self.notify(
"textDocument/didChange", "textDocument/didChange",
&json!({ &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<()> { pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
debug!(uri = uri, "LSP didClose");
self.notify( self.notify(
"textDocument/didClose", "textDocument/didClose",
&json!({ &json!({
@@ -326,14 +406,17 @@ impl LspClient {
self.call(method, &body) 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> { pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional("textDocument/hover", uri, line, character, None) 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> { pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional("textDocument/completion", uri, line, character, None) self.call_positional("textDocument/completion", uri, line, character, None)
} }
/// Request the definition location of the symbol at a position.
pub fn goto_definition( pub fn goto_definition(
&mut self, &mut self,
uri: &str, uri: &str,
@@ -343,6 +426,7 @@ impl LspClient {
self.call_positional("textDocument/definition", uri, line, character, None) 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> { pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional( self.call_positional(
"textDocument/references", uri, line, character, "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( pub fn collect_diagnostics(
&mut self, &mut self,
uri: &str, 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) { pub fn shutdown(&mut self) {
info!("LSP shutdown");
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5)); let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
let _ = self.notify("exit", &json!({})); let _ = self.notify("exit", &json!({}));
} }
} }
impl Drop for LspClient { impl Drop for LspClient {
/// Best-effort `exit` notification on drop.
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.notify("exit", &json!({})); 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 { pub fn path_to_lsp_uri(path: &str) -> String {
file_path_to_uri(path) 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::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};
mod client; mod client;
pub mod provisioner; pub mod provisioner;
@@ -70,6 +78,7 @@ impl LspManager {
language_id: language_id.to_string(), language_id: language_id.to_string(),
client: Arc::new(Mutex::new(client)), client: Arc::new(Mutex::new(client)),
}); });
info!(language_id = language_id, command = command, "LSP server connected");
Ok(()) Ok(())
} }
@@ -93,7 +102,11 @@ impl LspManager {
} }
let len = self.servers.len(); let len = self.servers.len();
self.servers.retain(|s| s.language_id != language_id); 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`. /// 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 /// are accepted at this layer — caller must ensure a server for
/// `language_id` is connected or will be connected later. /// `language_id` is connected or will be connected later.
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) { pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
let count = extensions.len();
for ext in extensions { for ext in extensions {
self.extension_registry self.extension_registry
.insert(ext.to_string(), language_id.to_string()); .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. /// 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. /// -> update `open_files` with the new version.
/// ///
/// Non-critical failures (file missing, server unreachable, send /// 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. /// so a stale notification cannot abort the calling flow.
pub fn did_change_file(&mut self, path: &Path) { pub fn did_change_file(&mut self, path: &Path) {
let Some(ext) = path let Some(ext) = path
@@ -132,12 +147,12 @@ impl LspManager {
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.map(|s| format!(".{s}")) .map(|s| format!(".{s}"))
else { else {
tracing::warn!("did_change_file: path has no extension: {:?}", path); warn!("did_change_file: path has no extension: {:?}", path);
return; return;
}; };
let Some(language_id) = self.extension_registry.get(&ext).cloned() else { let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
tracing::warn!( warn!(
"did_change_file: no LSP server registered for extension '{}'", "did_change_file: no LSP server registered for extension '{}'",
ext ext
); );
@@ -149,13 +164,13 @@ impl LspManager {
let text = match std::fs::read_to_string(path) { let text = match std::fs::read_to_string(path) {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e); warn!("did_change_file: failed to read {:?}: {}", path, e);
return; return;
} }
}; };
let Some(client) = self.get_client(&language_id) else { 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; return;
}; };
@@ -168,7 +183,7 @@ impl LspManager {
let mut client = match client.lock() { let mut client = match client.lock() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::warn!( warn!(
"did_change_file: client mutex poisoned for '{}': {}", "did_change_file: client mutex poisoned for '{}': {}",
language_id, language_id,
e e
@@ -184,7 +199,7 @@ impl LspManager {
}; };
if let Err(e) = send_result { if let Err(e) = send_result {
tracing::warn!( warn!(
"did_change_file: failed to notify '{}' for {}: {}", "did_change_file: failed to notify '{}' for {}: {}",
language_id, language_id,
uri, uri,
@@ -208,12 +223,14 @@ impl LspManager {
/// drop the vec. Failures from individual shutdowns are swallowed /// drop the vec. Failures from individual shutdowns are swallowed
/// because the goal is best-effort termination during teardown. /// because the goal is best-effort termination during teardown.
pub fn shutdown_all(&mut self) { pub fn shutdown_all(&mut self) {
let count = self.servers.len();
for server in &self.servers { for server in &self.servers {
if let Ok(mut client) = server.client.lock() { if let Ok(mut client) = server.client.lock() {
client.shutdown(); client.shutdown();
} }
} }
self.servers.clear(); self.servers.clear();
info!(count = count, "all LSP servers shut down");
} }
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs. /// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
@@ -248,6 +265,7 @@ impl LspManager {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
self.connect(command, args, language_id)?; self.connect(command, args, language_id)?;
self.register_extensions(language_id, extensions); self.register_extensions(language_id, extensions);
info!(language_id = language_id, "LSP connected with extensions");
Ok(()) Ok(())
} }
} }
@@ -38,6 +38,8 @@ pub enum ProvisionResult {
/// Sentinel command names used by `provision_single` to detect "download" /// Sentinel command names used by `provision_single` to detect "download"
/// tiers (which are dispatched to `download_*` helpers rather than /// tiers (which are dispatched to `download_*` helpers rather than
/// `run_command`). Kept as constants so `supported_servers` stays readable. /// `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_RUST_BIN: &str = "__download_rust_analyzer__";
pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__"; pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
@@ -1,8 +1,12 @@
//! Environment discovery: finding binaries on PATH and detecting available //! Environment discovery: finding binaries on PATH and detecting available
//! toolchains / package managers on the host system. //! 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::path::PathBuf;
use std::process::Command; use std::process::Command;
use tracing::{debug, info};
/// Rust toolchain availability on the host PATH. /// Rust toolchain availability on the host PATH.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -57,6 +61,16 @@ pub struct EnvInfo {
pub is_macos: bool, 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`. /// Check whether `binary` exists on PATH by shelling out to `which`.
/// ///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix /// 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 /// called during provisioning and the results feed into install-tier
/// gating, which is already cheap. /// gating, which is already cheap.
pub fn which(binary: &str) -> Option<PathBuf> { pub fn which(binary: &str) -> Option<PathBuf> {
debug!(binary = binary, "checking PATH");
let output = Command::new("which").arg(binary).output().ok()?; let output = Command::new("which").arg(binary).output().ok()?;
if !output.status.success() { if !output.status.success() {
return None; return None;
@@ -77,7 +92,9 @@ pub fn which(binary: &str) -> Option<PathBuf> {
if first.is_empty() { if first.is_empty() {
None None
} else { } 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 /// Edge case: `which` may not exist on Windows; we guard with cfg so
/// this only ever runs on Unix-like targets. /// this only ever runs on Unix-like targets.
pub fn detect_env() -> EnvInfo { pub fn detect_env() -> EnvInfo {
EnvInfo { debug!("detecting host environment");
let detected = EnvInfo {
rust: RustToolchain { rust: RustToolchain {
has_rustup: which("rustup").is_some(), has_rustup: which("rustup").is_some(),
has_cargo: which("cargo").is_some(), has_cargo: which("cargo").is_some(),
@@ -116,5 +134,14 @@ pub fn detect_env() -> EnvInfo {
}, },
is_linux: cfg!(target_os = "linux"), is_linux: cfg!(target_os = "linux"),
is_macos: cfg!(target_os = "macos"), 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 //! Each helper downloads a prebuilt binary (or archive) and places it
//! under `~/.local/share/zesdex/lsp/<server-name>/`. //! 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 std::path::{Path, PathBuf};
use tracing::info; use tracing::{debug, info};
use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN}; use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN};
use super::discovery::EnvInfo; use super::discovery::EnvInfo;
use super::manager::run_command; use super::manager::run_command;
/// Resolve the directory where downloaded LSP binaries are stored. /// 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> { fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
let base = dirs::data_dir() let base = dirs::data_dir()
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())? .ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
.join("zesdex") .join("zesdex")
.join("lsp") .join("lsp")
.join(server); .join(server);
debug!(server = server, path = %base.display(), "LSP install dir");
Ok(base) Ok(base)
} }
/// Check whether `def` was previously installed via the download tier /// Check whether `def` was previously installed via the download tier
/// (binary/launcher lives under `~/.local/share/zesdex/lsp/<name>/`). /// (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. /// Returns the path to the binary if found.
pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) -> Option<PathBuf> { pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) -> Option<PathBuf> {
let base = lsp_install_dir(&def.name).ok()?; 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() { let candidates: &[&str] = match def.name.as_str() {
"rust-analyzer" => &["rust-analyzer"], "rust-analyzer" => &["rust-analyzer"],
"jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"], "jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"],
@@ -39,17 +52,24 @@ pub(super) fn previous_download_install(def: &super::config::LanguageServerDef)
if p.exists() { if p.exists() {
// Skip directory entries that exist but are the base dir itself. // Skip directory entries that exist but are the base dir itself.
if p.is_file() { if p.is_file() {
debug!(name = %def.name, path = %p.display(), "found previous install");
return Some(p); return Some(p);
} }
} }
} }
debug!(name = %def.name, "no previous install found");
None None
} }
/// Download a file from `url` to `dest` using curl. /// 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> { 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(); 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 = [ let args = [
"-fsSL", "-fsSL",
"--connect-timeout", "--connect-timeout",
@@ -64,11 +84,15 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
if !ok { if !ok {
return Err(format!("download failed: {}", out.trim())); return Err(format!("download failed: {}", out.trim()));
} }
info!(url = url, "download complete");
Ok(()) Ok(())
} }
/// Download rust-analyzer from GitHub releases and install into /// 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( fn install_rust_analyzer_binary(
env: &EnvInfo, env: &EnvInfo,
progress: ProgressFn<'_>, progress: ProgressFn<'_>,
@@ -76,6 +100,7 @@ fn install_rust_analyzer_binary(
let base = lsp_install_dir("rust-analyzer")?; let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?; 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 { let url = if env.is_linux {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz" "https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
} else if env.is_macos { } else if env.is_macos {
@@ -84,9 +109,10 @@ fn install_rust_analyzer_binary(
return Err("no prebuilt binary for this OS".to_string()); return Err("no prebuilt binary for this OS".to_string());
}; };
let gz = base.join("rust-analyzer.gz"); let gz = base.join("rust-analyzer.gz"); // downloaded archive
let target = base.join("rust-analyzer"); let target = base.join("rust-analyzer"); // final binary path
info!("rust-analyzer: downloading prebuilt binary");
if let Some(cb) = progress { if let Some(cb) = progress {
cb("Rust: downloading prebuilt binary..."); cb("Rust: downloading prebuilt binary...");
} }
@@ -103,6 +129,7 @@ fn install_rust_analyzer_binary(
if !target.exists() { if !target.exists() {
return Err("binary missing after decompression".to_string()); return Err("binary missing after decompression".to_string());
} }
// Set executable bit on Unix (0o755 = rwxr-xr-x).
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -112,17 +139,24 @@ fn install_rust_analyzer_binary(
if let Some(cb) = progress { if let Some(cb) = progress {
cb("Rust: installed ✓"); cb("Rust: installed ✓");
} }
info!("rust-analyzer: installed at {}", target.display());
Ok(target) Ok(target)
} }
/// Download Eclipse JDT-LS from the official snapshot server, extract it, /// Download Eclipse JDT-LS from the official snapshot server, extract it,
/// and create a launcher script at `bin/jdtls`. /// 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> { fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let base = lsp_install_dir("jdtls")?; let base = lsp_install_dir("jdtls")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?; 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 url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
let tarball = base.join("jdtls.tar.gz"); let tarball = base.join("jdtls.tar.gz");
info!("jdtls: downloading (~150 MB)");
if let Some(cb) = progress { if let Some(cb) = progress {
cb("Java: downloading JDT-LS (~150MB)..."); 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); let _ = std::fs::remove_file(&tarball);
// Validate that the extracted contents include the plugins directory.
if !base.join("plugins").exists() { if !base.join("plugins").exists() {
return Err("extracted archive missing plugins/ directory".to_string()); return Err("extracted archive missing plugins/ directory".to_string());
} }
@@ -182,15 +217,20 @@ exec java \
if let Some(cb) = progress { if let Some(cb) = progress {
cb("Java: JDT-LS installed ✓"); cb("Java: JDT-LS installed ✓");
} }
info!("jdtls: installed at {}", launcher.display());
Ok(launcher) Ok(launcher)
} }
/// Dispatch a sentinel download tier to the correct helper. /// 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( pub(super) fn run_download_tier(
name: &str, name: &str,
env: &EnvInfo, env: &EnvInfo,
progress: ProgressFn<'_>, progress: ProgressFn<'_>,
) -> Result<PathBuf, String> { ) -> Result<PathBuf, String> {
info!(tier = name, "running download tier");
match name { match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress), DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress), DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
@@ -5,7 +5,7 @@ use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{info, warn}; use tracing::{debug, info, warn};
use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult}; use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult};
use super::discovery::{self, EnvInfo}; 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, /// 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. /// 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)> { 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); let mut command = Command::new(cmd);
command.args(args); command.args(args);
command.stdout(Stdio::piped()); command.stdout(Stdio::piped());
@@ -1,10 +1,11 @@
//! Auto-provisioning engine for LSP language servers. //! Auto-provisioning engine for LSP language servers.
//! //!
//! Flow: `detect_env()` → for each supported server in `supported_servers()` //! Flow: [`discovery::detect_env()`] probes the host → for each server in
//! → `provision_single()` tries install tiers in order → returns //! [`config::supported_servers()`] → [`manager::provision_all_with_progress()`]
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed). //! tries install tiers in order → returns [`config::ProvisionResult`]
//! Caller can then call `auto_connect()` to attach available servers //! (`AlreadyAvailable` / Installed / Failed).
//! to an existing `LspManager`. //! 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 //! Why: opening a project on a fresh machine should not require the user
//! to manually hunt down and install 4 different language servers. //! to manually hunt down and install 4 different language servers.
@@ -26,13 +27,13 @@ pub use config::{InstallTier, LanguageServerDef, ProgressFn, ProvisionResult};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use config::supported_servers; pub use config::supported_servers;
// Environment discovery // Environment discovery — toolchain and package-manager detection
#[allow(unused_imports)] #[allow(unused_imports)]
pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain}; pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use discovery::which; pub use discovery::which;
// Manager / orchestration // Manager / orchestration — provisioning loop and LspManager attachment
#[allow(unused_imports)] #[allow(unused_imports)]
pub use manager::{auto_connect, provision_all_with_progress, run_command}; pub use manager::{auto_connect, provision_all_with_progress, run_command};
+16 -6
View File
@@ -1,9 +1,16 @@
//! MCP server connection management: spawning/talking to stdio child //! MCP server connection management: spawning/talking to stdio child
//! processes and HTTP endpoints, and adapting their advertised tools to //! processes and HTTP endpoints, and adapting their advertised tools to
//! the crate's `Tool` trait. //! 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::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tracing::{info, warn};
use super::transport::{call_via_http, call_via_stdio, mcp_static_str, spawn_stdio_child}; use super::transport::{call_via_http, call_via_stdio, mcp_static_str, spawn_stdio_child};
@@ -128,6 +135,7 @@ impl McpManager {
command: &str, command: &str,
extra_args: &[String], extra_args: &[String],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
info!(name = name, command = command, "MCP connect stdio");
let transport = McpTransport::Stdio { let transport = McpTransport::Stdio {
command: command.to_string(), command: command.to_string(),
args: extra_args.to_vec(), args: extra_args.to_vec(),
@@ -146,17 +154,17 @@ impl McpManager {
.get("description") .get("description")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or_else(|| { .unwrap_or_else(|| {
tracing::warn!( warn!(
"[mcp] tool {} missing description", tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"),
t.get("name").and_then(|n| n.as_str()).unwrap_or("?") "MCP tool missing description"
); );
"" ""
}) })
.to_string(), .to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| { input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
tracing::warn!( warn!(
"[mcp] tool {} missing inputSchema", tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"),
t.get("name").and_then(|n| n.as_str()).unwrap_or("?") "MCP tool missing inputSchema"
); );
serde_json::Value::Null serde_json::Value::Null
}), }),
@@ -169,6 +177,7 @@ impl McpManager {
let handle = Arc::new(Mutex::new(child)); let handle = Arc::new(Mutex::new(child));
let tool_count = tools.len();
self.servers.push(McpServer { self.servers.push(McpServer {
name: name.to_string(), name: name.to_string(),
transport, transport,
@@ -176,6 +185,7 @@ impl McpManager {
child_handle: Some(handle), child_handle: Some(handle),
}); });
info!(name = name, tool_count = tool_count, "MCP server connected");
Ok(()) Ok(())
} }
} }
+7 -2
View File
@@ -1,4 +1,9 @@
//! Model Context Protocol (MCP) client: connects to external MCP servers //! Model Context Protocol (MCP) client: connects to external MCP servers
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait. //! (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. //! MCP transport layer: stdio child process management and HTTP client calls.
//! This module handles the low-level protocol details of communicating with //! This module handles the low-level protocol details of communicating with
//! MCP servers (both spawned subprocesses and remote HTTP endpoints). //! 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::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use tracing::{debug, info, warn};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // 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() { let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() {
Ok(c) => c, Ok(c) => c,
Err(poisoned) => { Err(poisoned) => {
tracing::warn!("[mcp] static string cache mutex poisoned, recovering"); warn!("[mcp] static string cache mutex poisoned, recovering");
poisoned.into_inner() poisoned.into_inner()
} }
}; };
@@ -88,6 +93,7 @@ impl StdioChild {
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
self.next_id += 1; self.next_id += 1;
let id = self.next_id; let id = self.next_id;
debug!(method = method, id = id, "MCP stdio call");
let req = json!({ let req = json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": id, "id": id,
@@ -163,7 +169,7 @@ impl StdioChild {
anyhow::bail!("MCP error: {err}"); anyhow::bail!("MCP error: {err}");
} }
return Ok(resp.get("result").cloned().unwrap_or_else(|| { 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 Value::Null
})); }));
} }
@@ -179,6 +185,7 @@ pub(crate) fn spawn_stdio_child(
command: &str, command: &str,
extra_args: &[String], extra_args: &[String],
) -> anyhow::Result<StdioChild> { ) -> anyhow::Result<StdioChild> {
info!(command = command, "MCP spawn stdio child");
let parts: Vec<&str> = command.split_whitespace().collect(); let parts: Vec<&str> = command.split_whitespace().collect();
let (prog, prog_args) = parts let (prog, prog_args) = parts
.split_first() .split_first()
@@ -249,6 +256,7 @@ pub(super) fn call_via_stdio(
tool_name: &str, tool_name: &str,
tool_args: &Value, tool_args: &Value,
) -> anyhow::Result<String> { ) -> 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. // Reuse the persistent child handle if available; otherwise spawn a new one.
let mut guard; let mut guard;
let child: &mut StdioChild = if let Some(mtx) = existing_handle { 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}"))?; .map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
&mut guard &mut guard
} else { } else {
// No persistent handle — spawn a fresh child for this one call.
let mut fresh = spawn_stdio_child(command, extra_args)?; let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call( let result = fresh.call(
"tools/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> { 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() let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS)) .timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS)) .connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
.build() .build()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::warn!( warn!(
"[mcp] HTTP client builder failed with connect timeout: {}. \ "MCP HTTP client builder failed with connect timeout: {}. \
retrying without connect timeout", retrying without connect timeout",
e, 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)) .timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.build() .build()
.unwrap_or_else(|e2| { .unwrap_or_else(|e2| {
tracing::warn!( warn!(
"[mcp] also failed: {}. using default client (no configured timeouts)", "MCP also failed: {}. using default client (no configured timeouts)",
e2, e2,
); );
reqwest::blocking::Client::new() 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() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
let text = resp.text().unwrap_or_else(|e| { 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() String::new()
}); });
anyhow::bail!("MCP HTTP server returned {status}: {text}"); 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(|| { 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 Value::Null
}); });
Ok(extract_text_content(&result)) 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| { 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() result.to_string()
}) })
} }
+12 -11
View File
@@ -1,14 +1,15 @@
//! Top-level application module: tool gate, modes, runtime loop, state, //! Top-level application module: tool gate, modes, runtime loop, state,
//! workflows, subagents, review, background bash, MCP integration, and //! workflows, subagents, review, background bash, MCP integration, and
//! native LSP client. //! native LSP client.
pub mod bgbash;
pub mod guard; pub mod bgbash; // Background bash process management
pub mod lsp; pub mod guard; // Tool gate: per-tool access control & permissions
pub mod mcp; pub mod lsp; // Native LSP client integration
pub mod mode; pub mod mcp; // Model Context Protocol tool bridge
pub mod review; pub mod mode; // Application operating modes (normal, yolo, etc.)
pub mod runtime; pub mod review; // Post-edit auto-review subagent
pub mod state; pub mod runtime; // Action dispatch, streams, slash commands
pub mod subagent; pub mod state; // AppStateRest, runtime state, turn events
pub mod util; pub mod subagent; // Spawned subagents (test-gen, arch, security review)
pub mod workflow; 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. //! Bash mode: handles submitting a shell command from the bash input panel.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Launch a background bash job for the submitted command. /// 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. /// the shared jobs map for later polling.
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) { pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
if !command.is_empty() { if !command.is_empty() {
debug!(command_len = command.len(), "spawning bash job from mode");
let _ = crate::app::bgbash::job::spawn_bash_job(command); let _ = crate::app::bgbash::job::spawn_bash_job(command);
state.dirty = true; state.dirty = true;
} else {
debug!("bash submit with empty command — ignored");
} }
} }
@@ -2,6 +2,7 @@
//! with bounded undo history. //! with bounded undo history.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
use tracing::debug;
/// State for the built-in line editor overlay: buffer contents, cursor /// State for the built-in line editor overlay: buffer contents, cursor
/// position, and a bounded undo stack. /// position, and a bounded undo stack.
@@ -30,7 +31,9 @@ impl EditorState {
/// Create a fresh editor state for `path`, seeded with existing content /// Create a fresh editor state for `path`, seeded with existing content
/// (or a single empty line for a new file). /// (or a single empty line for a new file).
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self { pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
let is_new = existing_content.is_none();
let content = existing_content.unwrap_or_else(|| vec![String::new()]); let content = existing_content.unwrap_or_else(|| vec![String::new()]);
debug!(path = %path, is_new, lines = content.len(), "editor opened");
EditorState { EditorState {
path, path,
content, content,
@@ -116,6 +119,7 @@ impl EditorState {
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) { pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
let editor = &mut state.misc.editor; let editor = &mut state.misc.editor;
let Some(ed) = editor.as_mut() else { let Some(ed) = editor.as_mut() else {
debug!("editor input received but no editor open — ignored");
return; return;
}; };
for c in text.chars() { 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. /// 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) { pub fn handle_editor_dismiss(state: &mut AppStateRest) {
debug!("editor dismissed without saving");
state.misc.editor = None; state.misc.editor = None;
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
state.dirty = true; state.dirty = true;
+12 -3
View File
@@ -1,13 +1,18 @@
//! Effort mode: cycles the agent's reasoning effort level, which scales the //! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and `max_tokens` for subsequent turns. //! LLM's temperature and `max_tokens` for subsequent turns.
use crate::app::state::rest::AppStateRest; 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"]; pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use, /// Multiplier applied to the user's configured `max_tokens` per effort level.
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token /// Same index as `EFFORT_LEVELS`. Higher effort = larger token budget.
/// budget for lower temperature (more deterministic, more room to reason/act).
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0]; 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]; 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 /// 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"). /// 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 { pub fn current_effort_str(state: &AppStateRest) -> &'static str {
let idx = current_effort(state); let idx = current_effort(state);
EFFORT_LEVELS[idx] EFFORT_LEVELS[idx]
@@ -41,6 +49,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state); let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len(); state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
let label = current_effort_str(state); let label = current_effort_str(state);
debug!(from = %EFFORT_LEVELS[current], to = %label, "effort level cycled");
state.toast_info(format!("Effort: {label}")); state.toast_info(format!("Effort: {label}"));
state.dirty = true; 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 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) { pub fn handle_key_text(state: &mut AppStateRest, text: String) {
debug!(len = text.len(), "key-input text captured");
state.input.buffer = text; state.input.buffer = text;
state.dirty = true; 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 crate::app::state::rest::AppStateRest;
use tracing::debug;
use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::domain::repository::MemoryRepository;
/// A unified representation of a lesson item for the interactive TUI overlay. /// 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)] #[derive(Debug, Clone)]
pub enum LearningItem { pub enum LearningItem {
Pending { 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> { pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let mut items = Vec::new(); let mut items = Vec::new();
@@ -29,6 +50,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
} else { } else {
Vec::new() Vec::new()
}; };
debug!(pending_count = pending.len(), "loading pending lessons");
for p in pending { for p in pending {
let scope_str = match p.lesson.scope { 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() zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
.list(&state.memory_dir) .list(&state.memory_dir)
.unwrap_or_default(); .unwrap_or_default();
debug!(stored_names = names.len(), "loading stored lessons");
for name in names { for name in names {
if let Ok(mem) = if let Ok(mem) =
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new() 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 items
} }
+14
View File
@@ -1,11 +1,25 @@
//! MCP mode: overlay for connecting to a configured MCP server. //! 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 crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Placeholder entry point for connecting to an MCP server by name. /// 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 /// Why: not yet wired to `McpManager::connect_stdio` — currently just
/// marks state dirty so the overlay re-renders. /// 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) { pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
debug!(%server_name, "connect_mcp called");
let _ = server_name; let _ = server_name;
// Mark state dirty to trigger a re-render of the MCP overlay.
state.dirty = true; state.dirty = true;
} }
+13 -10
View File
@@ -1,16 +1,19 @@
//! TUI mode definitions and per-mode input/action handlers, one submodule //! TUI mode definitions and per-mode input/action handlers, one submodule
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.). //! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
pub mod bash; //! Each mode encapsulates its own keyboard input parsing, state transitions,
pub mod editor; //! and view rendering so the top-level event loop can dispatch generically.
pub mod effort;
pub mod key_input;
pub mod mcp;
pub mod learning; pub mod bash; // Shell-command input overlay: prompt, history, execution
pub mod quit_confirm; pub mod editor; // Multi-line text editor overlay (write/edit tool content)
pub mod rewind; pub mod effort; // Reasoning-effort selector overlay
pub mod settings; pub mod key_input; // Generic single-key prompt overlay (e.g. rename, search)
pub mod todo; 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)`. /// Cycle `current` in the range `[0, len)`.
/// ///
@@ -1,11 +1,18 @@
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting. //! 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 crate::app::runtime::actions::Action;
use tracing::debug;
/// Translate the user's yes/no answer on the quit-confirm overlay into an action. /// 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` /// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
/// to dismiss the prompt without quitting. /// to dismiss the prompt without quitting.
pub fn handle_quit_confirm(yes: bool) -> Action { pub fn handle_quit_confirm(yes: bool) -> Action {
debug!(%yes, "handle_quit_confirm");
if yes { if yes {
Action::ForceQuit Action::ForceQuit
} else { } else {
+39 -4
View File
@@ -1,23 +1,47 @@
//! Rewind mode: restores a file to a pre-edit snapshot stored in the //! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store. //! 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 crate::app::state::rest::AppStateRest;
use sha2::Digest; use sha2::Digest;
use tracing::{debug, info};
use zesdex_cms::domain::repository::EditLogRepository; use zesdex_cms::domain::repository::EditLogRepository;
/// Returns the number of stored pre-edit blobs (snapshots) for this session. /// 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 { pub fn rewind_count(state: &AppStateRest) -> usize {
let Ok(conn) = open_session_db(&state.session_dir) else { let Ok(conn) = open_session_db(&state.session_dir) else {
debug!("rewind_count: cannot open session DB, returning 0");
return 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() .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 /// 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 /// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
/// of a running turn (e.g. from the Rewind overlay). /// 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) { pub fn rewind_to(state: &mut AppStateRest, index: usize) {
debug!(%index, "rewind_to start");
let conn = match open_session_db(&state.session_dir) { let conn = match open_session_db(&state.session_dir) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -66,6 +90,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
match std::fs::write(&restore_path, &bytes) { match std::fs::write(&restore_path, &bytes) {
Ok(()) => { Ok(()) => {
info!(path = %restore_path.display(), "rewind_to: file restored from snapshot");
state.toast_success(format!("Restored {} from snapshot", restore_path.display())); state.toast_success(format!("Restored {} from snapshot", restore_path.display()));
} }
Err(e) => { 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 = let repo =
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&state.session_dir) { 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); 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.transcript_cache.dirty = true;
state.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> { fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite"); let path = session_dir.join("messages.sqlite");
let conn = rusqlite::Connection::open(&path)?; let conn = rusqlite::Connection::open(&path)?;
debug!(path = %path.display(), "open_session_db opened");
Ok(conn) 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> { fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&state.session_dir) .open(&state.session_dir)
@@ -3,6 +3,7 @@
//! Flow: exposes small mutation functions (currently just cycling the //! Flow: exposes small mutation functions (currently just cycling the
//! internet access mode) invoked by keybindings while the settings overlay //! internet access mode) invoked by keybindings while the settings overlay
//! is active. //! is active.
use tracing::debug;
use zesdex_cms::domain::settings::{InternetMode, Settings}; use zesdex_cms::domain::settings::{InternetMode, Settings};
/// Advance the internet access mode to the next value in the cycle. /// 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. /// Return: nothing; mutates `settings.internet_mode` in place.
pub fn cycle_internet_mode(settings: &mut Settings) { pub fn cycle_internet_mode(settings: &mut Settings) {
let before = settings.internet_mode.clone();
settings.internet_mode = match settings.internet_mode { settings.internet_mode = match settings.internet_mode {
InternetMode::Off => InternetMode::ReadOnly, InternetMode::Off => InternetMode::ReadOnly,
InternetMode::ReadOnly => InternetMode::Full, InternetMode::ReadOnly => InternetMode::Full,
InternetMode::Full => InternetMode::Off, InternetMode::Full => InternetMode::Off,
}; };
debug!(?before, ?settings.internet_mode, "cycle_internet_mode");
} }
@@ -4,6 +4,7 @@
//! the todo overlay. //! the todo overlay.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
use tracing::debug;
/// Toggle the todo-list overlay open or closed. /// 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. /// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
pub fn handle_todo_toggle(state: &mut AppStateRest) { pub fn handle_todo_toggle(state: &mut AppStateRest) {
let before = state.misc.overlay;
if state.misc.overlay == Overlay::Todo { if state.misc.overlay == Overlay::Todo {
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
} else { } else {
state.misc.overlay = Overlay::Todo; state.misc.overlay = Overlay::Todo;
} }
debug!(before = %before, after = %state.misc.overlay, "handle_todo_toggle");
state.dirty = true; 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 /// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead). /// itself (that failure is reported via a `SystemNote` instead).
pub fn trigger_review(state: &mut AppStateRest) { pub fn trigger_review(state: &mut AppStateRest) {
tracing::info!("[review] triggering quality-review subagent");
state.misc.lesson_running = true; 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() { if let Some(workspace) = state.workspace_roots.first() {
let gitignore_path = workspace.join(".gitignore"); let gitignore_path = workspace.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default(); 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.session_dir.clone_from(&state.session_dir);
ctx.workspaces.clone_from(&state.workspace_roots); 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( let probe_result = probe::probe_build_test(
&state.workspace_roots, &state.workspace_roots,
state.settings.verify_command.as_deref(), state.settings.verify_command.as_deref(),
@@ -117,20 +122,30 @@ pub fn trigger_review(state: &mut AppStateRest) {
let probe_note = match &probe_result { let probe_note = match &probe_result {
Some(r) => { Some(r) => {
if r.passed { if r.passed {
tracing::debug!("[review] probe passed: {}", r.command);
format!("Build/test verification passed ({}).", r.command) format!("Build/test verification passed ({}).", r.command)
} else if r.timed_out { } else if r.timed_out {
tracing::debug!("[review] probe timed out: {}", r.command);
format!("Build/test verification timed out ({}).", r.command) format!("Build/test verification timed out ({}).", r.command)
} else { } else {
tracing::debug!("[review] probe failed: {}", r.command);
format!( format!(
"Build/test verification failed ({}). Output: {}", "Build/test verification failed ({}). Output: {}",
r.command, r.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); 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 turn_events_for_drain = state.turn_events.clone();
let (tx, _drain_thread) = spawn_subagent_with_drain(move |event| { 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(); let turn_events = state.turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
tracing::debug!("[review] subagent thread started");
let result = run_subagent(&ctx, &tx); let result = run_subagent(&ctx, &tx);
let message = match result { let message = match result {
Ok(verdict) => { 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( state.push_toast(Toast::new(
ToastKind::Info, ToastKind::Info,
"Generating lesson...".to_string(), "Generating lesson...".to_string(),
@@ -60,12 +60,13 @@ pub fn process_pending_lessons(
) -> std::io::Result<Vec<PendingLesson>> { ) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir); let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
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 remaining = Vec::new();
let mut to_keep = Vec::new(); let mut to_keep = Vec::new();
for p in &pending { for p in &pending {
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window { 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()); to_keep.push(p.lesson.clone());
} else { } else {
remaining.push(p.clone()); remaining.push(p.clone());
@@ -119,6 +120,7 @@ pub fn resolve_pending_lesson(
for p in pending { for p in pending {
if p.lesson.name == lesson_name { if p.lesson.name == lesson_name {
if keep { if keep {
tracing::info!("[pending] committing lesson: {lesson_name}");
let mem = Memory { let mem = Memory {
name: p.lesson.name.clone(), name: p.lesson.name.clone(),
description: p.lesson.content.chars().take(80).collect(), description: p.lesson.content.chars().take(80).collect(),
@@ -136,6 +138,8 @@ pub fn resolve_pending_lesson(
MarkdownMemoryRepository::new() MarkdownMemoryRepository::new()
.save(memory_dir, &mem) .save(memory_dir, &mem)
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
} else {
tracing::debug!("[pending] discarding lesson: {lesson_name}");
} }
} else { } else {
remaining.push(p); remaining.push(p);
+22 -1
View File
@@ -35,6 +35,10 @@ pub fn probe_build_test(
let probe_dir = workspaces.first()?; let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?; let cmd = resolve_verify_command(probe_dir, verify_command)?;
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( let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(
|| (cmd.clone(), String::new()), || (cmd.clone(), String::new()),
|(p, a)| (p.to_string(), a.to_string()), |(p, a)| (p.to_string(), a.to_string()),
@@ -47,6 +51,7 @@ pub fn probe_build_test(
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
.spawn() .spawn()
else { else {
tracing::warn!("[probe] failed to spawn: {cmd_prog}");
return None; return None;
}; };
@@ -72,9 +77,14 @@ pub fn probe_build_test(
} else { } else {
format!("{stdout}\n{stderr}") format!("{stdout}\n{stderr}")
}; };
let passed = status.success();
tracing::debug!(
"[probe] finished: passed={passed}, exit={:?}",
status.code()
);
return Some(ProbeResult { return Some(ProbeResult {
command: cmd.clone(), command: cmd.clone(),
passed: status.success(), passed,
output: truncate_output(&combined, 2048), output: truncate_output(&combined, 2048),
timed_out: false, timed_out: false,
}); });
@@ -86,6 +96,7 @@ pub fn probe_build_test(
} }
}; };
if timed_out { if timed_out {
tracing::debug!("[probe] timed out after {timeout_ms}ms: {cmd}");
Some(ProbeResult { Some(ProbeResult {
command: cmd.clone(), command: cmd.clone(),
passed: false, passed: false,
@@ -93,6 +104,7 @@ pub fn probe_build_test(
timed_out: true, timed_out: true,
}) })
} else { } else {
tracing::debug!("[probe] unexpected exit from polling loop for: {cmd}");
None None
} }
} }
@@ -114,23 +126,30 @@ pub(crate) fn resolve_verify_command(
probe_dir: &std::path::Path, probe_dir: &std::path::Path,
override_cmd: Option<&str>, override_cmd: Option<&str>,
) -> Option<String> { ) -> Option<String> {
// Use explicit override if provided and non-empty.
if let Some(cmd) = override_cmd { if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() { if !cmd.trim().is_empty() {
tracing::debug!("[probe] using override command: {cmd}");
return Some(cmd.trim().to_string()); 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_file = |name: &str| probe_dir.join(name).exists();
let has_dir = |name: &str| probe_dir.join(name).is_dir(); let has_dir = |name: &str| probe_dir.join(name).is_dir();
if has_file("Cargo.toml") { if has_file("Cargo.toml") {
tracing::debug!("[probe] detected Cargo project");
if has_dir("src") || has_dir("tests") { 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 && cargo test 2>&1".to_string());
} }
return Some("cargo build 2>&1".to_string()); return Some("cargo build 2>&1".to_string());
} }
if has_file("go.mod") { if has_file("go.mod") {
tracing::debug!("[probe] detected Go project");
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string()); return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
} }
if has_file("package.json") { if has_file("package.json") {
tracing::debug!("[probe] detected Node project");
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?; 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) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?; let scripts = v.get("scripts")?;
@@ -160,6 +179,7 @@ pub(crate) fn resolve_verify_command(
|| has_file("Pipfile") || has_file("Pipfile")
|| has_file("poetry.lock") || has_file("poetry.lock")
{ {
tracing::debug!("[probe] detected Python project");
if has_file("pyproject.toml") { if has_file("pyproject.toml") {
let content = let content =
std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default(); 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") { if has_file("Project.toml") || has_file("JuliaProject.toml") {
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string()); 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 None
} }
@@ -9,6 +9,8 @@ pub(crate) const STALE_AFTER_DAYS: i64 = 60;
/// Compose the system prompt for the quality-review subagent. /// Compose the system prompt for the quality-review subagent.
pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String { 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() { let diff_output = if let Some(workspace) = state.workspace_roots.first() {
std::process::Command::new("git") std::process::Command::new("git")
.arg("diff") .arg("diff")
@@ -22,6 +24,8 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S
String::new() 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 history_output = if let Some(rt) = &state.session_runtime {
let msgs: Vec<String> = rt let msgs: Vec<String> = rt
.messages .messages
@@ -41,6 +45,11 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S
String::new() String::new()
}; };
tracing::debug!(
"[prompt] diff={}chars, history={}chars",
diff_output.len(),
history_output.len()
);
let session_dir_disp = state.session_dir.display(); let session_dir_disp = state.session_dir.display();
format!( format!(
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\ "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 /// Return: names of newly-flagged memories, or an I/O error from
/// `mem.write`. /// `mem.write`.
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> { pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
tracing::debug!("[staleness] starting sweep in {:?}", memory_dir);
let mut flagged = Vec::new(); let mut flagged = Vec::new();
let names = MarkdownMemoryRepository::new() let names = MarkdownMemoryRepository::new()
.list(memory_dir) .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 { for name in names {
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) { if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" { if mem.updated_at < cutoff && mem.lifecycle != "stale" {
tracing::info!("[staleness] flagging as stale: {name}");
mem.lifecycle = "stale".to_string(); mem.lifecycle = "stale".to_string();
MarkdownMemoryRepository::new() MarkdownMemoryRepository::new()
.save(memory_dir, &mem) .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) { pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 { if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
tracing::trace!("[staleness] sweep skipped (rate-limited)");
return; return;
} }
tracing::debug!("[staleness] sweep window elapsed, running");
state.misc.last_staleness_sweep_ms = now; state.misc.last_staleness_sweep_ms = now;
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) { if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
if !flagged.is_empty() { if !flagged.is_empty() {
@@ -53,6 +53,7 @@ pub struct Lesson {
pub provenance: Provenance, pub provenance: Provenance,
} }
/// Default is an empty unverified project-scoped lesson with no provenance.
impl Default for Lesson { impl Default for Lesson {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -1,5 +1,7 @@
//! Maps parsed `/` slash commands into one or more `Action` variants //! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process. //! that `apply_action` can process.
use tracing::debug;
use crate::app::runtime::actions::Action; use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
use crate::controller::command::Command; 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 /// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
/// by `apply_action`. /// by `apply_action`.
pub fn apply_command(command: Command) -> Vec<Action> { pub fn apply_command(command: Command) -> Vec<Action> {
debug!("apply_command: {:?}", command);
match command { match command {
// ── Navigation overlays ────────────────────────────────────────
Command::Help => { Command::Help => {
vec![Action::OpenOverlay(Overlay::Help)] vec![Action::OpenOverlay(Overlay::Help)]
} }
@@ -27,12 +31,16 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::ClearConfirm => { Command::ClearConfirm => {
vec![Action::OpenOverlay(Overlay::ClearConfirm)] vec![Action::OpenOverlay(Overlay::ClearConfirm)]
} }
// ── System actions ─────────────────────────────────────────────
Command::Clear => { Command::Clear => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "clear".to_string(), kind: "clear".to_string(),
message: "transcript cleared".to_string(), message: "transcript cleared".to_string(),
}] }]
} }
// ── Login / auth ───────────────────────────────────────────────
Command::Login { provider } if provider.is_empty() => { Command::Login { provider } if provider.is_empty() => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
@@ -42,6 +50,8 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Login { provider } => { Command::Login { provider } => {
vec![Action::StartOAuth { provider }] vec![Action::StartOAuth { provider }]
} }
// ── Editor ─────────────────────────────────────────────────────
Command::Edit(path) if path == "." || path.is_empty() => { Command::Edit(path) if path == "." || path.is_empty() => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "info".to_string(), kind: "info".to_string(),
@@ -51,6 +61,8 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Edit(path) => { Command::Edit(path) => {
vec![Action::OpenEditor { path }] vec![Action::OpenEditor { path }]
} }
// ── Tools / configuration ──────────────────────────────────────
Command::McpAdd { name, command } => { Command::McpAdd { name, command } => {
vec![Action::McpAdd { name, command }] vec![Action::McpAdd { name, command }]
} }
@@ -61,12 +73,15 @@ pub fn apply_command(command: Command) -> Vec<Action> {
vec![Action::Compact] vec![Action::Compact]
} }
// ── Dashboard overlays ─────────────────────────────────────────
Command::TodoOpen => { Command::TodoOpen => {
vec![Action::OpenOverlay(Overlay::Todo)] vec![Action::OpenOverlay(Overlay::Todo)]
} }
Command::UsageOpen => { Command::UsageOpen => {
vec![Action::OpenOverlay(Overlay::Usage)] vec![Action::OpenOverlay(Overlay::Usage)]
} }
// ── Fallback ───────────────────────────────────────────────────
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
@@ -1,6 +1,15 @@
//! Simple action handler functions — one per `Action` variant, called by //! Simple action handler functions — one per `Action` variant, called by
//! `apply_action` in the root module. Each handler mutates `AppStateRest` //! `apply_action` in the root module. Each handler mutates `AppStateRest`
//! in place. //! 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::tokens::count_tokens;
use crate::app::runtime::context::window; use crate::app::runtime::context::window;
@@ -16,71 +25,104 @@ use super::memory::refresh_lesson_counters;
use super::spawn::spawn_turn; use super::spawn::spawn_turn;
use super::oauth::run_oauth_flow; 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) { pub(super) fn handle_force_quit(state: &mut AppStateRest) {
save_current_session(state); debug!("handle_force_quit");
state.shutdown_lsp(); save_current_session(state); // Persist session metadata + messages
state.quit = true; 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) { pub(super) fn handle_submit_input(state: &mut AppStateRest, text: String) {
debug!("handle_submit_input: len={}", text.len());
state.input.submit(); state.input.submit();
let text = text.trim().to_string(); let text = text.trim().to_string();
if text.is_empty() { if text.is_empty() {
state.dirty = true; state.dirty = true;
return; return;
} }
// Push user message into both the display transcript and the session-runtime message list
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone())); state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::user(text)); rt.push_message(ChatMessage::user(text));
refresh_lesson_counters(&state.memory_dir, rt); refresh_lesson_counters(&state.memory_dir, rt);
} else { } else {
// No active session — ensure the memory directory exists for future use
let _ = std::fs::create_dir_all(&state.memory_dir); let _ = std::fs::create_dir_all(&state.memory_dir);
} }
state.misc.thinking = true; state.misc.thinking = true;
spawn_turn(state); spawn_turn(state); // launches LLM streaming on a background OS thread
state.dirty = true; state.dirty = true;
} }
/// Delete one character left of the cursor in the input buffer.
pub(super) fn handle_delete_char(state: &mut AppStateRest) { pub(super) fn handle_delete_char(state: &mut AppStateRest) {
debug!("handle_delete_char");
state.input.delete_left(); state.input.delete_left();
state.dirty = true; state.dirty = true;
} }
/// Delete one character right of the cursor in the input buffer.
pub(super) fn handle_delete_char_right(state: &mut AppStateRest) { pub(super) fn handle_delete_char_right(state: &mut AppStateRest) {
debug!("handle_delete_char_right");
state.input.delete_right(); state.input.delete_right();
state.dirty = true; state.dirty = true;
} }
/// Move the cursor one position left.
pub(super) fn handle_cursor_left(state: &mut AppStateRest) { pub(super) fn handle_cursor_left(state: &mut AppStateRest) {
debug!("handle_cursor_left");
state.input.char_left(); state.input.char_left();
} }
/// Move the cursor one position right.
pub(super) fn handle_cursor_right(state: &mut AppStateRest) { pub(super) fn handle_cursor_right(state: &mut AppStateRest) {
debug!("handle_cursor_right");
state.input.char_right(); state.input.char_right();
} }
/// Navigate up through input history.
pub(super) fn handle_history_up(state: &mut AppStateRest) { pub(super) fn handle_history_up(state: &mut AppStateRest) {
debug!("handle_history_up");
state.input.history_up(); state.input.history_up();
state.dirty = true; state.dirty = true;
} }
/// Navigate down through input history.
pub(super) fn handle_history_down(state: &mut AppStateRest) { pub(super) fn handle_history_down(state: &mut AppStateRest) {
debug!("handle_history_down");
state.input.history_down(); state.input.history_down();
state.dirty = true; state.dirty = true;
} }
/// Scroll the transcript pane up by 5 lines.
pub(super) fn handle_scroll_up(state: &mut AppStateRest) { pub(super) fn handle_scroll_up(state: &mut AppStateRest) {
debug!("handle_scroll_up");
state.scroll.scroll_up(5); state.scroll.scroll_up(5);
state.dirty = true; state.dirty = true;
} }
/// Scroll the transcript pane down by 5 lines.
pub(super) fn handle_scroll_down(state: &mut AppStateRest) { pub(super) fn handle_scroll_down(state: &mut AppStateRest) {
debug!("handle_scroll_down");
state.scroll.scroll_down(5); state.scroll.scroll_down(5);
state.dirty = true; 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) { pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
debug!("handle_open_overlay: {:?}", overlay);
state.misc.overlay = overlay; state.misc.overlay = overlay;
// Reset selection index for list-based overlays
if overlay == Overlay::Learning if overlay == Overlay::Learning
|| overlay == Overlay::Rewind || overlay == Overlay::Rewind
|| overlay == Overlay::ModelSelector || overlay == Overlay::ModelSelector
@@ -90,13 +132,20 @@ pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
state.dirty = true; 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) { 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); let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
match resolved { match resolved {
Ok(abs_path) => { Ok(abs_path) => {
let content = std::fs::read_to_string(&abs_path).unwrap_or_default(); let content = std::fs::read_to_string(&abs_path).unwrap_or_default();
let lines: Vec<String> = let lines: Vec<String> =
content.lines().map(std::string::ToString::to_string).collect(); 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( let ed = crate::app::mode::editor::EditorState::open(
abs_path.to_string_lossy().to_string(), abs_path.to_string_lossy().to_string(),
Some(lines), Some(lines),
@@ -115,13 +164,20 @@ pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) {
state.dirty = true; 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) { 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> = let extra_args: Vec<String> =
command.split_whitespace().map(std::string::ToString::to_string).collect(); command.split_whitespace().map(std::string::ToString::to_string).collect();
let cmd = extra_args.first().cloned().unwrap_or_default(); let cmd = extra_args.first().cloned().unwrap_or_default(); // main executable
let args: Vec<String> = extra_args.into_iter().skip(1).collect(); let args: Vec<String> = extra_args.into_iter().skip(1).collect(); // remaining args
match state.mcp_manager.connect_stdio(&name, &cmd, &args) { match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
Ok(()) => { Ok(()) => {
// Read back the tool count from the newly connected server
let tool_count = state let tool_count = state
.mcp_manager .mcp_manager
.servers .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) { pub(super) fn handle_model_list(state: &mut AppStateRest) {
debug!("handle_model_list");
state.misc.selected_index = 0; state.misc.selected_index = 0;
state.misc.overlay = Overlay::ModelSelector; state.misc.overlay = Overlay::ModelSelector;
state.dirty = true; 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) { 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 { if state.misc.overlay == Overlay::Editor {
crate::app::mode::editor::handle_editor_dismiss(state); crate::app::mode::editor::handle_editor_dismiss(state);
} }
@@ -157,30 +221,42 @@ pub(super) fn handle_close_overlay(state: &mut AppStateRest) {
state.dirty = true; state.dirty = true;
} }
/// Push an informational toast with the given message.
pub(super) fn handle_system_note(state: &mut AppStateRest, message: String) { pub(super) fn handle_system_note(state: &mut AppStateRest, message: String) {
debug!("handle_system_note: {}", message);
let toast = Toast::new(ToastKind::Info, message); let toast = Toast::new(ToastKind::Info, message);
state.push_toast(toast); state.push_toast(toast);
} }
/// Show the quit-confirmation overlay.
pub(super) fn handle_quit_confirm(state: &mut AppStateRest) { pub(super) fn handle_quit_confirm(state: &mut AppStateRest) {
state.misc.overlay = Overlay::QuitConfirm; state.misc.overlay = Overlay::QuitConfirm;
state.dirty = true; state.dirty = true;
} }
/// Handle terminal resize — update the scroll max-visible width.
pub(super) fn handle_resize(state: &mut AppStateRest, w: u16) { pub(super) fn handle_resize(state: &mut AppStateRest, w: u16) {
debug!("handle_resize: width={}", w);
state.scroll.set_max_visible(w as usize); state.scroll.set_max_visible(w as usize);
state.dirty = true; 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) { 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 turn_events = state.turn_events.clone();
let provider_clone = provider.clone(); let provider_clone = provider.clone();
// Run the blocking OAuth HTTP flow off the main thread
std::thread::spawn(move || { std::thread::spawn(move || {
let result = run_oauth_flow(&provider_clone); let result = run_oauth_flow(&provider_clone);
let message = match result { let message = match result {
Ok(msg) => msg, Ok(msg) => msg,
Err(e) => format!("OAuth login failed: {e}"), Err(e) => format!("OAuth login failed: {e}"),
}; };
// Push result back via the shared turn-events queue
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "oauth".to_string(), kind: "oauth".to_string(),
@@ -196,7 +272,13 @@ pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
state.dirty = true; 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) { 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 state
.abort_flag .abort_flag
.store(true, std::sync::atomic::Ordering::SeqCst); .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) { 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); let max_wire_tokens = window::resolve(&state.app_config, &state.settings);
// Extract config before borrowing session_runtime mutably to avoid // ── Extract config before borrowing session_runtime mutably ────────────
// borrow conflicts. An LLM client is needed for summarization so the // These clones avoid borrow conflicts when we later take &mut rt below.
// compacted result preserves meaningful context (goals, decisions,
// files, state) instead of a useless static placeholder.
let api_key = state let api_key = state
.settings .settings
.api_keys .api_keys
@@ -227,7 +318,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
.map(|p| p.api_base.clone()); .map(|p| p.api_base.clone());
let abort_flag = state.abort_flag.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 llm_client = base_url.map(|url| {
let key = if api_key.is_empty() { let key = if api_key.is_empty() {
state state
@@ -262,8 +353,10 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
return; return;
} }
// ── Run compaction, capturing before/after token counts ──────────────
let (before_tokens, after_tokens, msg_count) = let (before_tokens, after_tokens, msg_count) =
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
// Estimate total tokens before compaction
let token_estimate: usize = rt let token_estimate: usize = rt
.messages .messages
.iter() .iter()
@@ -272,6 +365,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
.sum(); .sum();
let before = token_estimate; let before = token_estimate;
// Run the actual compaction via shaping (summarises old messages)
rt.messages = crate::app::runtime::context::shaping::shape_messages( rt.messages = crate::app::runtime::context::shaping::shape_messages(
&rt.messages, &rt.messages,
token_estimate, token_estimate,
@@ -280,6 +374,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
llm_client.as_ref(), llm_client.as_ref(),
Some(&*abort_flag), Some(&*abort_flag),
); );
// Estimate tokens after compaction
let after: usize = rt let after: usize = rt
.messages .messages
.iter() .iter()
@@ -307,7 +402,13 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
state.dirty = true; 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) { 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 { if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson( let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir, &rt.session_dir,
@@ -316,6 +417,7 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
true, true,
); );
} }
// Re-read on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt); 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; 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) { 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 { if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson( let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir, &rt.session_dir,
@@ -335,6 +443,7 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
false, false,
); );
} }
// Re-read on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt); 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; 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) { 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); 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 { if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt); refresh_lesson_counters(&state.memory_dir, rt);
} }
@@ -1,5 +1,10 @@
//! I/O helper functions: session persistence, API connectivity checks, //! I/O helper functions used by action handlers: session persistence, API
//! and review-available notification. //! 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::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent; 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. /// Persist the current session metadata and conversation to disk.
/// ///
/// Flow: build a `Session` object → save its metadata → write /// Flow: build a `Session` object → save its metadata via
/// `rt.messages` as JSON to the conversation file → errors are silently /// `FileSystemSessionRepository` → serialise `rt.messages` as JSON →
/// ignored. /// 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) { 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 base = state.store_base_dir();
let session = zesdex_iam::domain::session::Session::new( let session = zesdex_iam::domain::session::Session::new(
state.session_id.clone(), state.session_id.clone(),
@@ -21,8 +30,10 @@ pub(super) fn save_current_session(state: &AppStateRest) {
); );
let session_repo = let session_repo =
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); 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); let _ = session_repo.save_session(&base, &session);
if let Some(ref rt) = state.session_runtime { 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); let conv_path = session.conversation_path(&base);
if let Ok(data) = serde_json::to_string(&rt.messages) { if let Ok(data) = serde_json::to_string(&rt.messages) {
let _ = std::fs::write(&conv_path, data); 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 /// `should_trigger_review` on `Tick`), only informs the user that
/// a review has material to examine. /// a review has material to examine.
pub(super) fn maybe_trigger_review(state: &mut AppStateRest) { 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 { if !state.settings.flags.review_enabled {
return; return;
} }
// Only notify if there were actual edits this session
let edit_count = state let edit_count = state
.session_runtime .session_runtime
.as_ref() .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 /// 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 /// request to `<base_url>/chat/completions`, pushing the result as a `SystemNote`
/// next `Tick` handler updates `api_connected`. /// so the next `Tick` handler updates `api_connected`.
/// ///
/// Flow: resolve the provider's base URL → build a short-lived reqwest client /// Flow: resolve the provider's base URL → build a short-lived `reqwest` client
/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push /// with 3 s connect / 5 s total timeout → HEAD the `/chat/completions` endpoint
/// a `connectivity` `SystemNote` with the result. /// → 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) { 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 let base_url = state
.app_config .app_config
.providers .providers
@@ -74,13 +91,17 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
|| crate::service::provider::DEFAULT_BASE_URL.to_string(), || crate::service::provider::DEFAULT_BASE_URL.to_string(),
|p| p.api_base.clone(), |p| p.api_base.clone(),
); );
// Clone the shared queue handle before moving into the background thread
let turn_events = state.turn_events.clone(); 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 || { 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 url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
let connected = match reqwest::blocking::Client::builder() let connected = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5)) // total request timeout
.connect_timeout(std::time::Duration::from_secs(3)) .connect_timeout(std::time::Duration::from_secs(3)) // TCP connect timeout
.build() .build()
{ {
Ok(client) => match client.head(&url).send() { 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) // 401/403 means the server is reachable (just auth is wrong)
s.is_success() || s.as_u16() == 401 || s.as_u16() == 403 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() { if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "connectivity".to_string(), kind: "connectivity".to_string(),
@@ -1,5 +1,7 @@
//! Memory / lesson-counter helpers: refresh counters from on-disk data. //! Memory / lesson-counter helpers: refresh counters from on-disk data.
use tracing::debug;
use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
@@ -18,7 +20,12 @@ pub(super) fn refresh_lesson_counters(
memory_dir: &std::path::Path, memory_dir: &std::path::Path,
rt: &mut crate::app::state::runtime::SessionRuntime, 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(); let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default();
// Reset all counters before recounting (avoid stale accumulation)
rt.lesson_count = 0; rt.lesson_count = 0;
rt.lessons_user = 0; rt.lessons_user = 0;
rt.lessons_feedback = 0; rt.lessons_feedback = 0;
@@ -27,22 +34,30 @@ pub(super) fn refresh_lesson_counters(
rt.lessons_active = 0; rt.lessons_active = 0;
rt.lessons_stale = 0; rt.lessons_stale = 0;
rt.lessons_contradicted = 0; rt.lessons_contradicted = 0;
// Iterate over every memory slug and classify it by kind + lifecycle
for name in &names { for name in &names {
if let Ok(mem) = MarkdownMemoryRepository::new().load(memory_dir, name) { if let Ok(mem) = MarkdownMemoryRepository::new().load(memory_dir, name) {
rt.lesson_count += 1; rt.lesson_count += 1;
// Classify by memory kind (user-defined, feedback, project, reference)
match mem.kind.as_str() { match mem.kind.as_str() {
"user" => rt.lessons_user += 1, "user" => rt.lessons_user += 1,
"feedback" => rt.lessons_feedback += 1, "feedback" => rt.lessons_feedback += 1,
"project" => rt.lessons_project += 1, "project" => rt.lessons_project += 1,
"reference" => rt.lessons_reference += 1, "reference" => rt.lessons_reference += 1,
_ => {} _ => {} // Unknown kind — skip
} }
// Classify by lifecycle stage (active, stale, contradicted)
match mem.lifecycle.as_str() { match mem.lifecycle.as_str() {
"active" => rt.lessons_active += 1, "active" => rt.lessons_active += 1,
"stale" => rt.lessons_stale += 1, "stale" => rt.lessons_stale += 1,
"contradicted" => rt.lessons_contradicted += 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 //! chokepoint through which every key input, streaming event, and async
//! background-thread result mutates `AppStateRest`. //! 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 //! loop calls `apply_action(&mut state, action)` → for turn-producing
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS //! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
//! thread which drives `run_agent_turn` (stream to the LLM, gate and //! 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; //! 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) //! running turns on plain OS threads (rather than blocking the main loop)
//! keeps the TUI responsive while the LLM streams. //! 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 handlers;
mod io; mod io;
@@ -27,7 +38,7 @@ mod turn;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; 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` /// streaming pipeline, or subagent threads — that mutates `AppStateRest`
/// when applied via `apply_action`. /// when applied via `apply_action`.
/// ///
@@ -37,65 +48,101 @@ use crate::app::state::types::Overlay;
/// observable and cancellable from the UI. /// observable and cancellable from the UI.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Action { pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit, ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String), SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar, DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight, DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft, CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight, CursorRight,
/// Navigate up through command history.
HistoryUp, HistoryUp,
/// Navigate down through command history.
HistoryDown, HistoryDown,
/// Scroll the transcript pane up.
ScrollUp, ScrollUp,
/// Scroll the transcript pane down.
ScrollDown, ScrollDown,
/// Open a named overlay (Help, Settings, Mcp, Todo, Usage, etc.).
OpenOverlay(Overlay), OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay, CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote { SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String, kind: String,
/// The message text to display.
message: String, message: String,
}, },
/// Show the quit-confirmation overlay.
QuitConfirm, QuitConfirm,
/// Terminal resize event — carries the new column count.
Resize(u16, u16), Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s and runs side jobs.
Tick, Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept { LessonAccept {
name: String, name: String,
}, },
/// Reject a lesson by name.
LessonReject { LessonReject {
name: String, name: String,
}, },
/// Delete a previously stored lesson by name.
LessonDelete { LessonDelete {
name: String, name: String,
}, },
/// Start the OAuth device-code login flow for a named provider.
StartOAuth { StartOAuth {
provider: String, provider: String,
}, },
/// Open the inline file editor for `path`.
OpenEditor { OpenEditor {
path: String, path: String,
}, },
/// Register a new MCP server by name and shell command.
McpAdd { McpAdd {
name: String, name: String,
command: String, command: String,
}, },
/// Open the model-picker overlay.
ModelList, ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn, AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact, Compact,
} }
/// Apply an `Action` to the application state. /// Apply an `Action` to the application state.
/// ///
/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll /// Flow: pattern-match the variant → delegate to the corresponding handler
/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) → /// function in `handlers` (or `tick::handle_tick` for `Tick`) → handler
/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs /// mutates `state` (input buffer, scroll position, overlay, transcript,
/// (staleness sweep, pending-lesson commit). /// 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 /// Why: the single chokepoint that turns every typed key and async event
/// into a state change, so callers (controllers, subagent threads) only /// 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. /// Return: nothing; `state` is mutated in place.
pub fn apply_action(state: &mut AppStateRest, action: Action) { pub fn apply_action(state: &mut AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action { match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => handlers::handle_force_quit(state), 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::SubmitInput(text) => handlers::handle_submit_input(state, text),
Action::DeleteChar => handlers::handle_delete_char(state), Action::DeleteChar => handlers::handle_delete_char(state),
Action::DeleteCharRight => handlers::handle_delete_char_right(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::CursorRight => handlers::handle_cursor_right(state),
Action::HistoryUp => handlers::handle_history_up(state), Action::HistoryUp => handlers::handle_history_up(state),
Action::HistoryDown => handlers::handle_history_down(state), Action::HistoryDown => handlers::handle_history_down(state),
// ── Scroll / navigation ───────────────────────────────────────
Action::ScrollUp => handlers::handle_scroll_up(state), Action::ScrollUp => handlers::handle_scroll_up(state),
Action::ScrollDown => handlers::handle_scroll_down(state), Action::ScrollDown => handlers::handle_scroll_down(state),
Action::OpenOverlay(overlay) => handlers::handle_open_overlay(state, overlay), Action::OpenOverlay(overlay) => handlers::handle_open_overlay(state, overlay),
Action::CloseOverlay => handlers::handle_close_overlay(state), 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), // ── System / info ─────────────────────────────────────────────
Action::Resize(w, _h) => handlers::handle_resize(state, w), Action::SystemNote { kind: _kind, message } => {
Action::OpenEditor { path } => handlers::handle_open_editor(state, path), handlers::handle_system_note(state, message)
Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command), }
Action::ModelList => handlers::handle_model_list(state), Action::ModelList => handlers::handle_model_list(state),
Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider),
Action::AbortTurn => handlers::handle_abort_turn(state), Action::AbortTurn => handlers::handle_abort_turn(state),
Action::Compact => handlers::handle_compact(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::LessonAccept { name } => handlers::handle_lesson_accept(state, name),
Action::LessonReject { name } => handlers::handle_lesson_reject(state, name), Action::LessonReject { name } => handlers::handle_lesson_reject(state, name),
Action::LessonDelete { name } => handlers::handle_lesson_delete(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::rest::AppStateRest;
use crate::app::state::runtime::SessionRuntime; use crate::app::state::runtime::SessionRuntime;
use crate::app::state::runtime::TurnEvent; 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] #[test]
fn hive_mind_converged_system_note_sets_session_flag() { 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())); let tmp = std::env::temp_dir().join(format!("zesdex-actions-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap(); std::fs::create_dir_all(&tmp).unwrap();
let mut state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")); let mut state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"));
state.session_runtime = Some(SessionRuntime::new(tmp.clone())); state.session_runtime = Some(SessionRuntime::new(tmp.clone()));
// Verify the flag starts as false
assert!(!state.session_runtime.as_ref().unwrap().hive_mind_converged); 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() { if let Ok(mut q) = state.turn_events.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(), kind: "hive_mind_converged".to_string(),
message: String::new(), message: String::new(),
}); });
} }
// Tick drains the queue and processes the note
apply_action(&mut state, Action::Tick); apply_action(&mut state, Action::Tick);
// Verify the flag is now set
assert!(state.session_runtime.as_ref().unwrap().hive_mind_converged); assert!(state.session_runtime.as_ref().unwrap().hive_mind_converged);
std::fs::remove_dir_all(&tmp).ok(); std::fs::remove_dir_all(&tmp).ok();
@@ -1,5 +1,7 @@
//! OAuth PKCE flow — browser-based login for API providers. //! OAuth PKCE flow — browser-based login for API providers.
use tracing::{info, warn};
/// Run a browser-based OAuth PKCE flow for the given provider. /// Run a browser-based OAuth PKCE flow for the given provider.
/// ///
/// Flow: look up config by provider name ("zen"/"opencode", "openai", /// 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 /// Return: a success message on completion, or an error if the flow fails
/// at any step. /// at any step.
pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result<String> { 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::oauth::OAuthConfig;
use zesdex_iam::domain::service::OAuthService; use zesdex_iam::domain::service::OAuthService;
use zesdex_iam::application::oauth_service::OAuthServiceImpl; 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)?; let (auth_url, state) = oauth_service.start_flow(&config, &redirect_uri)?;
if auth_url.is_empty() { 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() { } else if webbrowser::open(&auth_url).is_err() {
tracing::warn!( warn!(
"[oauth] could not open browser for '{}'; user must open URL manually:\n{}", "OAuth could not open browser for '{}'; user must open URL manually:\n{}",
provider, provider,
auth_url auth_url
); );
} }
info!(provider = provider, "waiting for OAuth redirect");
let code = server.wait_for_code(120_000, &state)?; let code = server.wait_for_code(120_000, &state)?;
oauth_service oauth_service
.complete_flow(&config, &redirect_uri, &code, &state) .complete_flow(&config, &redirect_uri, &code, &state)
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
info!(provider = provider, "OAuth flow completed");
Ok(format!("Successfully authenticated with {provider}.")) 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`. //! 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::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent; use crate::app::state::runtime::TurnEvent;
use tracing::{error, info};
use super::turn::run_agent_turn; use super::turn::run_agent_turn;
@@ -41,6 +49,7 @@ pub(super) struct TurnCtx {
/// ///
/// Return: nothing; results flow through `state.turn_events`. /// Return: nothing; results flow through `state.turn_events`.
pub(super) fn spawn_turn(state: &AppStateRest) { 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() { let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard *guard
} else { } else {
@@ -110,14 +119,14 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
let in_flight_flag = state.turn_in_flight.clone(); let in_flight_flag = state.turn_in_flight.clone();
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone(); let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
let abort_flag = state.abort_flag.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 let hive_mind_converged = state
.session_runtime .session_runtime
.as_ref() .as_ref()
.is_some_and(|rt| rt.hive_mind_converged); .is_some_and(|rt| rt.hive_mind_converged);
*in_flight_flag.lock().unwrap_or_else(|e| { *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() e.into_inner()
}) = true; }) = true;
@@ -126,7 +135,7 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
std::thread::spawn(move || { std::thread::spawn(move || {
let db = crate::model::msglog::open_or_create(&edit_session_dir) let db = crate::model::msglog::open_or_create(&edit_session_dir)
.ok() .ok()
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c))); .map(|c| Arc::new(std::sync::Mutex::new(c)));
let tc = TurnCtx { let tc = TurnCtx {
client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url), client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url),
tdefs: tool_defs, tdefs: tool_defs,
@@ -145,10 +154,12 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
}; };
let result = run_agent_turn(&tc, &messages, &events_q); let result = run_agent_turn(&tc, &messages, &events_q);
if let Err(e) = result { if let Err(e) = result {
info!("spawn_turn: turn returned error: {}", e);
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error(e.to_string())); q.push_back(TurnEvent::Error(e.to_string()));
} }
} }
info!("spawn_turn: turn completed");
if let Ok(mut flag) = in_flight_flag.lock() { if let Ok(mut flag) = in_flight_flag.lock() {
*flag = false; *flag = false;
} }
@@ -1,6 +1,14 @@
//! Tick-action handler: drain turn events, LSP provision messages, //! Tick-action handler: drain turn events, LSP provision messages,
//! API connectivity checks, staleness sweep, pending lessons, and //! API connectivity checks, staleness sweep, pending lessons, and
//! todo.md polling. //! 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::review::{should_trigger_review, trigger_review};
use crate::app::state::rest::AppStateRest; 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 /// Handle `Action::Tick` — the periodic event that drains async results
/// and runs background maintenance tasks. /// 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) { pub(super) fn handle_tick(state: &mut AppStateRest) {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1); 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(); let now_ms = chrono::Utc::now().timestamp_millis();
// Remove expired toasts from the display stack.
state.misc.drain_expired_toasts(now_ms); 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) { if state.misc.tick_count.is_multiple_of(10) {
let todo_path = state.session_dir.join("todo.md"); let todo_path = state.session_dir.join("todo.md");
if let Ok(content) = std::fs::read_to_string(&todo_path) { 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 // Background API connectivity check — runs on a background thread
// every ~1s while disconnected, every ~30s while connected, so the // every ~1s while disconnected, every ~30s while connected, so the
// status bar reflects real API availability without user input. // 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 }; let check_interval = if state.misc.api_connected { 600 } else { 20 };
if state.misc.tick_count.is_multiple_of(check_interval) { if state.misc.tick_count.is_multiple_of(check_interval) {
spawn_api_connectivity_check(state); 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())); 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> = { let events: Vec<TurnEvent> = {
if let Ok(mut q) = state.turn_events.lock() { if let Ok(mut q) = state.turn_events.lock() {
q.drain(..).collect() 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 { if turn_finished {
maybe_trigger_review(state); maybe_trigger_review(state);
} }
// Ensure state is marked dirty if anything changed this cycle.
if turn_finished || state.dirty { if turn_finished || state.dirty {
state.dirty = true; state.dirty = true;
} }
@@ -314,6 +314,11 @@ pub(super) fn run_agent_turn(
let mut todo_retry_count = 0usize; let mut todo_retry_count = 0usize;
tracing::debug!(
"[turn] entering main agent loop — max todo retries: {}",
MAX_TODO_RETRIES,
);
loop { loop {
let token_estimate: usize = msgs let token_estimate: usize = msgs
.iter() .iter()
@@ -493,6 +498,9 @@ pub(super) fn run_agent_turn(
archive_message(tc.db.as_ref(), &tc.session_id, &response); archive_message(tc.db.as_ref(), &tc.session_id, &response);
msgs.push(response); msgs.push(response);
let mut results_vec = Vec::new(); 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| { std::thread::scope(|s| {
let mut handles = Vec::new(); let mut handles = Vec::new();
let tc_ref = tc; 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); push_event(&events_q, TurnEvent::Done);
Ok(()) Ok(())
@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Cross-call tool-result deduplication: when a read-only tool is called //! Cross-call tool-result deduplication: when a read-only tool is called
//! again with identical arguments, the earlier result is replaced with a //! again with identical arguments, the earlier result is replaced with a
//! placeholder so only the latest copy occupies context. //! 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 crate::dto::chat::message::{ChatMessage, Role};
use sha2::Digest; use sha2::Digest;
use std::collections::HashMap; use std::collections::HashMap;
use tracing;
const DUPLICATE_PLACEHOLDER: &str = const DUPLICATE_PLACEHOLDER: &str =
"[duplicate result — superseded by a later identical call, see below]"; "[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 /// `true` iff at least one entry was replaced. The caller uses the
/// `bool` to decide whether the result is worth persisting/announcing, /// `bool` to decide whether the result is worth persisting/announcing,
/// without `ChatMessage` needing to implement `PartialEq`. /// 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) { 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) // tool_call_id -> (tool name, canonical JSON of its arguments)
let mut call_info: HashMap<String, (String, String)> = HashMap::new(); let mut call_info: HashMap<String, (String, String)> = HashMap::new();
for m in messages { for m in messages {
@@ -84,6 +92,7 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
}) })
.collect(); .collect();
tracing::debug!(changed, "dedup::collapse — done");
(result, changed) (result, changed)
} }
@@ -101,6 +110,9 @@ fn dedup_key(tool_name: &str, canonical_args: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 super::*;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction}; use crate::dto::chat::tool::{ToolCall, ToolFunction};
@@ -2,6 +2,18 @@
//! per-result compression, budget-based shaping, and shared //! per-result compression, budget-based shaping, and shared
//! context-window resolution — replaces `runtime::shortsend`. //! 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 //! No facade function here: `dedup`, `shaping`, and `tokens` are called
//! directly from each call site (the per-turn auto-compaction loop in //! directly from each call site (the per-turn auto-compaction loop in
//! `actions::run_agent_turn`, and `Action::Compact`), matching this //! `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:"; const SUMMARY_PREFIX: &str = "[Summary of compacted prior conversation:";
/// Detect whether a message contains a previous compaction summary. /// 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 { fn msg_has_prior_summary(m: &ChatMessage) -> bool {
m.content m.content
.as_deref() .as_deref()
@@ -77,6 +81,13 @@ fn msg_has_prior_summary(m: &ChatMessage) -> bool {
/// Format dropped messages for the summarization prompt, excluding any /// Format dropped messages for the summarization prompt, excluding any
/// messages that are themselves previous summaries (those are handled /// messages that are themselves previous summaries (those are handled
/// separately by progressive summarization). /// 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 { fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
dropped dropped
.iter() .iter()
@@ -106,6 +117,13 @@ fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
} }
/// Extract the content of a previous compaction summary from a message. /// 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> { fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
let content = m.content.as_deref()?; let content = m.content.as_deref()?;
if content.starts_with(SUMMARY_PREFIX) { 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: /// Build the summarization prompt, supporting progressive compaction:
/// if the dropped messages contain a previous summary, it is extracted /// if the dropped messages contain a previous summary, it is extracted
/// and the new prompt asks the LLM to build on it. /// 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( fn build_summarization_prompt(
dropped_msgs: &[ChatMessage], dropped_msgs: &[ChatMessage],
dropped_content: &str, dropped_content: &str,
@@ -174,6 +199,14 @@ fn build_summarization_prompt(
/// `[prior conversation compacted]` placeholder — it tells the LLM how /// `[prior conversation compacted]` placeholder — it tells the LLM how
/// many messages of each role were dropped and what tools were used, /// many messages of each role were dropped and what tools were used,
/// preserving key structural context. /// 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 { fn make_structural_summary(dropped: &[ChatMessage]) -> String {
use std::fmt::Write; use std::fmt::Write;
@@ -244,11 +277,22 @@ pub fn shape_messages(
client: Option<&crate::service::provider::LlmClient>, client: Option<&crate::service::provider::LlmClient>,
abort_flag: Option<&AtomicBool>, abort_flag: Option<&AtomicBool>,
) -> Vec<ChatMessage> { ) -> 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) { 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(); return messages.to_vec();
} }
if force && messages.len() < 5 { if force && messages.len() < 5 {
tracing::debug!("shape_messages — force but fewer than 5 messages, no-op");
return messages.to_vec(); return messages.to_vec();
} }
@@ -352,10 +396,12 @@ pub fn shape_messages(
} }
} }
} else { } else {
tracing::debug!("shape_messages — summarization aborted by user, using structural summary");
make_structural_summary(&dropped_msgs) make_structural_summary(&dropped_msgs)
} }
} else { } else {
// No LLM client available (tests / edge case with no provider). // 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) make_structural_summary(&dropped_msgs)
}; };
@@ -363,11 +409,19 @@ pub fn shape_messages(
} }
result.extend(keep_recent.into_iter().rev()); result.extend(keep_recent.into_iter().rev());
tracing::debug!(
result_len = result.len(),
dropped = dropped_msgs.len(),
"shape_messages — done"
);
result result
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! Unit tests for message shaping: threshold hysteresis, system-message
//! preservation, structural-summary fallback, and most-recent survival.
use super::*; use super::*;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Per-tool-result compression: shrink large tool outputs before they //! Per-tool-result compression: shrink large tool outputs before they
//! ever enter conversation history, dispatching by content shape. //! ever enter conversation history, dispatching by content shape.
//! //!
@@ -12,6 +11,7 @@
//! overall budget. //! overall budget.
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Write; use std::fmt::Write;
use tracing;
/// Below this size, compression isn't worth the risk of losing detail — /// Below this size, compression isn't worth the risk of losing detail —
/// pass the output through unchanged. /// 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 /// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at
/// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from /// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from
/// whichever detector matches its content shape. /// 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 { 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 { 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(); return output.to_string();
} }
if serde_json::from_str::<serde_json::Value>(output).is_ok() { 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); return squash_json(output);
} }
if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(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); return squash_log(output);
} }
tracing::trace!(tool_name, "squash::apply — routing to squash_generic");
squash_generic(output, GENERIC_BUDGET_BYTES) 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]` /// Render a subset of `lines` in order, inserting a `[N lines omitted]`
/// marker at every gap between kept lines. /// 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 { fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect(); let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
kept_sorted.sort_unstable(); kept_sorted.sort_unstable();
@@ -309,6 +331,9 @@ fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
@@ -11,6 +11,7 @@
//! closer than a flat byte-per-token guess; it's only used for the //! closer than a flat byte-per-token guess; it's only used for the
//! 85%/95% budget thresholds, not for billing-accurate counts. //! 85%/95% budget thresholds, not for billing-accurate counts.
use tracing;
/// Count tokens in a single string under `o200k_base`. /// 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 /// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
/// as ordinary text, not interpreted as a control token. /// as ordinary text, not interpreted as a control token.
pub fn count_tokens(text: &str) -> usize { pub fn count_tokens(text: &str) -> usize {
tiktoken_rs::o200k_base_singleton() let count = tiktoken_rs::o200k_base_singleton()
.encode_ordinary(text) .encode_ordinary(text)
.len() .len();
tracing::trace!(len = text.len(), count, "count_tokens");
count
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! Unit tests for token counting: empty strings, known phrases, code,
//! and ChatMessage content extraction.
use super::*; use super::*;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
/// Count tokens in a `ChatMessage`'s text content. /// Count tokens in a `ChatMessage`'s text content.
///
/// Returns 0 when the message has no content (None).
fn count_message_tokens(msg: &ChatMessage) -> usize { fn count_message_tokens(msg: &ChatMessage) -> usize {
msg.content.as_deref().map_or(0, count_tokens) 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 //! Single source of truth for resolving the active model's context
//! window size, replacing three copies of the same lookup that had //! window size, replacing three copies of the same lookup that had
//! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each //! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each
//! had their own inline version — the status bar's copy additionally //! had their own inline version — the status bar's copy additionally
//! displayed "?" on no match instead of falling back like the other two, //! displayed "?" on no match instead of falling back like the other two,
//! an inconsistency this unifies away). //! an inconsistency this unifies away).
use tracing::debug;
use zesdex_cms::domain::app_config::AppConfig; use zesdex_cms::domain::app_config::AppConfig;
use zesdex_cms::domain::settings::Settings; 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 /// `settings` -> use its `context_window` if set -> otherwise fall back
/// to `app_config.default_context_window`. /// 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". /// Return: always a concrete token count, never "unknown".
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize { pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
app_config // Search model roles for one matching the active provider + model pair
.model_roles let matched = app_config.model_roles.values().find(|role| {
.values() role.provider == settings.provider && role.model == settings.model
.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) .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)] #[cfg(test)]
@@ -1,9 +1,15 @@
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS //! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
//! after any activity, then slows down to conserve CPU. //! 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::collections::VecDeque;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::app::state::runtime::TurnEvent; use crate::app::state::runtime::TurnEvent;
use tracing;
const FAST_POLL_MS: u64 = 8; const FAST_POLL_MS: u64 = 8;
const SLOW_POLL_MS: u64 = 100; 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 /// Mark the current time as the last activity and arm the fast-poll
/// window for the next `IDLE_THRESHOLD_MS`. /// 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) { pub fn mark_active(&mut self) {
self.last_activity = Instant::now(); self.last_activity = Instant::now();
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS)); 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`. /// 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. /// 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). /// Return: a `Vec` of all events that were in the queue (may be empty).
pub fn drain_events( pub fn drain_events(
events: &std::sync::Mutex<VecDeque<TurnEvent>>, events: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> Vec<TurnEvent> { ) -> Vec<TurnEvent> {
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::collections::VecDeque;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tracing;
use super::state::runtime::TurnEvent; 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 /// errors inline. Used by the 20+ locations in `actions/turn.rs` that push
/// events and want to skip the boilerplate. /// events and want to skip the boilerplate.
pub fn push_event( pub fn push_event(
q: &Arc<Mutex<VecDeque<TurnEvent>>>, q: &Arc<Mutex<VecDeque<TurnEvent>>>, // shared turn-event queue (locked on access)
event: TurnEvent, 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() { if let Ok(mut guard) = q.lock() {
guard.push_back(event); guard.push_back(event); // enqueue at the back for FIFO processing
} }
} }

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