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