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

Meliputi:
- File-level //! doc comment: tujuan file, alur kerja, komponen utama
- Function-level /// doc comment: apa, parameter, return, flow, edge cases
- Struct/enum/trait /// doc comment: peran, field docs
- Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi
- Inline comments untuk variable dan branching logic penting
- Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities,
  zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils
- Build: 0 errors, 242/242 tests passed
This commit is contained in:
asepharyana
2026-07-19 17:05:47 +07:00
parent 6680795ce7
commit 5aaedbf787
255 changed files with 5666 additions and 743 deletions
@@ -14,15 +14,22 @@ use std::sync::Mutex;
use std::sync::OnceLock;
use super::job::BashJob;
use tracing::debug;
/// Lazily-initialised, process-wide registry of background bash jobs keyed
/// by job id.
///
/// Flow: first call creates the `Mutex<HashMap>` inside a `OnceLock`;
/// subsequent calls return the same static reference.
///
/// Return: a reference to the static `Mutex<HashMap<...>>`, created on
/// first access.
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = OnceLock::new();
JOBS.get_or_init(|| Mutex::new(HashMap::new()))
JOBS.get_or_init(|| {
debug!("bash_jobs_map initialised");
Mutex::new(HashMap::new())
})
}
/// Drain any newly available output lines from a background bash job.
@@ -43,8 +50,10 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
lines.push(line);
}
if lines.is_empty() {
debug!(%id, "bash_output: no new lines");
None
} else {
debug!(%id, count = lines.len(), "bash_output: new lines drained");
Some(lines)
}
}
@@ -60,6 +69,8 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
/// with that id exists.
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
debug!(%id, "bash_kill called");
let mut map = bash_jobs_map()
.lock()
.map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
@@ -69,9 +80,12 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> {
// Actually terminate the child process via its PID
if job.child_pid > 0 {
#[cfg(unix)]
// SAFETY: job.child_pid is the real PID of the spawned child;
// SIGTERM is safe and the process may already be dead.
unsafe {
libc::kill(job.child_pid as i32, libc::SIGTERM);
}
debug!(%id, pid = job.child_pid, "bash_kill: SIGTERM sent");
}
Ok(())
}
+12 -3
View File
@@ -13,6 +13,7 @@ use std::io::BufRead;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use tracing::{debug, warn};
/// Maximum number of output lines buffered in memory per background job.
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
@@ -26,9 +27,15 @@ const MAX_OUTPUT_LINES: usize = 10_000;
/// synchronously, so the TUI can poll for new lines without blocking.
/// The bounded channel prevents OOM from fast producers (e.g. `yes`).
pub struct BashJob {
/// Unique identifier for this job (UUID v4).
pub id: String,
/// OS process ID of the spawned child, used by `bash_kill` to send SIGTERM.
pub child_pid: u32,
/// Receiving end of the bounded channel carrying stdout/stderr lines
/// and `__exit:<code>` sentinels from the background thread.
pub output_rx: mpsc::Receiver<String>,
/// Exit code captured from the `__exit:` sentinel, or `None` if the job
/// is still running or hasn't been polled past its exit sentinel yet.
pub exit_code: Option<i32>,
}
@@ -71,7 +78,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
})
.is_err()
{
tracing::warn!(
warn!(
"[bgbash:{}] failed to spawn named thread, using unnamed fallback",
id_for_log
);
@@ -130,7 +137,7 @@ fn spawn_bash_thread_body(
let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
if stderr_tx.try_send(format!("[stderr] {line}")).is_err() {
tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
debug!("[bgbash] stderr buffer full, discarding remaining stderr");
break;
}
}
@@ -142,7 +149,7 @@ fn spawn_bash_thread_body(
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if output_tx.try_send(line).is_err() {
tracing::debug!(
debug!(
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
id_for_log,
MAX_OUTPUT_LINES,
@@ -171,11 +178,13 @@ impl BashJob {
Ok(line) => {
if line.starts_with("__exit:") {
self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok());
debug!(%self.id, exit_code = ?self.exit_code, "try_read_line: job exited");
None
} else {
Some(line)
}
}
// Channel empty or disconnected — no new output yet.
Err(_) => None,
}
}
@@ -1,4 +1,9 @@
//! Background bash: run shell commands off the main thread, poll their
//! output non-blockingly, and terminate them on demand.
//!
//! Flow: [`job`] defines the `BgJob` struct (a spawned child process with a
//! ticker for incremental output). [`control`] provides the UI-facing actions
//! (start, cancel, follow, etc.) that operate on the shared job registry at
//! `state.bg_bash`.
pub mod control;
pub mod job;
+21 -1
View File
@@ -6,6 +6,7 @@
pub mod patterns;
use patterns::*;
use tracing::debug;
/// Outcome of gating a tool call: whether it's allowed to run.
#[derive(Debug, Clone, PartialEq)]
@@ -33,39 +34,47 @@ impl Guard {
) -> Verdict {
let is_risky = crate::tool::tool_is_risky(tool_name);
let is_mcp = tool_name.starts_with("mcp__");
debug!(tool_name, is_risky, is_mcp, "gating tool call");
// Universal checks applied to EVERY tool.
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
debug!(tool_name, "blocked by path-traversal check");
return v;
}
if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) {
debug!(tool_name, "blocked by output-path check");
return v;
}
// Non-risky, non-MCP tools pass after universal checks.
if !is_risky && !is_mcp {
debug!(tool_name, "non-risky non-MCP tool allowed after universal checks");
return Verdict::Allow;
}
// File-mutating tools: require a meaningful reason.
if matches!(tool_name, "write" | "edit" | "delete") {
if let Err(msg) = Self::validate_reason(tool_name, args) {
debug!(tool_name, "blocked by reason validation");
return Verdict::Block(msg);
}
}
// write / edit content scanning for stub/denial/assumption patterns.
if let Some(v) = Self::check_content_safety(tool_name, args) {
debug!(tool_name, "blocked by content-safety check");
return v;
}
// Bash-specific destructive / exfiltration checks.
if let Some(v) = Self::check_bash_safety(args) {
debug!(tool_name, "blocked by bash-safety check");
return v;
}
// git_operator: require a non-trivial reason.
if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) {
debug!(tool_name, "blocked by git_operator reason check");
if args.get("reason").and_then(|v| v.as_str()).is_some() {
return Verdict::Block(format!(
"git_operator requires a non-trivial 'reason' \
@@ -81,12 +90,14 @@ impl Guard {
if is_mcp {
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
if reason.trim().len() < MIN_REASON_LEN {
debug!(tool_name, "blocked by MCP reason length");
return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a non-trivial 'reason' \
(>= {MIN_REASON_LEN} chars) explaining why it is needed"
));
}
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
debug!(tool_name, "blocked by missing MCP reason");
return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a 'reason' argument \
explaining the operation"
@@ -94,6 +105,7 @@ impl Guard {
}
}
debug!(tool_name, "tool call allowed");
Verdict::Allow
}
@@ -374,9 +386,17 @@ impl Default for Guard {
#[cfg(test)]
mod tests {
//! Unit tests for the Guard gating system: verdict parsing, tool
//! classification, path-traversal detection, content-safety patterns,
//! and reason validation.
use super::*;
use serde_json::json;
/// Parse a verdict from either a JSON object `{"verdict": "allow|block",
/// "reason": "..."}` or a text line `Verdict: Allow|Block <reason>`.
///
/// Flow: try JSON parse first → fall back to text line parsing → fall
/// back to keyword heuristics.
fn parse_verdict(text: &str) -> Option<Verdict> {
let trimmed = text.trim();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
@@ -7,20 +7,24 @@
/// Stub / placeholder / denial / assumption patterns that should never reach
/// a file in real code. Detected in write/edit content and bash heredocs.
pub const STUB_PATTERNS: &[&str] = &[
// Rust macro stubs
"todo!()",
"todo!(",
"unimplemented!()",
"unimplemented!(",
"todo_macro",
// Review markers left by the AI
"FIXME",
"fixme:",
"XXX:",
// Explicit placeholder tokens
"PLACEHOLDER",
"REPLACE_ME",
"stub_value",
"stub_function",
"fake_response",
"fake_data",
// Admission that work was deferred
"not implemented",
"not yet implemented",
"to be implemented",
@@ -30,36 +34,43 @@ pub const STUB_PATTERNS: &[&str] = &[
/// Language patterns indicating the AI is denying responsibility or
/// punting the work ("I'll skip this", "for now just", etc).
pub const DENIAL_PATTERNS: &[&str] = &[
// Explicit skip/punt
"// skip",
"// skipping",
"// skipping for now",
"// for now just",
"// punt",
"// punted",
// Hack / workaround framing
"// hack:",
"// hacky",
"// hack workaround",
"// workaround:",
"// cba",
// Deferral language
"// later",
"// do later",
"// ignore for now",
"// disable",
"// disabled",
"// bypass",
// Temporary / quick-fix framing (likely will never be revisited)
"// quick fix",
"// temp fix",
"// temporary fix",
"// temp:",
"// temporary:",
// No-op placeholder
"// noop",
];
/// Assumption-language patterns: words/phrases that indicate the code is
/// reasoning based on guesswork rather than data.
pub const ASSUMPTION_PATTERNS: &[&str] = &[
// Assertions without evidence
"// assume",
"// assuming",
// Speculative qualification
"// probably",
"// maybe",
"// might",
@@ -75,14 +86,18 @@ pub const ASSUMPTION_PATTERNS: &[&str] = &[
/// Network-exfiltration and credential-disclosure patterns for bash.
pub const EXFIL_PATTERNS: &[&str] = &[
// Network data-transfer tools
"curl ",
"wget ",
// Reverse shells / netcat
"nc -e ",
"ncat ",
"/dev/tcp/",
// Obfuscated payloads
"base64 -d |",
"base64 --decode |",
"openssl s_client",
// SSH and file-transfer exfiltration
"ssh -R ",
"scp /",
"rsync /",
@@ -90,17 +105,22 @@ pub const EXFIL_PATTERNS: &[&str] = &[
/// Substrings of well-known credential / secret files that bash must not read.
pub const SENSITIVE_PATH_PATTERNS: &[&str] = &[
// SSH private keys and auth
".ssh/id_rsa",
".ssh/id_ed25519",
".ssh/authorized_keys",
// Cloud / package-manager credentials
".aws/credentials",
".aws/config",
".netrc",
".pypirc",
".npmrc",
// Container orchestration secrets
".kube/config",
".docker/config.json",
// GPG keys
".gnupg/",
// System-level secrets
"/etc/shadow",
"/etc/passwd",
"/proc/self/environ",
+99 -2
View File
@@ -1,20 +1,48 @@
//! Low-level LSP client: spawns a language server subprocess, speaks
//! JSON-RPC 2.0 over stdio, and exposes typed methods for the LSP
//! lifecycle and text-document notifications.
//!
//! Flow: `LspClient::spawn` → `initialize` handshake → `didOpen` / `didChange`
//! / `didClose` → positional queries (hover, completion, etc.) →
//! `shutdown` / `exit` on drop.
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tracing::{debug, info};
/// Timeout for the `initialize` handshake (60 s).
const LSP_INIT_TIMEOUT_MS: u64 = 60_000;
/// Timeout for regular LSP method calls (30 s).
const LSP_CALL_TIMEOUT_MS: u64 = 30_000;
/// Timeout waiting for a `textDocument/publishDiagnostics` notification (10 s).
const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
/// A connected LSP language server over stdio JSON-RPC 2.0.
///
/// Holds the child's stdin/stdout streams and tracks the next request id
/// together with the capabilities the server advertised during `initialize`.
/// The caller is responsible for calling `shutdown` before dropping.
pub struct LspClient {
/// Write end of the child's stdin pipe.
stdin: std::process::ChildStdin,
/// Buffered read end of the child's stdout pipe.
stdout: BufReader<std::process::ChildStdout>,
/// Monotonically increasing request id for JSON-RPC calls.
next_id: u64,
/// The `capabilities` blob returned by the server's `initialize` response.
server_capabilities: Value,
}
/// Convert an arbitrary file path (relative or absolute) to a `file://` URI
/// suitable for the LSP `TextDocumentItem.uri` field.
///
/// Flow: resolve relative paths against CWD → canonicalize → prepend `file://`
/// with platform-appropriate slashes.
///
/// Edge case: on Windows, drive letters get a triple slash (`file:///C:/...`).
fn file_path_to_uri(path: &str) -> String {
let abs_path = std::path::Path::new(path);
let abs_path = if abs_path.is_relative() {
@@ -40,7 +68,20 @@ fn file_path_to_uri(path: &str) -> String {
}
impl LspClient {
/// Spawn an LSP server process and run the `initialize` handshake.
///
/// Flow: spawn child with piped stdio → build `LspClient` → send
/// `initialize` request with client capabilities → store
/// `server_capabilities` from the response → send `initialized`
/// notification.
///
/// Param `command`: path or name of the LSP server binary.
/// Param `args`: CLI arguments passed to the binary.
///
/// Return: a fully initialized `LspClient`, or an error if spawn or
/// handshake fails.
pub fn spawn(command: &str, args: &[String]) -> anyhow::Result<Self> {
info!(command = command, "LspClient::spawn");
let mut cmd = Command::new(command);
cmd.args(args);
cmd.stdin(Stdio::piped());
@@ -69,6 +110,7 @@ impl LspClient {
server_capabilities: Value::Null,
};
// Build the `initialize` params with client capabilities.
let init_params = json!({
"processId": std::process::id(),
"clientInfo": {
@@ -118,21 +160,29 @@ impl LspClient {
&init_params,
Duration::from_millis(LSP_INIT_TIMEOUT_MS),
)?;
// Store the capabilities blob for later inspection.
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
client.notify("initialized", &json!({}))?;
info!(command = command, "LSP client initialized");
Ok(client)
}
/// Return the server capabilities blob from the `initialize` response.
pub fn server_capabilities(&self) -> &Value {
&self.server_capabilities
}
/// Send a JSON-RPC request and wait for the matching response (default timeout).
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
}
/// Send a JSON-RPC request and wait for the matching response (custom timeout).
///
/// Flow: bump `next_id` → build `{"jsonrpc","id","method","params"}` →
/// `send_frame` → `read_response` with the chosen timeout.
fn call_with_timeout(
&mut self,
method: &str,
@@ -140,26 +190,33 @@ impl LspClient {
timeout: Duration,
) -> anyhow::Result<Value> {
self.next_id += 1;
let id = self.next_id;
let id = self.next_id; // unique id for this request
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
debug!(method = method, id = id, "LSP call");
self.send_frame(&req)?;
self.read_response(id, timeout)
}
/// Send a JSON-RPC notification (no response expected).
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params
});
debug!(method = method, "LSP notify");
self.send_frame(&req)
}
/// Write a JSON-RPC frame (Content-Length header + body) to the child's stdin.
///
/// Flow: serialize msg → build `Content-Length: N\r\n\r\n` → write header
/// → write body → flush. All I/O errors are wrapped with context.
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
let body = serde_json::to_string(msg)
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
@@ -176,6 +233,11 @@ impl LspClient {
Ok(())
}
/// Read frames from stdout until one matches `expected_id`, then return its
/// `result` (or error on a JSON-RPC error response).
///
/// Flow: loop `read_frame` until id matches → check for `error` field →
/// return `result` or bail with the error code/message.
fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
@@ -200,6 +262,10 @@ impl LspClient {
}
}
/// Read frames from stdout until one matches the given `method`
/// notification, then return its `params`.
///
/// Flow: loop `read_frame` until `method` field matches → return `params`.
pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
@@ -213,8 +279,16 @@ impl LspClient {
}
}
/// Read a single JSON-RPC frame (header + body) from the child's stdout.
///
/// Flow: loop reading header lines until blank line → parse
/// `Content-Length` (capped at 64 MiB) → read exact body bytes →
/// parse JSON. Returns the parsed JSON value.
///
/// Edge case: Content-Length values >64 MiB are rejected (CWE-400).
fn read_frame(&mut self) -> anyhow::Result<Value> {
let mut content_length: Option<usize> = None;
// Read header lines until a blank line.
loop {
let mut line = String::new();
match self.stdout.read_line(&mut line) {
@@ -224,7 +298,7 @@ impl LspClient {
}
let trimmed = line.trim();
if trimmed.is_empty() {
break;
break; // end of headers
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
// Cap Content-Length at 64 MiB to prevent OOM from a
@@ -257,6 +331,7 @@ impl LspClient {
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
}
/// Notify the server that a document was opened (`textDocument/didOpen`).
pub fn did_open(
&mut self,
uri: &str,
@@ -264,6 +339,7 @@ impl LspClient {
version: i32,
text: &str,
) -> anyhow::Result<()> {
debug!(uri = uri, version = version, "LSP didOpen");
self.notify(
"textDocument/didOpen",
&json!({
@@ -277,7 +353,9 @@ impl LspClient {
)
}
/// Notify the server that a document's content changed (`textDocument/didChange`).
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
debug!(uri = uri, version = version, "LSP didChange");
self.notify(
"textDocument/didChange",
&json!({
@@ -292,7 +370,9 @@ impl LspClient {
)
}
/// Notify the server that a document was closed (`textDocument/didClose`).
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
debug!(uri = uri, "LSP didClose");
self.notify(
"textDocument/didClose",
&json!({
@@ -326,14 +406,17 @@ impl LspClient {
self.call(method, &body)
}
/// Request hover information at a document position.
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional("textDocument/hover", uri, line, character, None)
}
/// Request completion items at a document position.
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional("textDocument/completion", uri, line, character, None)
}
/// Request the definition location of the symbol at a position.
pub fn goto_definition(
&mut self,
uri: &str,
@@ -343,6 +426,7 @@ impl LspClient {
self.call_positional("textDocument/definition", uri, line, character, None)
}
/// Request all references to the symbol at a position, including the declaration.
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call_positional(
"textDocument/references", uri, line, character,
@@ -350,6 +434,10 @@ impl LspClient {
)
}
/// Open a document, collect its diagnostics, then close it.
///
/// Flow: `didOpen` → wait for `textDocument/publishDiagnostics` notification
/// → `didClose` → return the `diagnostics` array (or empty on error).
pub fn collect_diagnostics(
&mut self,
uri: &str,
@@ -371,13 +459,19 @@ impl LspClient {
}
}
/// Send `shutdown` + `exit` to the server gracefully.
///
/// Flow: call `shutdown` with 5 s timeout → send `exit` notification.
/// Failures are silently ignored (best-effort cleanup).
pub fn shutdown(&mut self) {
info!("LSP shutdown");
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
let _ = self.notify("exit", &json!({}));
}
}
impl Drop for LspClient {
/// Best-effort `exit` notification on drop.
fn drop(&mut self) {
let _ = self.notify("exit", &json!({}));
}
@@ -396,6 +490,9 @@ fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
}
}
/// Convert an arbitrary file path to a `file://` URI for LSP protocol use.
///
/// This is the public entry point; delegates to the private `file_path_to_uri`.
pub fn path_to_lsp_uri(path: &str) -> String {
file_path_to_uri(path)
}
+26 -8
View File
@@ -1,6 +1,14 @@
//! LSP server connection management: registry of connected servers,
//! per-extension routing, and file-change notification dispatch.
//!
//! Flow: [`LspManager::connect`] spawns a server → [`register_extensions`]
//! maps file extensions to a language id → [`did_change_file`] routes edits
//! as `didOpen` / `didChange` notifications.
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};
mod client;
pub mod provisioner;
@@ -70,6 +78,7 @@ impl LspManager {
language_id: language_id.to_string(),
client: Arc::new(Mutex::new(client)),
});
info!(language_id = language_id, command = command, "LSP server connected");
Ok(())
}
@@ -93,7 +102,11 @@ impl LspManager {
}
let len = self.servers.len();
self.servers.retain(|s| s.language_id != language_id);
self.servers.len() < len
let removed = self.servers.len() < len;
if removed {
info!(language_id = language_id, "LSP server disconnected");
}
removed
}
/// Return the language id (e.g. "rust") registered for `language_id`.
@@ -111,10 +124,12 @@ impl LspManager {
/// are accepted at this layer — caller must ensure a server for
/// `language_id` is connected or will be connected later.
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
let count = extensions.len();
for ext in extensions {
self.extension_registry
.insert(ext.to_string(), language_id.to_string());
}
debug!(language_id = language_id, count = count, "extensions registered");
}
/// Notify the relevant LSP server that a file's contents have changed.
@@ -124,7 +139,7 @@ impl LspManager {
/// -> update `open_files` with the new version.
///
/// Non-critical failures (file missing, server unreachable, send
/// error) are logged with `tracing::warn!` rather than propagated,
/// error) are logged with `warn!` rather than propagated,
/// so a stale notification cannot abort the calling flow.
pub fn did_change_file(&mut self, path: &Path) {
let Some(ext) = path
@@ -132,12 +147,12 @@ impl LspManager {
.and_then(|e| e.to_str())
.map(|s| format!(".{s}"))
else {
tracing::warn!("did_change_file: path has no extension: {:?}", path);
warn!("did_change_file: path has no extension: {:?}", path);
return;
};
let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
tracing::warn!(
warn!(
"did_change_file: no LSP server registered for extension '{}'",
ext
);
@@ -149,13 +164,13 @@ impl LspManager {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
warn!("did_change_file: failed to read {:?}: {}", path, e);
return;
}
};
let Some(client) = self.get_client(&language_id) else {
tracing::warn!("did_change_file: no client for language '{}'", language_id);
warn!("did_change_file: no client for language '{}'", language_id);
return;
};
@@ -168,7 +183,7 @@ impl LspManager {
let mut client = match client.lock() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
warn!(
"did_change_file: client mutex poisoned for '{}': {}",
language_id,
e
@@ -184,7 +199,7 @@ impl LspManager {
};
if let Err(e) = send_result {
tracing::warn!(
warn!(
"did_change_file: failed to notify '{}' for {}: {}",
language_id,
uri,
@@ -208,12 +223,14 @@ impl LspManager {
/// drop the vec. Failures from individual shutdowns are swallowed
/// because the goal is best-effort termination during teardown.
pub fn shutdown_all(&mut self) {
let count = self.servers.len();
for server in &self.servers {
if let Ok(mut client) = server.client.lock() {
client.shutdown();
}
}
self.servers.clear();
info!(count = count, "all LSP servers shut down");
}
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
@@ -248,6 +265,7 @@ impl LspManager {
) -> anyhow::Result<()> {
self.connect(command, args, language_id)?;
self.register_extensions(language_id, extensions);
info!(language_id = language_id, "LSP connected with extensions");
Ok(())
}
}
@@ -38,6 +38,8 @@ pub enum ProvisionResult {
/// Sentinel command names used by `provision_single` to detect "download"
/// tiers (which are dispatched to `download_*` helpers rather than
/// `run_command`). Kept as constants so `supported_servers` stays readable.
/// These are never actual executables — they are matched by prefix/suffix in
/// `manager.rs` and dispatched to `install::run_download_tier`.
pub(super) const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__";
pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
@@ -1,8 +1,12 @@
//! Environment discovery: finding binaries on PATH and detecting available
//! toolchains / package managers on the host system.
//!
//! Flow: [`detect_env`] shells out to `which` for each tool and builds an
//! [`EnvInfo`] struct that the provisioner uses to gate install tiers.
use std::path::PathBuf;
use std::process::Command;
use tracing::{debug, info};
/// Rust toolchain availability on the host PATH.
#[derive(Debug, Clone)]
@@ -57,6 +61,16 @@ pub struct EnvInfo {
pub is_macos: bool,
}
/// Check whether `binary` exists on PATH by shelling out to `which`.
///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
/// `which` returns exit 0 + stdout path when found, non-zero
/// otherwise. We return the first stdout line as the `PathBuf`.
///
/// Returns None if `which` itself is missing, fails to spawn, or the
/// binary is not on PATH. We deliberately don't cache this — it's only
/// called during provisioning and the results feed into install-tier
/// gating, which is already cheap.
/// Check whether `binary` exists on PATH by shelling out to `which`.
///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
@@ -68,6 +82,7 @@ pub struct EnvInfo {
/// called during provisioning and the results feed into install-tier
/// gating, which is already cheap.
pub fn which(binary: &str) -> Option<PathBuf> {
debug!(binary = binary, "checking PATH");
let output = Command::new("which").arg(binary).output().ok()?;
if !output.status.success() {
return None;
@@ -77,7 +92,9 @@ pub fn which(binary: &str) -> Option<PathBuf> {
if first.is_empty() {
None
} else {
Some(PathBuf::from(first))
let path = PathBuf::from(first);
debug!(binary = binary, path = %path.display(), "found on PATH");
Some(path)
}
}
@@ -92,7 +109,8 @@ pub fn which(binary: &str) -> Option<PathBuf> {
/// Edge case: `which` may not exist on Windows; we guard with cfg so
/// this only ever runs on Unix-like targets.
pub fn detect_env() -> EnvInfo {
EnvInfo {
debug!("detecting host environment");
let detected = EnvInfo {
rust: RustToolchain {
has_rustup: which("rustup").is_some(),
has_cargo: which("cargo").is_some(),
@@ -116,5 +134,14 @@ pub fn detect_env() -> EnvInfo {
},
is_linux: cfg!(target_os = "linux"),
is_macos: cfg!(target_os = "macos"),
}
};
info!(
?detected.rust,
?detected.web,
?detected.platform,
?detected.pacman_brew,
?detected.apt_dnf,
"environment detected"
);
detected
}
@@ -3,30 +3,43 @@
//!
//! Each helper downloads a prebuilt binary (or archive) and places it
//! under `~/.local/share/zesdex/lsp/<server-name>/`.
//!
//! Flow: `run_download_tier` dispatches sentinel command names to the
//! appropriate installer (`install_rust_analyzer_binary` or
//! `install_jdtls_from_eclipse`). Each installer downloads, extracts,
//! and sets executable permissions on the binary.
use std::path::{Path, PathBuf};
use tracing::info;
use tracing::{debug, info};
use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN};
use super::discovery::EnvInfo;
use super::manager::run_command;
/// Resolve the directory where downloaded LSP binaries are stored.
///
/// Returns `~/.local/share/zesdex/lsp/<server>/` (using `dirs::data_dir`).
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
let base = dirs::data_dir()
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
.join("zesdex")
.join("lsp")
.join(server);
debug!(server = server, path = %base.display(), "LSP install dir");
Ok(base)
}
/// Check whether `def` was previously installed via the download tier
/// (binary/launcher lives under `~/.local/share/zesdex/lsp/<name>/`).
///
/// Flow: resolve install dir → iterate known binary name patterns under
/// that dir → return the first existing file path.
///
/// Returns the path to the binary if found.
pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) -> Option<PathBuf> {
let base = lsp_install_dir(&def.name).ok()?;
// Candidate relative paths under the install directory for each server.
let candidates: &[&str] = match def.name.as_str() {
"rust-analyzer" => &["rust-analyzer"],
"jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"],
@@ -39,17 +52,24 @@ pub(super) fn previous_download_install(def: &super::config::LanguageServerDef)
if p.exists() {
// Skip directory entries that exist but are the base dir itself.
if p.is_file() {
debug!(name = %def.name, path = %p.display(), "found previous install");
return Some(p);
}
}
}
debug!(name = %def.name, "no previous install found");
None
}
/// Download a file from `url` to `dest` using curl.
///
/// Flow: build curl args with connect-timeout (15 s) and max-time
/// (`max_secs`) → delegate to `run_command` → return error on failure.
fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
let path_str = dest.to_str().ok_or("invalid dest path")?.to_string();
info!(url = url, dest = %path_str, "downloading");
info!(url = url, dest = %path_str, max_secs = max_secs, "downloading file");
// curl flags: -f (fail on HTTP error), -sS (silent but show errors),
// -L (follow redirects), --connect-timeout, --max-time, -o (output).
let args = [
"-fsSL",
"--connect-timeout",
@@ -64,11 +84,15 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
if !ok {
return Err(format!("download failed: {}", out.trim()));
}
info!(url = url, "download complete");
Ok(())
}
/// Download rust-analyzer from GitHub releases and install into
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
/// `~/.local/share/zesdex/lsp/rust-analyzer/rust-analyzer`.
///
/// Flow: create install dir → pick platform URL → download gzipped binary →
/// decompress with gunzip → set executable permissions → return binary path.
fn install_rust_analyzer_binary(
env: &EnvInfo,
progress: ProgressFn<'_>,
@@ -76,6 +100,7 @@ fn install_rust_analyzer_binary(
let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
// GitHub release URLs for the latest rust-analyzer prebuilt binary.
let url = if env.is_linux {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
} else if env.is_macos {
@@ -84,9 +109,10 @@ fn install_rust_analyzer_binary(
return Err("no prebuilt binary for this OS".to_string());
};
let gz = base.join("rust-analyzer.gz");
let target = base.join("rust-analyzer");
let gz = base.join("rust-analyzer.gz"); // downloaded archive
let target = base.join("rust-analyzer"); // final binary path
info!("rust-analyzer: downloading prebuilt binary");
if let Some(cb) = progress {
cb("Rust: downloading prebuilt binary...");
}
@@ -103,6 +129,7 @@ fn install_rust_analyzer_binary(
if !target.exists() {
return Err("binary missing after decompression".to_string());
}
// Set executable bit on Unix (0o755 = rwxr-xr-x).
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -112,17 +139,24 @@ fn install_rust_analyzer_binary(
if let Some(cb) = progress {
cb("Rust: installed ✓");
}
info!("rust-analyzer: installed at {}", target.display());
Ok(target)
}
/// Download Eclipse JDT-LS from the official snapshot server, extract it,
/// and create a launcher script at `bin/jdtls`.
///
/// Flow: create install dir → download ~150 MB tarball → extract with tar →
/// verify `plugins/` exists → write a bash launcher script that resolves
/// the JDT-LS launcher JAR and config → set launcher executable.
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let base = lsp_install_dir("jdtls")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
let tarball = base.join("jdtls.tar.gz");
info!("jdtls: downloading (~150 MB)");
if let Some(cb) = progress {
cb("Java: downloading JDT-LS (~150MB)...");
}
@@ -146,6 +180,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
}
let _ = std::fs::remove_file(&tarball);
// Validate that the extracted contents include the plugins directory.
if !base.join("plugins").exists() {
return Err("extracted archive missing plugins/ directory".to_string());
}
@@ -182,15 +217,20 @@ exec java \
if let Some(cb) = progress {
cb("Java: JDT-LS installed ✓");
}
info!("jdtls: installed at {}", launcher.display());
Ok(launcher)
}
/// Dispatch a sentinel download tier to the correct helper.
///
/// Matches sentinel constants (`DOWNLOAD_RUST_BIN`, `DOWNLOAD_JDTLS`) and
/// routes to the appropriate platform-aware installer.
pub(super) fn run_download_tier(
name: &str,
env: &EnvInfo,
progress: ProgressFn<'_>,
) -> Result<PathBuf, String> {
info!(tier = name, "running download tier");
match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
@@ -5,7 +5,7 @@ use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult};
use super::discovery::{self, EnvInfo};
@@ -25,6 +25,7 @@ use crate::app::lsp::LspManager;
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
debug!(cmd = cmd, args = ?args, "running command");
let mut command = Command::new(cmd);
command.args(args);
command.stdout(Stdio::piped());
@@ -1,10 +1,11 @@
//! Auto-provisioning engine for LSP language servers.
//!
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
//! → `provision_single()` tries install tiers in order → returns
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call `auto_connect()` to attach available servers
//! to an existing `LspManager`.
//! Flow: [`discovery::detect_env()`] probes the host → for each server in
//! [`config::supported_servers()`] → [`manager::provision_all_with_progress()`]
//! tries install tiers in order → returns [`config::ProvisionResult`]
//! (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call [`manager::auto_connect()`] to attach available
//! servers to an existing [`crate::app::lsp::LspManager`].
//!
//! Why: opening a project on a fresh machine should not require the user
//! to manually hunt down and install 4 different language servers.
@@ -26,13 +27,13 @@ pub use config::{InstallTier, LanguageServerDef, ProgressFn, ProvisionResult};
#[allow(unused_imports)]
pub use config::supported_servers;
// Environment discovery
// Environment discovery — toolchain and package-manager detection
#[allow(unused_imports)]
pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain};
#[allow(unused_imports)]
pub use discovery::which;
// Manager / orchestration
// Manager / orchestration — provisioning loop and LspManager attachment
#[allow(unused_imports)]
pub use manager::{auto_connect, provision_all_with_progress, run_command};
+21 -11
View File
@@ -1,9 +1,16 @@
//! MCP server connection management: spawning/talking to stdio child
//! processes and HTTP endpoints, and adapting their advertised tools to
//! the crate's `Tool` trait.
//!
//! Flow: [`McpManager::connect_stdio`] spawns an MCP server → runs
//! `initialize` handshake → calls `tools/list` → wraps each advertised
//! tool in an [`McpToolAdapter`] (which implements `Tool`) → stores the
//! server with its persistent child handle for subsequent `tools/call`.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
use super::transport::{call_via_http, call_via_stdio, mcp_static_str, spawn_stdio_child};
@@ -128,6 +135,7 @@ impl McpManager {
command: &str,
extra_args: &[String],
) -> anyhow::Result<()> {
info!(name = name, command = command, "MCP connect stdio");
let transport = McpTransport::Stdio {
command: command.to_string(),
args: extra_args.to_vec(),
@@ -146,18 +154,18 @@ impl McpManager {
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
tracing::warn!(
"[mcp] tool {} missing description",
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
);
""
})
.to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
tracing::warn!(
"[mcp] tool {} missing inputSchema",
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
warn!(
tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"),
"MCP tool missing description"
);
""
})
.to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
warn!(
tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"),
"MCP tool missing inputSchema"
);
serde_json::Value::Null
}),
})
@@ -169,6 +177,7 @@ impl McpManager {
let handle = Arc::new(Mutex::new(child));
let tool_count = tools.len();
self.servers.push(McpServer {
name: name.to_string(),
transport,
@@ -176,6 +185,7 @@ impl McpManager {
child_handle: Some(handle),
});
info!(name = name, tool_count = tool_count, "MCP server connected");
Ok(())
}
}
+7 -2
View File
@@ -1,4 +1,9 @@
//! Model Context Protocol (MCP) client: connects to external MCP servers
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
pub mod manager;
pub mod transport;
//!
//! Sub-modules:
//! - [`manager`] — server registry, connection lifecycle, tool adapter
//! - [`transport`] — low-level stdio child management and HTTP client calls
pub mod manager; // McpManager, McpServer, McpToolAdapter
pub mod transport; // McpTransport, McpToolInfo, StdioChild, wire helpers
+19 -9
View File
@@ -1,11 +1,16 @@
//! MCP transport layer: stdio child process management and HTTP client calls.
//! This module handles the low-level protocol details of communicating with
//! MCP servers (both spawned subprocesses and remote HTTP endpoints).
//!
//! Flow: `spawn_stdio_child` → `StdioChild::call` for JSON-RPC messages;
//! `call_via_stdio` / `call_via_http` are convenience wrappers for
//! `tools/call` that reuse a persistent child handle or spawn a fresh one.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::sync::{Mutex, OnceLock};
use tracing::{debug, info, warn};
// ---------------------------------------------------------------------------
// Constants
@@ -26,7 +31,7 @@ pub(super) fn mcp_static_str(s: &str) -> &'static str {
let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() {
Ok(c) => c,
Err(poisoned) => {
tracing::warn!("[mcp] static string cache mutex poisoned, recovering");
warn!("[mcp] static string cache mutex poisoned, recovering");
poisoned.into_inner()
}
};
@@ -88,6 +93,7 @@ impl StdioChild {
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
self.next_id += 1;
let id = self.next_id;
debug!(method = method, id = id, "MCP stdio call");
let req = json!({
"jsonrpc": "2.0",
"id": id,
@@ -163,7 +169,7 @@ impl StdioChild {
anyhow::bail!("MCP error: {err}");
}
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
warn!("MCP stdio response missing 'result' field: {}", trimmed);
Value::Null
}));
}
@@ -179,6 +185,7 @@ pub(crate) fn spawn_stdio_child(
command: &str,
extra_args: &[String],
) -> anyhow::Result<StdioChild> {
info!(command = command, "MCP spawn stdio child");
let parts: Vec<&str> = command.split_whitespace().collect();
let (prog, prog_args) = parts
.split_first()
@@ -249,6 +256,7 @@ pub(super) fn call_via_stdio(
tool_name: &str,
tool_args: &Value,
) -> anyhow::Result<String> {
debug!(tool = tool_name, has_handle = existing_handle.is_some(), "MCP call_via_stdio");
// Reuse the persistent child handle if available; otherwise spawn a new one.
let mut guard;
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
@@ -257,6 +265,7 @@ pub(super) fn call_via_stdio(
.map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
&mut guard
} else {
// No persistent handle — spawn a fresh child for this one call.
let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call(
"tools/call",
@@ -280,13 +289,14 @@ pub(super) fn call_via_stdio(
}
pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
debug!(tool = tool_name, url = url, "MCP call_via_http");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS))
.build()
.unwrap_or_else(|e| {
tracing::warn!(
"[mcp] HTTP client builder failed with connect timeout: {}. \
warn!(
"MCP HTTP client builder failed with connect timeout: {}. \
retrying without connect timeout",
e,
);
@@ -294,8 +304,8 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an
.timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS))
.build()
.unwrap_or_else(|e2| {
tracing::warn!(
"[mcp] also failed: {}. using default client (no configured timeouts)",
warn!(
"MCP also failed: {}. using default client (no configured timeouts)",
e2,
);
reqwest::blocking::Client::new()
@@ -323,7 +333,7 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
warn!("MCP failed to read HTTP response body: {}", e);
String::new()
});
anyhow::bail!("MCP HTTP server returned {status}: {text}");
@@ -338,7 +348,7 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an
}
let result = response.get("result").cloned().unwrap_or_else(|| {
tracing::warn!("[mcp] HTTP response missing 'result' field");
warn!("MCP HTTP response missing 'result' field");
Value::Null
});
Ok(extract_text_content(&result))
@@ -365,7 +375,7 @@ pub(super) fn extract_text_content(result: &Value) -> String {
}
}
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
warn!("MCP failed to pretty-print result: {}", e);
result.to_string()
})
}
+12 -11
View File
@@ -1,14 +1,15 @@
//! Top-level application module: tool gate, modes, runtime loop, state,
//! workflows, subagents, review, background bash, MCP integration, and
//! native LSP client.
pub mod bgbash;
pub mod guard;
pub mod lsp;
pub mod mcp;
pub mod mode;
pub mod review;
pub mod runtime;
pub mod state;
pub mod subagent;
pub mod util;
pub mod workflow;
pub mod bgbash; // Background bash process management
pub mod guard; // Tool gate: per-tool access control & permissions
pub mod lsp; // Native LSP client integration
pub mod mcp; // Model Context Protocol tool bridge
pub mod mode; // Application operating modes (normal, yolo, etc.)
pub mod review; // Post-edit auto-review subagent
pub mod runtime; // Action dispatch, streams, slash commands
pub mod state; // AppStateRest, runtime state, turn events
pub mod subagent; // Spawned subagents (test-gen, arch, security review)
pub mod util; // Miscellaneous helpers
pub mod workflow; // Hive-mind orchestration & agent workflows
@@ -1,5 +1,6 @@
//! Bash mode: handles submitting a shell command from the bash input panel.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Launch a background bash job for the submitted command.
///
@@ -12,7 +13,10 @@ use crate::app::state::rest::AppStateRest;
/// the shared jobs map for later polling.
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
if !command.is_empty() {
debug!(command_len = command.len(), "spawning bash job from mode");
let _ = crate::app::bgbash::job::spawn_bash_job(command);
state.dirty = true;
} else {
debug!("bash submit with empty command — ignored");
}
}
@@ -2,6 +2,7 @@
//! with bounded undo history.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use tracing::debug;
/// State for the built-in line editor overlay: buffer contents, cursor
/// position, and a bounded undo stack.
@@ -30,7 +31,9 @@ impl EditorState {
/// Create a fresh editor state for `path`, seeded with existing content
/// (or a single empty line for a new file).
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
let is_new = existing_content.is_none();
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
debug!(path = %path, is_new, lines = content.len(), "editor opened");
EditorState {
path,
content,
@@ -116,6 +119,7 @@ impl EditorState {
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
let editor = &mut state.misc.editor;
let Some(ed) = editor.as_mut() else {
debug!("editor input received but no editor open — ignored");
return;
};
for c in text.chars() {
@@ -138,7 +142,14 @@ pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
}
/// Close the editor overlay without saving, clearing editor state.
///
/// Flow: reset editor to `None` → set overlay to `Overlay::None` →
/// mark state dirty for re-render.
///
/// Why: discards unsaved edits; the caller is responsible for saving
/// via a separate commit action.
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
debug!("editor dismissed without saving");
state.misc.editor = None;
state.misc.overlay = Overlay::None;
state.dirty = true;
+12 -3
View File
@@ -1,13 +1,18 @@
//! Effort mode: cycles the agent's reasoning effort level, which scales the
//! LLM's temperature and `max_tokens` for subsequent turns.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Named effort levels from lowest to highest. Higher levels allocate more
/// tokens and use lower temperature for more deterministic reasoning.
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use,
/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token
/// budget for lower temperature (more deterministic, more room to reason/act).
/// Multiplier applied to the user's configured `max_tokens` per effort level.
/// Same index as `EFFORT_LEVELS`. Higher effort = larger token budget.
const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0];
/// Temperature override per effort level. Higher effort = lower temperature
/// (more deterministic, less creative variation).
const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1];
/// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent
@@ -28,6 +33,9 @@ pub fn current_effort(state: &AppStateRest) -> usize {
}
/// Return the current effort level's display name (e.g. "medium").
///
/// Flow: delegate to `current_effort` for clamped index → index into
/// `EFFORT_LEVELS`.
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
let idx = current_effort(state);
EFFORT_LEVELS[idx]
@@ -41,6 +49,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
let current = current_effort(state);
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
let label = current_effort_str(state);
debug!(from = %EFFORT_LEVELS[current], to = %label, "effort level cycled");
state.toast_info(format!("Effort: {label}"));
state.dirty = true;
}
@@ -1,8 +1,18 @@
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
//! Key input mode: raw text capture overlay used for one-off key/text prompts
//! such as rename, search, and inline file paths.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Replace the input buffer with the given text and mark state dirty.
/// Store the captured text into the input buffer and mark state dirty.
///
/// Flow: write `text` into `state.input.buffer` → set dirty flag so the
/// TUI re-renders the overlay with the new text.
///
/// Why: the overlay reads `state.input.buffer` to display the current
/// prompt text; this is the single point where captured keystrokes
/// become visible to the renderer.
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
debug!(len = text.len(), "key-input text captured");
state.input.buffer = text;
state.dirty = true;
}
+25 -1
View File
@@ -1,7 +1,19 @@
//! Learning mode: TUI overlay for reviewing and managing lesson items.
//! Loads both pending lessons (from the session directory) and stored
//! lessons (from long-term memory) into a unified list for the overlay.
//!
//! Flow: read pending files → deserialize as `PendingLesson` → read
//! long-term memory dir → filter by `kind == "lesson"` → merge into
//! a single `Vec<LearningItem>`.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
use zesdex_cms::domain::repository::MemoryRepository;
/// A unified representation of a lesson item for the interactive TUI overlay.
///
/// Two variants: `Pending` (not yet committed to long-term memory) and
/// `Stored` (already persisted in the memory directory).
#[derive(Debug, Clone)]
pub enum LearningItem {
Pending {
@@ -19,7 +31,16 @@ pub enum LearningItem {
},
}
/// Dynamically read all pending and stored lessons.
/// Dynamically read all pending and stored lessons from session dir and
/// long-term memory.
///
/// Flow: load pending lessons from `state.session_runtime.session_dir` →
/// map each to `LearningItem::Pending` → load stored memories from
/// `state.memory_dir` → filter by `kind == "lesson"` → collect remaining
/// items.
///
/// Return: merged `Vec<LearningItem>` (pending first, then stored). Empty
/// vec if nothing is found.
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let mut items = Vec::new();
@@ -29,6 +50,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
} else {
Vec::new()
};
debug!(pending_count = pending.len(), "loading pending lessons");
for p in pending {
let scope_str = match p.lesson.scope {
@@ -58,6 +80,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
.list(&state.memory_dir)
.unwrap_or_default();
debug!(stored_names = names.len(), "loading stored lessons");
for name in names {
if let Ok(mem) =
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
@@ -75,5 +98,6 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
}
}
debug!(total_items = items.len(), "learning items loaded");
items
}
+14
View File
@@ -1,11 +1,25 @@
//! MCP mode: overlay for connecting to a configured MCP server.
//!
//! Flow: invoked from the TUI overlay — reads the server name from user input,
//! then delegates to the appropriate MCP connection path.
use crate::app::state::rest::AppStateRest;
use tracing::debug;
/// Placeholder entry point for connecting to an MCP server by name.
///
/// Flow: marks state dirty → overlay re-renders.
///
/// Why: not yet wired to `McpManager::connect_stdio` — currently just
/// marks state dirty so the overlay re-renders.
///
/// ## Future
/// Once `McpManager::connect_stdio` is wired, this function will:
/// 1. Resolve `server_name` from the config registry.
/// 2. Spawn the stdio subprocess.
/// 3. Register the transport in the MCP manager.
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
debug!(%server_name, "connect_mcp called");
let _ = server_name;
// Mark state dirty to trigger a re-render of the MCP overlay.
state.dirty = true;
}
+13 -10
View File
@@ -1,16 +1,19 @@
//! TUI mode definitions and per-mode input/action handlers, one submodule
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
pub mod bash;
pub mod editor;
pub mod effort;
pub mod key_input;
pub mod mcp;
//! Each mode encapsulates its own keyboard input parsing, state transitions,
//! and view rendering so the top-level event loop can dispatch generically.
pub mod learning;
pub mod quit_confirm;
pub mod rewind;
pub mod settings;
pub mod todo;
pub mod bash; // Shell-command input overlay: prompt, history, execution
pub mod editor; // Multi-line text editor overlay (write/edit tool content)
pub mod effort; // Reasoning-effort selector overlay
pub mod key_input; // Generic single-key prompt overlay (e.g. rename, search)
pub mod mcp; // MCP tool argument builder overlay
pub mod learning; // Learning/reflection input overlay
pub mod quit_confirm; // Quit confirmation dialog overlay
pub mod rewind; // Rewind/undo checkpoint selection overlay
pub mod settings; // Settings panel overlay
pub mod todo; // TODO-list management overlay
/// Cycle `current` in the range `[0, len)`.
///
@@ -1,11 +1,18 @@
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
//!
//! Flow: user presses quit → overlay appears with yes/no → `handle_quit_confirm`
//! translates the choice into an `Action`.
use crate::app::runtime::actions::Action;
use tracing::debug;
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
///
/// Flow: receives `true` (yes, quit) or `false` (no, cancel).
///
/// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay`
/// to dismiss the prompt without quitting.
pub fn handle_quit_confirm(yes: bool) -> Action {
debug!(%yes, "handle_quit_confirm");
if yes {
Action::ForceQuit
} else {
+39 -4
View File
@@ -1,23 +1,47 @@
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store.
//!
//! Flow: user invokes Rewind overlay → `rewind_count` shows available snapshots
//! → user picks an index → `rewind_to` fetches the blob, writes it back to disk,
//! and logs the rewind in the edit log.
use crate::app::state::rest::AppStateRest;
use sha2::Digest;
use tracing::{debug, info};
use zesdex_cms::domain::repository::EditLogRepository;
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
///
/// Flow: opens the session DB → lists blob keys → returns count.
///
/// Return: `0` if the DB cannot be opened or no blobs exist.
pub fn rewind_count(state: &AppStateRest) -> usize {
let Ok(conn) = open_session_db(&state.session_dir) else {
debug!("rewind_count: cannot open session DB, returning 0");
return 0;
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
let count = crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok()
.map_or(0, |keys| keys.len())
.map_or(0, |keys| keys.len());
debug!(count, "rewind_count");
count
}
/// Restores a file to its pre-edit state by retrieving the blob stored under index
/// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
/// of a running turn (e.g. from the Rewind overlay).
///
/// Flow:
/// 1. Open session DB.
/// 2. List blob keys.
/// 3. Validate index bounds.
/// 4. Retrieve blob bytes.
/// 5. Resolve the original file path from the edit log.
/// 6. Write bytes back to disk.
/// 7. Log the rewind as an edit-log entry.
/// 8. Mark transcript cache dirty to force a UI refresh.
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
debug!(%index, "rewind_to start");
let conn = match open_session_db(&state.session_dir) {
Ok(c) => c,
Err(e) => {
@@ -66,6 +90,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
match std::fs::write(&restore_path, &bytes) {
Ok(()) => {
info!(path = %restore_path.display(), "rewind_to: file restored from snapshot");
state.toast_success(format!("Restored {} from snapshot", restore_path.display()));
}
Err(e) => {
@@ -73,7 +98,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
}
}
// Log the rewind itself as an edit entry
// Log the rewind itself as an edit entry so the operation is auditable.
let repo =
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&state.session_dir) {
@@ -90,17 +115,27 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let _ = repo.append(&state.session_dir, &mut el, entry);
}
// Clear the transcript to force a refresh
// Clear the transcript cache to force the UI to refresh.
state.transcript_cache.dirty = true;
state.dirty = true;
debug!("rewind_to finished");
}
/// Open a direct SQLite connection to the session database.
///
/// Flow: constructs the path to `messages.sqlite` under `session_dir` → opens with rusqlite.
fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
let path = session_dir.join("messages.sqlite");
let conn = rusqlite::Connection::open(&path)?;
debug!(path = %path.display(), "open_session_db opened");
Ok(conn)
}
/// Walk the edit log backwards to find the most recent `write` or `edit` entry,
/// and return its path.
///
/// Why: the blob key is a `tool_call_id`, but the edit log stores paths, not
/// tool_call_ids. We fall back to the last-known written/edited path.
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
.open(&state.session_dir)
@@ -3,6 +3,7 @@
//! Flow: exposes small mutation functions (currently just cycling the
//! internet access mode) invoked by keybindings while the settings overlay
//! is active.
use tracing::debug;
use zesdex_cms::domain::settings::{InternetMode, Settings};
/// Advance the internet access mode to the next value in the cycle.
@@ -14,9 +15,11 @@ use zesdex_cms::domain::settings::{InternetMode, Settings};
///
/// Return: nothing; mutates `settings.internet_mode` in place.
pub fn cycle_internet_mode(settings: &mut Settings) {
let before = settings.internet_mode.clone();
settings.internet_mode = match settings.internet_mode {
InternetMode::Off => InternetMode::ReadOnly,
InternetMode::ReadOnly => InternetMode::Full,
InternetMode::Full => InternetMode::Off,
};
debug!(?before, ?settings.internet_mode, "cycle_internet_mode");
}
@@ -4,6 +4,7 @@
//! the todo overlay.
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use tracing::debug;
/// Toggle the todo-list overlay open or closed.
///
@@ -14,10 +15,12 @@ use crate::app::state::types::Overlay;
///
/// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place.
pub fn handle_todo_toggle(state: &mut AppStateRest) {
let before = state.misc.overlay;
if state.misc.overlay == Overlay::Todo {
state.misc.overlay = Overlay::None;
} else {
state.misc.overlay = Overlay::Todo;
}
debug!(before = %before, after = %state.misc.overlay, "handle_todo_toggle");
state.dirty = true;
}
+19 -1
View File
@@ -73,8 +73,11 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
pub fn trigger_review(state: &mut AppStateRest) {
tracing::info!("[review] triggering quality-review subagent");
state.misc.lesson_running = true;
// Ensure docs/lesson/ is gitignored so generated lesson files don't
// pollute the workspace's tracked state.
if let Some(workspace) = state.workspace_roots.first() {
let gitignore_path = workspace.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
@@ -108,6 +111,8 @@ pub fn trigger_review(state: &mut AppStateRest) {
ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces.clone_from(&state.workspace_roots);
// Run build/test probe so the review subagent gets a real pass/fail
// signal rather than reviewing changes blind.
let probe_result = probe::probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
@@ -117,20 +122,30 @@ pub fn trigger_review(state: &mut AppStateRest) {
let probe_note = match &probe_result {
Some(r) => {
if r.passed {
tracing::debug!("[review] probe passed: {}", r.command);
format!("Build/test verification passed ({}).", r.command)
} else if r.timed_out {
tracing::debug!("[review] probe timed out: {}", r.command);
format!("Build/test verification timed out ({}).", r.command)
} else {
tracing::debug!("[review] probe failed: {}", r.command);
format!(
"Build/test verification failed ({}). Output: {}",
r.command, r.output
)
}
}
None => "No build/test probe matched.".to_string(),
None => {
tracing::debug!("[review] no probe matched");
"No build/test probe matched.".to_string()
}
};
ctx.system_prompt = prompt::compose_review_prompt(state, &probe_note);
tracing::debug!(
"[review] prompt length: {} chars",
ctx.system_prompt.len()
);
let turn_events_for_drain = state.turn_events.clone();
let (tx, _drain_thread) = spawn_subagent_with_drain(move |event| {
@@ -164,6 +179,7 @@ pub fn trigger_review(state: &mut AppStateRest) {
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
tracing::debug!("[review] subagent thread started");
let result = run_subagent(&ctx, &tx);
let message = match result {
Ok(verdict) => {
@@ -180,6 +196,8 @@ pub fn trigger_review(state: &mut AppStateRest) {
}
});
// Push a non-blocking toast so the user knows a lesson is being
// generated; the actual outcome arrives via SystemNote.
state.push_toast(Toast::new(
ToastKind::Info,
"Generating lesson...".to_string(),
@@ -60,12 +60,13 @@ pub fn process_pending_lessons(
) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
let grace_window = 5_000;
let grace_window = 5_000; // 5 seconds for user to reject auto-resolve
let mut remaining = Vec::new();
let mut to_keep = Vec::new();
for p in &pending {
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
tracing::debug!("[pending] auto-resolving lesson: {}", p.lesson.name);
to_keep.push(p.lesson.clone());
} else {
remaining.push(p.clone());
@@ -119,6 +120,7 @@ pub fn resolve_pending_lesson(
for p in pending {
if p.lesson.name == lesson_name {
if keep {
tracing::info!("[pending] committing lesson: {lesson_name}");
let mem = Memory {
name: p.lesson.name.clone(),
description: p.lesson.content.chars().take(80).collect(),
@@ -136,6 +138,8 @@ pub fn resolve_pending_lesson(
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
.map_err(|e| std::io::Error::other(e.to_string()))?;
} else {
tracing::debug!("[pending] discarding lesson: {lesson_name}");
}
} else {
remaining.push(p);
+22 -1
View File
@@ -35,6 +35,10 @@ pub fn probe_build_test(
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
tracing::debug!("[probe] running: {cmd} in {:?}", probe_dir);
// Split "command arg1 arg2" into program + args for Command API.
// If there's no space, args are empty.
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(
|| (cmd.clone(), String::new()),
|(p, a)| (p.to_string(), a.to_string()),
@@ -47,6 +51,7 @@ pub fn probe_build_test(
.stderr(std::process::Stdio::piped())
.spawn()
else {
tracing::warn!("[probe] failed to spawn: {cmd_prog}");
return None;
};
@@ -72,9 +77,14 @@ pub fn probe_build_test(
} else {
format!("{stdout}\n{stderr}")
};
let passed = status.success();
tracing::debug!(
"[probe] finished: passed={passed}, exit={:?}",
status.code()
);
return Some(ProbeResult {
command: cmd.clone(),
passed: status.success(),
passed,
output: truncate_output(&combined, 2048),
timed_out: false,
});
@@ -86,6 +96,7 @@ pub fn probe_build_test(
}
};
if timed_out {
tracing::debug!("[probe] timed out after {timeout_ms}ms: {cmd}");
Some(ProbeResult {
command: cmd.clone(),
passed: false,
@@ -93,6 +104,7 @@ pub fn probe_build_test(
timed_out: true,
})
} else {
tracing::debug!("[probe] unexpected exit from polling loop for: {cmd}");
None
}
}
@@ -114,23 +126,30 @@ pub(crate) fn resolve_verify_command(
probe_dir: &std::path::Path,
override_cmd: Option<&str>,
) -> Option<String> {
// Use explicit override if provided and non-empty.
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
tracing::debug!("[probe] using override command: {cmd}");
return Some(cmd.trim().to_string());
}
}
// Auto-detect from project marker files, trying common ecosystems
// in priority order.
let has_file = |name: &str| probe_dir.join(name).exists();
let has_dir = |name: &str| probe_dir.join(name).is_dir();
if has_file("Cargo.toml") {
tracing::debug!("[probe] detected Cargo project");
if has_dir("src") || has_dir("tests") {
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
}
return Some("cargo build 2>&1".to_string());
}
if has_file("go.mod") {
tracing::debug!("[probe] detected Go project");
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
}
if has_file("package.json") {
tracing::debug!("[probe] detected Node project");
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?;
@@ -160,6 +179,7 @@ pub(crate) fn resolve_verify_command(
|| has_file("Pipfile")
|| has_file("poetry.lock")
{
tracing::debug!("[probe] detected Python project");
if has_file("pyproject.toml") {
let content =
std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
@@ -223,6 +243,7 @@ pub(crate) fn resolve_verify_command(
if has_file("Project.toml") || has_file("JuliaProject.toml") {
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
}
tracing::debug!("[probe] no project marker files matched in {probe_dir:?}");
None
}
@@ -9,6 +9,8 @@ pub(crate) const STALE_AFTER_DAYS: i64 = 60;
/// Compose the system prompt for the quality-review subagent.
pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String {
tracing::debug!("[prompt] composing review prompt");
// Capture the unstaged diff so the reviewer can evaluate actual changes.
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
std::process::Command::new("git")
.arg("diff")
@@ -22,6 +24,8 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S
String::new()
};
// Extract the last 10 chat messages (user + assistant) so the reviewer
// can cross-check what was discussed against what was actually changed.
let history_output = if let Some(rt) = &state.session_runtime {
let msgs: Vec<String> = rt
.messages
@@ -41,6 +45,11 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S
String::new()
};
tracing::debug!(
"[prompt] diff={}chars, history={}chars",
diff_output.len(),
history_output.len()
);
let session_dir_disp = state.session_dir.display();
format!(
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\
@@ -17,6 +17,7 @@ use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryReposito
/// Return: names of newly-flagged memories, or an I/O error from
/// `mem.write`.
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
tracing::debug!("[staleness] starting sweep in {:?}", memory_dir);
let mut flagged = Vec::new();
let names = MarkdownMemoryRepository::new()
.list(memory_dir)
@@ -26,6 +27,7 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
for name in names {
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
tracing::info!("[staleness] flagging as stale: {name}");
mem.lifecycle = "stale".to_string();
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
@@ -48,8 +50,10 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis();
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
tracing::trace!("[staleness] sweep skipped (rate-limited)");
return;
}
tracing::debug!("[staleness] sweep window elapsed, running");
state.misc.last_staleness_sweep_ms = now;
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
if !flagged.is_empty() {
@@ -53,6 +53,7 @@ pub struct Lesson {
pub provenance: Provenance,
}
/// Default is an empty unverified project-scoped lesson with no provenance.
impl Default for Lesson {
fn default() -> Self {
Self {
@@ -1,5 +1,7 @@
//! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process.
use tracing::debug;
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
use crate::controller::command::Command;
@@ -14,7 +16,9 @@ use crate::controller::command::Command;
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
/// by `apply_action`.
pub fn apply_command(command: Command) -> Vec<Action> {
debug!("apply_command: {:?}", command);
match command {
// ── Navigation overlays ────────────────────────────────────────
Command::Help => {
vec![Action::OpenOverlay(Overlay::Help)]
}
@@ -27,12 +31,16 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::ClearConfirm => {
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
}
// ── System actions ─────────────────────────────────────────────
Command::Clear => {
vec![Action::SystemNote {
kind: "clear".to_string(),
message: "transcript cleared".to_string(),
}]
}
// ── Login / auth ───────────────────────────────────────────────
Command::Login { provider } if provider.is_empty() => {
vec![Action::SystemNote {
kind: "error".to_string(),
@@ -42,6 +50,8 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Login { provider } => {
vec![Action::StartOAuth { provider }]
}
// ── Editor ─────────────────────────────────────────────────────
Command::Edit(path) if path == "." || path.is_empty() => {
vec![Action::SystemNote {
kind: "info".to_string(),
@@ -51,6 +61,8 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Edit(path) => {
vec![Action::OpenEditor { path }]
}
// ── Tools / configuration ──────────────────────────────────────
Command::McpAdd { name, command } => {
vec![Action::McpAdd { name, command }]
}
@@ -61,12 +73,15 @@ pub fn apply_command(command: Command) -> Vec<Action> {
vec![Action::Compact]
}
// ── Dashboard overlays ─────────────────────────────────────────
Command::TodoOpen => {
vec![Action::OpenOverlay(Overlay::Todo)]
}
Command::UsageOpen => {
vec![Action::OpenOverlay(Overlay::Usage)]
}
// ── Fallback ───────────────────────────────────────────────────
Command::Unknown(cmd) => {
vec![Action::SystemNote {
kind: "error".to_string(),
@@ -1,6 +1,15 @@
//! Simple action handler functions — one per `Action` variant, called by
//! `apply_action` in the root module. Each handler mutates `AppStateRest`
//! in place.
//!
//! Handlers are deliberately short and focused — they extract arguments from
//! the `Action` variant, perform a single state mutation, and mark `dirty`
//! so the TUI re-renders on the next frame.
//!
//! More complex orchestration (turn spawning, OAuth background threads) is
//! delegated to sibling sub-modules (`spawn`, `oauth`, `io`, `memory`).
use tracing::debug;
use crate::app::runtime::context::tokens::count_tokens;
use crate::app::runtime::context::window;
@@ -16,71 +25,104 @@ use super::memory::refresh_lesson_counters;
use super::spawn::spawn_turn;
use super::oauth::run_oauth_flow;
/// Hard exit — save session, shut down LSP, set quit flag.
///
/// Flow: persist session metadata and conversation → terminate LSP client →
/// set `quit = true` so the event loop exits on the next iteration.
pub(super) fn handle_force_quit(state: &mut AppStateRest) {
save_current_session(state);
state.shutdown_lsp();
state.quit = true;
debug!("handle_force_quit");
save_current_session(state); // Persist session metadata + messages
state.shutdown_lsp(); // Gracefully shut down LSP connection
state.quit = true; // Signal event loop to exit
}
/// Submit user text as a new LLM turn.
///
/// Flow: mark input as submitted → trim → guard empty → push `ChatMessageDisplay`
/// into transcript → push `ChatMessage` into session runtime → refresh lesson
/// counters → set `thinking = true` → spawn a background turn thread.
pub(super) fn handle_submit_input(state: &mut AppStateRest, text: String) {
debug!("handle_submit_input: len={}", text.len());
state.input.submit();
let text = text.trim().to_string();
if text.is_empty() {
state.dirty = true;
return;
}
// Push user message into both the display transcript and the session-runtime message list
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::user(text));
refresh_lesson_counters(&state.memory_dir, rt);
} else {
// No active session — ensure the memory directory exists for future use
let _ = std::fs::create_dir_all(&state.memory_dir);
}
state.misc.thinking = true;
spawn_turn(state);
spawn_turn(state); // launches LLM streaming on a background OS thread
state.dirty = true;
}
/// Delete one character left of the cursor in the input buffer.
pub(super) fn handle_delete_char(state: &mut AppStateRest) {
debug!("handle_delete_char");
state.input.delete_left();
state.dirty = true;
}
/// Delete one character right of the cursor in the input buffer.
pub(super) fn handle_delete_char_right(state: &mut AppStateRest) {
debug!("handle_delete_char_right");
state.input.delete_right();
state.dirty = true;
}
/// Move the cursor one position left.
pub(super) fn handle_cursor_left(state: &mut AppStateRest) {
debug!("handle_cursor_left");
state.input.char_left();
}
/// Move the cursor one position right.
pub(super) fn handle_cursor_right(state: &mut AppStateRest) {
debug!("handle_cursor_right");
state.input.char_right();
}
/// Navigate up through input history.
pub(super) fn handle_history_up(state: &mut AppStateRest) {
debug!("handle_history_up");
state.input.history_up();
state.dirty = true;
}
/// Navigate down through input history.
pub(super) fn handle_history_down(state: &mut AppStateRest) {
debug!("handle_history_down");
state.input.history_down();
state.dirty = true;
}
/// Scroll the transcript pane up by 5 lines.
pub(super) fn handle_scroll_up(state: &mut AppStateRest) {
debug!("handle_scroll_up");
state.scroll.scroll_up(5);
state.dirty = true;
}
/// Scroll the transcript pane down by 5 lines.
pub(super) fn handle_scroll_down(state: &mut AppStateRest) {
debug!("handle_scroll_down");
state.scroll.scroll_down(5);
state.dirty = true;
}
/// Open a named overlay — sets the overlay variant and resets selection index
/// for overlays that support list navigation (Learning, Rewind, ModelSelector).
pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
debug!("handle_open_overlay: {:?}", overlay);
state.misc.overlay = overlay;
// Reset selection index for list-based overlays
if overlay == Overlay::Learning
|| overlay == Overlay::Rewind
|| overlay == Overlay::ModelSelector
@@ -90,13 +132,20 @@ pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
state.dirty = true;
}
/// Open the inline file editor for `path`.
///
/// Flow: resolve the workspace-relative path → read file content →
/// construct `EditorState` → set overlay to `Editor`.
pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) {
debug!("handle_open_editor: {}", path);
// Resolve path relative to workspace roots
let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
match resolved {
Ok(abs_path) => {
let content = std::fs::read_to_string(&abs_path).unwrap_or_default();
let lines: Vec<String> =
content.lines().map(std::string::ToString::to_string).collect();
// Create the editor state from the file content lines
let ed = crate::app::mode::editor::EditorState::open(
abs_path.to_string_lossy().to_string(),
Some(lines),
@@ -115,13 +164,20 @@ pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) {
state.dirty = true;
}
/// Register a new MCP server by name and shell command.
///
/// Flow: parse command string into (cmd, args) → call `connect_stdio` on the
/// MCP manager → push success/error toast.
pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
debug!("handle_mcp_add: name={}, command={}", name, command);
// Split the command string into program + arguments
let extra_args: Vec<String> =
command.split_whitespace().map(std::string::ToString::to_string).collect();
let cmd = extra_args.first().cloned().unwrap_or_default();
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
let cmd = extra_args.first().cloned().unwrap_or_default(); // main executable
let args: Vec<String> = extra_args.into_iter().skip(1).collect(); // remaining args
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
Ok(()) => {
// Read back the tool count from the newly connected server
let tool_count = state
.mcp_manager
.servers
@@ -142,14 +198,22 @@ pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: St
}
}
/// Open the model-picker overlay and reset the selection index.
pub(super) fn handle_model_list(state: &mut AppStateRest) {
debug!("handle_model_list");
state.misc.selected_index = 0;
state.misc.overlay = Overlay::ModelSelector;
state.dirty = true;
}
/// Close the current overlay — dismisses the editor overlay specially if active.
///
/// Flow: if the active overlay is the Editor, call `handle_editor_dismiss` to
/// finalise edits before clearing the overlay; otherwise just reset to `None`.
/// Always marks `dirty` so the TUI re-renders without the overlay.
pub(super) fn handle_close_overlay(state: &mut AppStateRest) {
// If the overlay is the Editor, dismiss it properly first
debug!("handle_close_overlay");
// Dismiss the editor with save-confirm if it is currently open
if state.misc.overlay == Overlay::Editor {
crate::app::mode::editor::handle_editor_dismiss(state);
}
@@ -157,30 +221,42 @@ pub(super) fn handle_close_overlay(state: &mut AppStateRest) {
state.dirty = true;
}
/// Push an informational toast with the given message.
pub(super) fn handle_system_note(state: &mut AppStateRest, message: String) {
debug!("handle_system_note: {}", message);
let toast = Toast::new(ToastKind::Info, message);
state.push_toast(toast);
}
/// Show the quit-confirmation overlay.
pub(super) fn handle_quit_confirm(state: &mut AppStateRest) {
state.misc.overlay = Overlay::QuitConfirm;
state.dirty = true;
}
/// Handle terminal resize — update the scroll max-visible width.
pub(super) fn handle_resize(state: &mut AppStateRest, w: u16) {
debug!("handle_resize: width={}", w);
state.scroll.set_max_visible(w as usize);
state.dirty = true;
}
/// Start an OAuth device-code login flow on a background thread.
///
/// Flow: clone the turn-events queue → spawn thread → run `run_oauth_flow` →
/// push result as a `TurnEvent::SystemNote` back to the main loop.
pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
debug!("handle_start_oauth: provider={}", provider);
let turn_events = state.turn_events.clone();
let provider_clone = provider.clone();
// Run the blocking OAuth HTTP flow off the main thread
std::thread::spawn(move || {
let result = run_oauth_flow(&provider_clone);
let message = match result {
Ok(msg) => msg,
Err(e) => format!("OAuth login failed: {e}"),
};
// Push result back via the shared turn-events queue
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "oauth".to_string(),
@@ -196,7 +272,13 @@ pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
state.dirty = true;
}
/// Set the abort flag to signal the currently running LLM turn to stop.
///
/// Flow: atomically set `abort_flag` to `true` (checked by the streaming
/// task between tool calls) → push a warning toast to inform the user.
pub(super) fn handle_abort_turn(state: &mut AppStateRest) {
debug!("handle_abort_turn");
// Signal the streaming task to stop at the next safe point
state
.abort_flag
.store(true, std::sync::atomic::Ordering::SeqCst);
@@ -206,13 +288,22 @@ pub(super) fn handle_abort_turn(state: &mut AppStateRest) {
));
}
/// AI-summary compaction of the conversation history.
///
/// Flow: resolve max-wire-tokens → extract provider config (API key, model,
/// base URL) → build an `LlmClient` → delegate to `shape_messages` which
/// summarises older messages via the LLM → compute token diff → push toast.
///
/// Why: compaction preserves semantic context (goals, decisions, files, state)
/// instead of naively dropping messages, using the configured LLM to produce
/// a concise summary of what came before.
pub(super) fn handle_compact(state: &mut AppStateRest) {
debug!("handle_compact");
// Resolve the maximum allowed tokens from the wire window config
let max_wire_tokens = window::resolve(&state.app_config, &state.settings);
// Extract config before borrowing session_runtime mutably to avoid
// borrow conflicts. An LLM client is needed for summarization so the
// compacted result preserves meaningful context (goals, decisions,
// files, state) instead of a useless static placeholder.
// ── Extract config before borrowing session_runtime mutably ────────────
// These clones avoid borrow conflicts when we later take &mut rt below.
let api_key = state
.settings
.api_keys
@@ -227,7 +318,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
.map(|p| p.api_base.clone());
let abort_flag = state.abort_flag.clone();
// Build the LLM client if we have a configured base_url.
// ── Build the LLM client if a base_url is configured ───────────────────
let llm_client = base_url.map(|url| {
let key = if api_key.is_empty() {
state
@@ -262,8 +353,10 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
return;
}
// ── Run compaction, capturing before/after token counts ──────────────
let (before_tokens, after_tokens, msg_count) =
if let Some(ref mut rt) = state.session_runtime {
// Estimate total tokens before compaction
let token_estimate: usize = rt
.messages
.iter()
@@ -272,6 +365,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
.sum();
let before = token_estimate;
// Run the actual compaction via shaping (summarises old messages)
rt.messages = crate::app::runtime::context::shaping::shape_messages(
&rt.messages,
token_estimate,
@@ -280,6 +374,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
llm_client.as_ref(),
Some(&*abort_flag),
);
// Estimate tokens after compaction
let after: usize = rt
.messages
.iter()
@@ -307,7 +402,13 @@ pub(super) fn handle_compact(state: &mut AppStateRest) {
state.dirty = true;
}
/// Accept a pending lesson (learned behaviour pattern) by name.
///
/// Flow: resolve the pending lesson with `accepted = true` → refresh lesson
/// counters → push success toast.
pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
debug!("handle_lesson_accept: {}", name);
// Resolve the pending lesson file (writes accepted=true metadata)
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir,
@@ -316,6 +417,7 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
true,
);
}
// Re-read on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt);
}
@@ -326,7 +428,13 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
state.dirty = true;
}
/// Reject a pending lesson by name — resolves it with `accepted = false`.
///
/// Flow: resolve the pending lesson with `accepted = false` → refresh lesson
/// counters → push info toast.
pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
debug!("handle_lesson_reject: {}", name);
// Resolve the pending lesson file (writes accepted=false metadata)
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir,
@@ -335,6 +443,7 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
false,
);
}
// Re-read on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt);
}
@@ -345,8 +454,16 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
state.dirty = true;
}
/// Delete a previously stored lesson by name — removes the underlying
/// memory file and refreshes counters.
///
/// Flow: delete the memory markdown file via the CMS repository → refresh
/// lesson counters from the remaining on-disk state → push info toast.
pub(super) fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
debug!("handle_lesson_delete: {}", name);
// Remove the memory file from disk via the CMS repository
let _ = MarkdownMemoryRepository::new().delete(&state.memory_dir, &name);
// Re-read remaining on-disk state to update the dashboard counters
if let Some(ref mut rt) = state.session_runtime {
refresh_lesson_counters(&state.memory_dir, rt);
}
@@ -1,5 +1,10 @@
//! I/O helper functions: session persistence, API connectivity checks,
//! and review-available notification.
//! I/O helper functions used by action handlers: session persistence, API
//! connectivity checks, and review-available notification toasts.
//!
//! These are deliberately kept separate from `handlers.rs` to keep handler
//! bodies short and to allow these helpers to be called from multiple places.
use tracing::debug;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
@@ -8,12 +13,16 @@ use zesdex_iam::domain::repository::SessionRepository;
/// Persist the current session metadata and conversation to disk.
///
/// Flow: build a `Session` object → save its metadata → write
/// `rt.messages` as JSON to the conversation file → errors are silently
/// ignored.
/// Flow: build a `Session` object → save its metadata via
/// `FileSystemSessionRepository` → serialise `rt.messages` as JSON →
/// write to the conversation file. All errors are silently ignored so the
/// save is best-effort and non-blocking.
///
/// Why: called on `ForceQuit` so the session can be resumed later.
/// Why: called on `ForceQuit` so the session (including full message history)
/// can be resumed after a restart.
pub(super) fn save_current_session(state: &AppStateRest) {
debug!("save_current_session: session_id={}", state.session_id);
// Resolve the persistent store base directory (usually ~/.local/share/zesdex/)
let base = state.store_base_dir();
let session = zesdex_iam::domain::session::Session::new(
state.session_id.clone(),
@@ -21,8 +30,10 @@ pub(super) fn save_current_session(state: &AppStateRest) {
);
let session_repo =
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
// Save session metadata record (id, type, timestamps) to the repo directory
let _ = session_repo.save_session(&base, &session);
if let Some(ref rt) = state.session_runtime {
// Write the full message list as JSON to the conversation file
let conv_path = session.conversation_path(&base);
if let Ok(data) = serde_json::to_string(&rt.messages) {
let _ = std::fs::write(&conv_path, data);
@@ -40,9 +51,12 @@ pub(super) fn save_current_session(state: &AppStateRest) {
/// `should_trigger_review` on `Tick`), only informs the user that
/// a review has material to examine.
pub(super) fn maybe_trigger_review(state: &mut AppStateRest) {
debug!("maybe_trigger_review");
// Respect the user's review-disable toggle
if !state.settings.flags.review_enabled {
return;
}
// Only notify if there were actual edits this session
let edit_count = state
.session_runtime
.as_ref()
@@ -57,15 +71,18 @@ pub(super) fn maybe_trigger_review(state: &mut AppStateRest) {
}
/// Spawn a background thread that checks API reachability via a lightweight HEAD
/// request to `<base_url>/models`, pushing the result as a `SystemNote` so the
/// next `Tick` handler updates `api_connected`.
/// request to `<base_url>/chat/completions`, pushing the result as a `SystemNote`
/// so the next `Tick` handler updates `api_connected`.
///
/// Flow: resolve the provider's base URL → build a short-lived reqwest client
/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push
/// a `connectivity` `SystemNote` with the result.
/// Flow: resolve the provider's base URL → build a short-lived `reqwest` client
/// with 3 s connect / 5 s total timeout → HEAD the `/chat/completions` endpoint
/// → treat HTTP 200/401/403 as "connected", anything else as "disconnected" →
/// push a `connectivity` `SystemNote` with the boolean result.
///
/// Why: runs off the event loop so a slow/timed-out network does not block the TUI.
/// Why: runs off the event loop so a slow or timed-out network does not block the TUI.
pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
debug!("spawn_api_connectivity_check");
// Resolve the base URL from the configured provider, falling back to default
let base_url = state
.app_config
.providers
@@ -74,13 +91,17 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
|| crate::service::provider::DEFAULT_BASE_URL.to_string(),
|p| p.api_base.clone(),
);
// Clone the shared queue handle before moving into the background thread
let turn_events = state.turn_events.clone();
// Fire-and-forget: the blocking HTTP call runs on a background thread
// so a slow/timed-out network does not block the TUI event loop.
std::thread::spawn(move || {
// Build the health-check URL, removing any trailing slash from the base
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
let connected = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.connect_timeout(std::time::Duration::from_secs(3))
.timeout(std::time::Duration::from_secs(5)) // total request timeout
.connect_timeout(std::time::Duration::from_secs(3)) // TCP connect timeout
.build()
{
Ok(client) => match client.head(&url).send() {
@@ -89,10 +110,11 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) {
// 401/403 means the server is reachable (just auth is wrong)
s.is_success() || s.as_u16() == 401 || s.as_u16() == 403
}
Err(_) => false,
Err(_) => false, // Network error or timeout → disconnected
},
Err(_) => false,
Err(_) => false, // Client construction failed → disconnected
};
// Push result back via the shared turn-events queue for the next Tick
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "connectivity".to_string(),
@@ -1,5 +1,7 @@
//! Memory / lesson-counter helpers: refresh counters from on-disk data.
use tracing::debug;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
@@ -18,7 +20,12 @@ pub(super) fn refresh_lesson_counters(
memory_dir: &std::path::Path,
rt: &mut crate::app::state::runtime::SessionRuntime,
) {
debug!("refresh_lesson_counters: dir={:?}", memory_dir);
// Fetch all memory slugs from the directory listing
let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default();
// Reset all counters before recounting (avoid stale accumulation)
rt.lesson_count = 0;
rt.lessons_user = 0;
rt.lessons_feedback = 0;
@@ -27,22 +34,30 @@ pub(super) fn refresh_lesson_counters(
rt.lessons_active = 0;
rt.lessons_stale = 0;
rt.lessons_contradicted = 0;
// Iterate over every memory slug and classify it by kind + lifecycle
for name in &names {
if let Ok(mem) = MarkdownMemoryRepository::new().load(memory_dir, name) {
rt.lesson_count += 1;
// Classify by memory kind (user-defined, feedback, project, reference)
match mem.kind.as_str() {
"user" => rt.lessons_user += 1,
"feedback" => rt.lessons_feedback += 1,
"project" => rt.lessons_project += 1,
"reference" => rt.lessons_reference += 1,
_ => {}
_ => {} // Unknown kind — skip
}
// Classify by lifecycle stage (active, stale, contradicted)
match mem.lifecycle.as_str() {
"active" => rt.lessons_active += 1,
"stale" => rt.lessons_stale += 1,
"contradicted" => rt.lessons_contradicted += 1,
_ => {}
_ => {} // Unknown lifecycle — skip
}
}
// If the memory file was deleted between list() and load(),
// silently skip — no error noise needed.
}
}
@@ -2,7 +2,7 @@
//! chokepoint through which every key input, streaming event, and async
//! background-thread result mutates `AppStateRest`.
//!
//! Flow: controllers/subagent threads construct `Action` values → the event
//! Flow: controllers / subagent threads construct `Action` values → the event
//! loop calls `apply_action(&mut state, action)` → for turn-producing
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
@@ -15,6 +15,17 @@
//! need to know how to *produce* actions, not how to update state safely;
//! running turns on plain OS threads (rather than blocking the main loop)
//! keeps the TUI responsive while the LLM streams.
//!
//! Sub-modules:
//! - `handlers` — one handler function per `Action` variant (except `Tick`)
//! - `io` — I/O helpers (save transcript, trigger review) used by handlers
//! - `memory` — memory-file read/write operations
//! - `oauth` — OAuth device-code login flow
//! - `spawn` — spawning turns on background OS threads
//! - `tick` — the periodic `Tick` handler that drains `TurnEvent`s
//! - `turn` — the core agent-turn logic (LLM streaming, tool execution)
use tracing::debug;
mod handlers;
mod io;
@@ -27,7 +38,7 @@ mod turn;
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
/// A single, well-typed event in the app — produced by key input, the
/// A single well-typed event in the app — produced by key input, the
/// streaming pipeline, or subagent threads — that mutates `AppStateRest`
/// when applied via `apply_action`.
///
@@ -37,65 +48,101 @@ use crate::app::state::types::Overlay;
/// observable and cancellable from the UI.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay (Help, Settings, Mcp, Todo, Usage, etc.).
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event — carries the new column count.
Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s and runs side jobs.
Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept {
name: String,
},
/// Reject a lesson by name.
LessonReject {
name: String,
},
/// Delete a previously stored lesson by name.
LessonDelete {
name: String,
},
/// Start the OAuth device-code login flow for a named provider.
StartOAuth {
provider: String,
},
/// Open the inline file editor for `path`.
OpenEditor {
path: String,
},
/// Register a new MCP server by name and shell command.
McpAdd {
name: String,
command: String,
},
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
}
/// Apply an `Action` to the application state.
///
/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll
/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) →
/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs
/// (staleness sweep, pending-lesson commit).
/// Flow: pattern-match the variant → delegate to the corresponding handler
/// function in `handlers` (or `tick::handle_tick` for `Tick`) → handler
/// mutates `state` (input buffer, scroll position, overlay, transcript,
/// runtime, toasts, dirty flag, etc.).
///
/// For `Tick`: also drains queued `TurnEvent`s from the shared queue and
/// runs periodic side jobs (staleness sweep, pending-lesson commit).
///
/// Why: the single chokepoint that turns every typed key and async event
/// into a state change, so callers (controllers, subagent threads) only
/// need to know how to *produce* actions.
/// need to know how to *produce* actions, not how to update state safely.
///
/// Return: nothing; `state` is mutated in place.
pub fn apply_action(state: &mut AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => handlers::handle_force_quit(state),
Action::QuitConfirm => handlers::handle_quit_confirm(state),
Action::Resize(w, _h) => handlers::handle_resize(state, w),
Action::Tick => tick::handle_tick(state),
// ── Input / editing ───────────────────────────────────────────
Action::SubmitInput(text) => handlers::handle_submit_input(state, text),
Action::DeleteChar => handlers::handle_delete_char(state),
Action::DeleteCharRight => handlers::handle_delete_char_right(state),
@@ -103,23 +150,30 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
Action::CursorRight => handlers::handle_cursor_right(state),
Action::HistoryUp => handlers::handle_history_up(state),
Action::HistoryDown => handlers::handle_history_down(state),
// ── Scroll / navigation ───────────────────────────────────────
Action::ScrollUp => handlers::handle_scroll_up(state),
Action::ScrollDown => handlers::handle_scroll_down(state),
Action::OpenOverlay(overlay) => handlers::handle_open_overlay(state, overlay),
Action::CloseOverlay => handlers::handle_close_overlay(state),
Action::SystemNote { kind: _kind, message } => handlers::handle_system_note(state, message),
Action::QuitConfirm => handlers::handle_quit_confirm(state),
Action::Resize(w, _h) => handlers::handle_resize(state, w),
Action::OpenEditor { path } => handlers::handle_open_editor(state, path),
Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command),
// ── System / info ─────────────────────────────────────────────
Action::SystemNote { kind: _kind, message } => {
handlers::handle_system_note(state, message)
}
Action::ModelList => handlers::handle_model_list(state),
Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider),
Action::AbortTurn => handlers::handle_abort_turn(state),
Action::Compact => handlers::handle_compact(state),
// ── Editor / MCP / OAuth ──────────────────────────────────────
Action::OpenEditor { path } => handlers::handle_open_editor(state, path),
Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command),
Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider),
// ── Lessons ───────────────────────────────────────────────────
Action::LessonAccept { name } => handlers::handle_lesson_accept(state, name),
Action::LessonReject { name } => handlers::handle_lesson_reject(state, name),
Action::LessonDelete { name } => handlers::handle_lesson_delete(state, name),
Action::Tick => tick::handle_tick(state),
}
}
@@ -129,24 +183,35 @@ mod tests {
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::SessionRuntime;
use crate::app::state::runtime::TurnEvent;
use tracing::info;
/// Verify that a `TurnEvent::SystemNote` with `kind == "hive_mind_converged"`
/// sets the `hive_mind_converged` flag on the session runtime after `Tick`.
///
/// Flow: create a fresh state → push a `hive_mind_converged` `TurnEvent`
/// onto the shared queue → apply `Tick` → assert the flag is now `true`.
#[test]
fn hive_mind_converged_system_note_sets_session_flag() {
info!("test: hive_mind_converged_system_note_sets_session_flag");
let tmp = std::env::temp_dir().join(format!("zesdex-actions-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let mut state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"));
state.session_runtime = Some(SessionRuntime::new(tmp.clone()));
// Verify the flag starts as false
assert!(!state.session_runtime.as_ref().unwrap().hive_mind_converged);
// Push a hive_mind_converged system note onto the turn-event queue
if let Ok(mut q) = state.turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(),
message: String::new(),
});
}
// Tick drains the queue and processes the note
apply_action(&mut state, Action::Tick);
// Verify the flag is now set
assert!(state.session_runtime.as_ref().unwrap().hive_mind_converged);
std::fs::remove_dir_all(&tmp).ok();
@@ -1,5 +1,7 @@
//! OAuth PKCE flow — browser-based login for API providers.
use tracing::{info, warn};
/// Run a browser-based OAuth PKCE flow for the given provider.
///
/// Flow: look up config by provider name ("zen"/"opencode", "openai",
@@ -15,6 +17,7 @@
/// Return: a success message on completion, or an error if the flow fails
/// at any step.
pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
info!(provider = provider, "starting OAuth flow");
use zesdex_iam::domain::oauth::OAuthConfig;
use zesdex_iam::domain::service::OAuthService;
use zesdex_iam::application::oauth_service::OAuthServiceImpl;
@@ -86,20 +89,22 @@ pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
let (auth_url, state) = oauth_service.start_flow(&config, &redirect_uri)?;
if auth_url.is_empty() {
tracing::warn!("[oauth] auth_url was empty for provider '{}'", provider);
warn!("OAuth auth_url was empty for provider '{}'", provider);
} else if webbrowser::open(&auth_url).is_err() {
tracing::warn!(
"[oauth] could not open browser for '{}'; user must open URL manually:\n{}",
warn!(
"OAuth could not open browser for '{}'; user must open URL manually:\n{}",
provider,
auth_url
);
}
info!(provider = provider, "waiting for OAuth redirect");
let code = server.wait_for_code(120_000, &state)?;
oauth_service
.complete_flow(&config, &redirect_uri, &code, &state)
.map_err(|e| anyhow::anyhow!("{e}"))?;
info!(provider = provider, "OAuth flow completed");
Ok(format!("Successfully authenticated with {provider}."))
}
@@ -1,8 +1,16 @@
//! Turn-spawning logic: `spawn_turn` and the `TurnCtx` bundle passed to
//! Turn-spawning logic: `spawn_turn` and the [`TurnCtx`] bundle passed to
//! the background thread that runs `run_agent_turn`.
//!
//! Flow: `spawn_turn` collects messages, config, API key, and tools from
//! `AppStateRest` → builds a [`TurnCtx`] → spawns a plain OS thread that
//! calls `run_agent_turn` → drains errors into `TurnEvent::Error`.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use tracing::{error, info};
use super::turn::run_agent_turn;
@@ -41,6 +49,7 @@ pub(super) struct TurnCtx {
///
/// Return: nothing; results flow through `state.turn_events`.
pub(super) fn spawn_turn(state: &AppStateRest) {
info!("spawn_turn: starting new turn");
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard
} else {
@@ -110,14 +119,14 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
let in_flight_flag = state.turn_in_flight.clone();
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
let abort_flag = state.abort_flag.clone();
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
abort_flag.store(false, Ordering::SeqCst);
let hive_mind_converged = state
.session_runtime
.as_ref()
.is_some_and(|rt| rt.hive_mind_converged);
*in_flight_flag.lock().unwrap_or_else(|e| {
tracing::error!("[spawn_turn] in_flight_flag mutex poisoned: {}", e);
error!("spawn_turn: in_flight_flag mutex poisoned: {}", e);
e.into_inner()
}) = true;
@@ -126,7 +135,7 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
std::thread::spawn(move || {
let db = crate::model::msglog::open_or_create(&edit_session_dir)
.ok()
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
.map(|c| Arc::new(std::sync::Mutex::new(c)));
let tc = TurnCtx {
client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url),
tdefs: tool_defs,
@@ -145,10 +154,12 @@ pub(super) fn spawn_turn(state: &AppStateRest) {
};
let result = run_agent_turn(&tc, &messages, &events_q);
if let Err(e) = result {
info!("spawn_turn: turn returned error: {}", e);
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error(e.to_string()));
}
}
info!("spawn_turn: turn completed");
if let Ok(mut flag) = in_flight_flag.lock() {
*flag = false;
}
@@ -1,6 +1,14 @@
//! Tick-action handler: drain turn events, LSP provision messages,
//! API connectivity checks, staleness sweep, pending lessons, and
//! todo.md polling.
//!
//! Flow: `handle_tick` is called from the event loop on each cycle.
//! It drains the `turn_events` queue (driving the transcript cache and
//! session runtime), drains `lsp_provision_msgs` into toasts, runs
//! background maintenance (todo.md poll, API connectivity, staleness
//! sweep, lessons), and flags `state.dirty` when something changed.
use tracing::debug;
use crate::app::review::{should_trigger_review, trigger_review};
use crate::app::state::rest::AppStateRest;
@@ -14,11 +22,24 @@ use super::turn::HIVE_MIND_KICKOFF_NOTE;
/// Handle `Action::Tick` — the periodic event that drains async results
/// and runs background maintenance tasks.
///
/// Flow:
/// 1. Bump tick counter, drain expired toasts
/// 2. Every 10 ticks: poll `todo.md` for external changes
/// 3. Every N ticks: `spawn_api_connectivity_check` (N=20 when disconnected, 600 when connected)
/// 4. Run staleness sweep and process pending lessons
/// 5. Drain `lsp_provision_msgs` into toasts
/// 6. Drain `turn_events` queue, dispatching each variant to state mutation
/// 7. If turn finished, trigger optional review
pub(super) fn handle_tick(state: &mut AppStateRest) {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
let tick = state.misc.tick_count;
debug!(tick = tick, "handle_tick");
let now_ms = chrono::Utc::now().timestamp_millis();
// Remove expired toasts from the display stack.
state.misc.drain_expired_toasts(now_ms);
// Poll todo.md every 10 ticks (~1 s) for external edits.
if state.misc.tick_count.is_multiple_of(10) {
let todo_path = state.session_dir.join("todo.md");
if let Ok(content) = std::fs::read_to_string(&todo_path) {
@@ -35,6 +56,7 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
// Background API connectivity check — runs on a background thread
// every ~1s while disconnected, every ~30s while connected, so the
// status bar reflects real API availability without user input.
// Poll interval: every ~2 s when disconnected (20 ticks), ~60 s when connected (600 ticks).
let check_interval = if state.misc.api_connected { 600 } else { 20 };
if state.misc.tick_count.is_multiple_of(check_interval) {
spawn_api_connectivity_check(state);
@@ -68,6 +90,9 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
state.push_toast(Toast::new(kind, msg.clone()));
}
// Drain the background-thread turn events queue. Each variant maps to
// state mutations — transcript cache updates, session runtime messages,
// toast notifications, and workflow engine agent roster changes.
let events: Vec<TurnEvent> = {
if let Ok(mut q) = state.turn_events.lock() {
q.drain(..).collect()
@@ -370,9 +395,11 @@ pub(super) fn handle_tick(state: &mut AppStateRest) {
}
}
}
// If a turn just completed, trigger the optional inline review flow.
if turn_finished {
maybe_trigger_review(state);
}
// Ensure state is marked dirty if anything changed this cycle.
if turn_finished || state.dirty {
state.dirty = true;
}
@@ -314,6 +314,11 @@ pub(super) fn run_agent_turn(
let mut todo_retry_count = 0usize;
tracing::debug!(
"[turn] entering main agent loop — max todo retries: {}",
MAX_TODO_RETRIES,
);
loop {
let token_estimate: usize = msgs
.iter()
@@ -493,6 +498,9 @@ pub(super) fn run_agent_turn(
archive_message(tc.db.as_ref(), &tc.session_id, &response);
msgs.push(response);
let mut results_vec = Vec::new();
// Execute all tool calls in parallel using std::thread::scope,
// which guarantees all spawned threads complete before the
// closure returns — no manual join needed.
std::thread::scope(|s| {
let mut handles = Vec::new();
let tc_ref = tc;
@@ -725,6 +733,11 @@ pub(super) fn run_agent_turn(
}
}
tracing::debug!(
"[turn] agent turn completed — total edits this turn: {}",
total_edits_this_turn.as_ref().map_or(0, |(c, _, _)| *c),
);
push_event(&events_q, TurnEvent::Done);
Ok(())
@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Cross-call tool-result deduplication: when a read-only tool is called
//! again with identical arguments, the earlier result is replaced with a
//! placeholder so only the latest copy occupies context.
@@ -19,6 +18,7 @@ use crate::app::subagent::division::tool_scope::READ_TOOLS;
use crate::dto::chat::message::{ChatMessage, Role};
use sha2::Digest;
use std::collections::HashMap;
use tracing;
const DUPLICATE_PLACEHOLDER: &str =
"[duplicate result — superseded by a later identical call, see below]";
@@ -29,7 +29,15 @@ const DUPLICATE_PLACEHOLDER: &str =
/// `true` iff at least one entry was replaced. The caller uses the
/// `bool` to decide whether the result is worth persisting/announcing,
/// without `ChatMessage` needing to implement `PartialEq`.
///
/// # Status
///
/// This function is defined but not yet wired into the compaction loop;
/// it will be called from the per-turn auto-compaction pass once the
/// shaping integration is complete.
#[expect(dead_code, reason = "will be wired into the compaction loop")]
pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
tracing::debug!(n_messages = messages.len(), "dedup::collapse — start");
// tool_call_id -> (tool name, canonical JSON of its arguments)
let mut call_info: HashMap<String, (String, String)> = HashMap::new();
for m in messages {
@@ -84,6 +92,7 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
})
.collect();
tracing::debug!(changed, "dedup::collapse — done");
(result, changed)
}
@@ -101,6 +110,9 @@ fn dedup_key(tool_name: &str, canonical_args: &str) -> String {
#[cfg(test)]
mod tests {
//! Unit tests for tool-result dedup: identical read-tool calls are
//! collapsed, different args / mutating tools are left untouched,
//! and orphaned tool results pass through unchanged.
use super::*;
use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction};
@@ -2,6 +2,18 @@
//! per-result compression, budget-based shaping, and shared
//! context-window resolution — replaces `runtime::shortsend`.
//!
//! # Sub-modules
//!
//! | Module | Responsibility |
//! |------------|----------------------------------------------------------|
//! | `dedup` | Cross-call deduplication of repeated tool results |
//! | `shaping` | Budget-based message shaping within the context window |
//! | `squash` | Per-result compression (summarisation / truncation) |
//! | `tokens` | Token counting and estimation |
//! | `window` | Resolve the active model's context-window size |
//!
//! # Call-sites
//!
//! No facade function here: `dedup`, `shaping`, and `tokens` are called
//! directly from each call site (the per-turn auto-compaction loop in
//! `actions::run_agent_turn`, and `Action::Compact`), matching this
@@ -68,6 +68,10 @@ const FORCE_KEEP_MAX: usize = 15;
const SUMMARY_PREFIX: &str = "[Summary of compacted prior conversation:";
/// Detect whether a message contains a previous compaction summary.
///
/// Flow: check if message `content` starts with [`SUMMARY_PREFIX`].
/// Used to filter out old summaries from the "dropped" set so they
/// are handled by progressive summarization instead.
fn msg_has_prior_summary(m: &ChatMessage) -> bool {
m.content
.as_deref()
@@ -77,6 +81,13 @@ fn msg_has_prior_summary(m: &ChatMessage) -> bool {
/// Format dropped messages for the summarization prompt, excluding any
/// messages that are themselves previous summaries (those are handled
/// separately by progressive summarization).
///
/// Flow: filter out prior-summary messages → for each remaining message,
/// render a `[Role]: content` line with optional tool-call list appended.
/// Join entries with `\n\n---\n\n` as separator.
///
/// Return: a single string suitable as the `### New messages to merge`
/// section of the summarization prompt.
fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
dropped
.iter()
@@ -106,6 +117,13 @@ fn format_dropped_messages(dropped: &[ChatMessage]) -> String {
}
/// Extract the content of a previous compaction summary from a message.
///
/// Flow: check if `content` starts with [`SUMMARY_PREFIX`] → strip prefix
/// and trailing `]` → return inner text. Returns `None` if the message
/// is not a prior-summary message.
///
/// Why: progressive summarization needs the old summary text so the LLM
/// can merge it with new context instead of starting from scratch.
fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
let content = m.content.as_deref()?;
if content.starts_with(SUMMARY_PREFIX) {
@@ -124,6 +142,13 @@ fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
/// Build the summarization prompt, supporting progressive compaction:
/// if the dropped messages contain a previous summary, it is extracted
/// and the new prompt asks the LLM to build on it.
///
/// Flow: search dropped messages for a prior summary via `extract_prior_summary`.
/// If found, emit a "build on this" prompt with the previous summary + new
/// content. Otherwise emit a plain "summarize this history" prompt.
/// In both cases the prompt requests a structured 5-section summary.
///
/// Return: a fully-formed user-style prompt string ready to send to the LLM.
fn build_summarization_prompt(
dropped_msgs: &[ChatMessage],
dropped_content: &str,
@@ -174,6 +199,14 @@ fn build_summarization_prompt(
/// `[prior conversation compacted]` placeholder — it tells the LLM how
/// many messages of each role were dropped and what tools were used,
/// preserving key structural context.
///
/// Flow: count messages by role → collect unique tool names → extract
/// the last user message as a hint → format as:
/// `[prior conversation: N user, M assistant, ... | tools used: ... | last request: ...]`
///
/// Why: a static placeholder provides zero useful context. Even without
/// AI summarization, structural metadata helps the LLM understand what
/// was lost.
fn make_structural_summary(dropped: &[ChatMessage]) -> String {
use std::fmt::Write;
@@ -244,11 +277,22 @@ pub fn shape_messages(
client: Option<&crate::service::provider::LlmClient>,
abort_flag: Option<&AtomicBool>,
) -> Vec<ChatMessage> {
tracing::debug!(
n_messages = messages.len(),
token_count,
max_wire_tokens,
force,
has_client = client.is_some(),
"shape_messages — entry"
);
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
tracing::debug!("shape_messages — under budget or too few messages, no-op");
return messages.to_vec();
}
if force && messages.len() < 5 {
tracing::debug!("shape_messages — force but fewer than 5 messages, no-op");
return messages.to_vec();
}
@@ -352,10 +396,12 @@ pub fn shape_messages(
}
}
} else {
tracing::debug!("shape_messages — summarization aborted by user, using structural summary");
make_structural_summary(&dropped_msgs)
}
} else {
// No LLM client available (tests / edge case with no provider).
tracing::debug!("shape_messages — no LLM client, using structural summary");
make_structural_summary(&dropped_msgs)
};
@@ -363,11 +409,19 @@ pub fn shape_messages(
}
result.extend(keep_recent.into_iter().rev());
tracing::debug!(
result_len = result.len(),
dropped = dropped_msgs.len(),
"shape_messages — done"
);
result
}
#[cfg(test)]
mod tests {
//! Unit tests for message shaping: threshold hysteresis, system-message
//! preservation, structural-summary fallback, and most-recent survival.
use super::*;
use crate::dto::chat::message::ChatMessage;
@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Per-tool-result compression: shrink large tool outputs before they
//! ever enter conversation history, dispatching by content shape.
//!
@@ -12,6 +11,7 @@
//! overall budget.
use std::collections::HashSet;
use std::fmt::Write;
use tracing;
/// Below this size, compression isn't worth the risk of losing detail —
/// pass the output through unchanged.
@@ -46,16 +46,28 @@ const LOG_SHAPED_TOOLS: &[&str] = &["bash"];
/// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at
/// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from
/// whichever detector matches its content shape.
///
/// # Status
///
/// Defined but not yet wired into the tool-execution pipeline; will be
/// called from `tool::shell` and MCP result handlers once integration
/// is complete.
#[expect(dead_code, reason = "will be wired into the tool-execution pipeline")]
pub fn apply(tool_name: &str, output: &str) -> String {
tracing::trace!(tool_name, output_len = output.len(), "squash::apply — start");
if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES {
tracing::trace!(tool_name, "squash::apply — passthrough (never-squash tool or under floor)");
return output.to_string();
}
if serde_json::from_str::<serde_json::Value>(output).is_ok() {
tracing::trace!(tool_name, "squash::apply — routing to squash_json");
return squash_json(output);
}
if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(output) {
tracing::trace!(tool_name, "squash::apply — routing to squash_log");
return squash_log(output);
}
tracing::trace!(tool_name, "squash::apply — routing to squash_generic");
squash_generic(output, GENERIC_BUDGET_BYTES)
}
@@ -287,6 +299,16 @@ fn squash_generic(text: &str, budget: usize) -> String {
/// Render a subset of `lines` in order, inserting a `[N lines omitted]`
/// marker at every gap between kept lines.
///
/// Flow: sort kept indices → iterate; for each kept line, if a gap
/// exists before it write `[N lines omitted]`, then write the line.
/// After all kept lines, write a final omission marker if lines remain.
///
/// Why `[N lines omitted]` instead of a comment-shaped marker: the
/// `rtk` project's own regression tests found that comment shapes get
/// parsed by the LLM as code and trigger a retry loop.
///
/// Return: rendered string with kept lines in original order.
fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
kept_sorted.sort_unstable();
@@ -309,6 +331,9 @@ fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
#[cfg(test)]
mod tests {
//! Unit tests for tool-result squashing: floor threshold, read-tool
//! exemption, JSON structure preservation, log compression, and
//! generic truncation with head/tail retention.
use super::*;
#[test]
@@ -11,6 +11,7 @@
//! closer than a flat byte-per-token guess; it's only used for the
//! 85%/95% budget thresholds, not for billing-accurate counts.
use tracing;
/// Count tokens in a single string under `o200k_base`.
///
@@ -20,17 +21,23 @@
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
/// as ordinary text, not interpreted as a control token.
pub fn count_tokens(text: &str) -> usize {
tiktoken_rs::o200k_base_singleton()
let count = tiktoken_rs::o200k_base_singleton()
.encode_ordinary(text)
.len()
.len();
tracing::trace!(len = text.len(), count, "count_tokens");
count
}
#[cfg(test)]
mod tests {
//! Unit tests for token counting: empty strings, known phrases, code,
//! and ChatMessage content extraction.
use super::*;
use crate::dto::chat::message::ChatMessage;
/// Count tokens in a `ChatMessage`'s text content.
///
/// Returns 0 when the message has no content (None).
fn count_message_tokens(msg: &ChatMessage) -> usize {
msg.content.as_deref().map_or(0, count_tokens)
}
@@ -1,10 +1,10 @@
#![allow(dead_code)]
//! Single source of truth for resolving the active model's context
//! window size, replacing three copies of the same lookup that had
//! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each
//! had their own inline version — the status bar's copy additionally
//! displayed "?" on no match instead of falling back like the other two,
//! an inconsistency this unifies away).
use tracing::debug;
use zesdex_cms::domain::app_config::AppConfig;
use zesdex_cms::domain::settings::Settings;
@@ -15,14 +15,31 @@ use zesdex_cms::domain::settings::Settings;
/// `settings` -> use its `context_window` if set -> otherwise fall back
/// to `app_config.default_context_window`.
///
/// # Tracing
/// Outputs a `tracing::debug!` event with the resolved token count and
/// matching role name (or "fallback") at each call site.
///
/// Return: always a concrete token count, never "unknown".
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
app_config
.model_roles
.values()
.find(|role| role.provider == settings.provider && role.model == settings.model)
// Search model roles for one matching the active provider + model pair
let matched = app_config.model_roles.values().find(|role| {
role.provider == settings.provider && role.model == settings.model
});
// Use the role's explicit context_window, or fall back to the default
let tokens: usize = matched
.and_then(|role| role.context_window)
.unwrap_or(app_config.default_context_window) as usize
.unwrap_or(app_config.default_context_window) as usize;
debug!(
provider = %settings.provider,
model = %settings.model,
tokens,
source = if matched.is_some() { "model_role" } else { "default_fallback" },
"resolved context-window size",
);
tokens
}
#[cfg(test)]
@@ -1,9 +1,15 @@
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
//! after any activity, then slows down to conserve CPU.
//!
//! Flow: `mark_active()` sets a fast-poll deadline; `poll_interval()`
//! checks if the deadline is still in the future and returns either
//! `FAST_POLL_MS` (8 ms) or `SLOW_POLL_MS` (100 ms). `is_idle()` reports
//! whether the deadline has expired.
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::app::state::runtime::TurnEvent;
use tracing;
const FAST_POLL_MS: u64 = 8;
const SLOW_POLL_MS: u64 = 100;
@@ -40,9 +46,17 @@ impl EventLoop {
/// Mark the current time as the last activity and arm the fast-poll
/// window for the next `IDLE_THRESHOLD_MS`.
///
/// Called by the event loop whenever a TurnEvent arrives, keeping the
/// UI responsive during bursts of activity.
pub fn mark_active(&mut self) {
self.last_activity = Instant::now();
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS));
let deadline = Duration::from_millis(IDLE_THRESHOLD_MS);
self.fast_poll_until = Some(Instant::now() + deadline);
tracing::debug!(
"[event-loop] marked active — fast-poll armed for next {}ms",
IDLE_THRESHOLD_MS,
);
}
/// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`.
@@ -52,11 +66,24 @@ impl EventLoop {
/// Drain all pending `TurnEvent`s from the shared mutex queue.
///
/// Flow: acquire the mutex lock → drain the VecDeque into a Vec → release.
/// Returns an empty Vec if the lock is poisoned.
///
/// Return: a `Vec` of all events that were in the queue (may be empty).
pub fn drain_events(
events: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> Vec<TurnEvent> {
events.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
let drained: Vec<TurnEvent> = events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
if !drained.is_empty() {
tracing::debug!(
"[event-loop] drained {} event(s)",
drained.len(),
);
}
drained
}
}
+6 -3
View File
@@ -3,6 +3,7 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tracing;
use super::state::runtime::TurnEvent;
@@ -17,10 +18,12 @@ pub mod stream;
/// errors inline. Used by the 20+ locations in `actions/turn.rs` that push
/// events and want to skip the boilerplate.
pub fn push_event(
q: &Arc<Mutex<VecDeque<TurnEvent>>>,
event: TurnEvent,
q: &Arc<Mutex<VecDeque<TurnEvent>>>, // shared turn-event queue (locked on access)
event: TurnEvent, // event to enqueue
) {
tracing::debug!("pushing turn event");
// Silently ignores a poisoned mutex so callers never have to handle lock errors
if let Ok(mut guard) = q.lock() {
guard.push_back(event);
guard.push_back(event); // enqueue at the back for FIFO processing
}
}

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