ci: add GitHub Actions workflows with semantic-release auto-versioning

chore: fix all 702 clippy warnings across codebase
- auto-fix 475 via cargo clippy --fix
- fix remaining 227 manually: uninlined_format_args, redundant_closure, match_same_arms,
  underscore_binding, format_push_string, items_after_statements, needless_pass_by_value,
  clone_on_copy, case_sensitive_extension, single_match/let-else, write_with_newline,
  and other clippy lints
This commit is contained in:
asepharyana
2026-07-13 08:12:12 +07:00
parent be921d6836
commit 29a9fae3f6
79 changed files with 826 additions and 904 deletions
+5
View File
@@ -18,9 +18,14 @@ jobs:
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy
- name: Build - name: Build
run: cargo build --release run: cargo build --release
- name: Test - name: Test
run: cargo test run: cargo test
- name: Clippy
run: cargo clippy -- -D warnings
+3 -2
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Global registry of running background bash jobs, and control operations //! Global registry of running background bash jobs, and control operations
//! (output polling, kill) exposed to the rest of the app. //! (output polling, kill) exposed to the rest of the app.
//! //!
@@ -57,7 +58,7 @@ 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<()> {
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?; let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
let job = map.remove(id); let job = map.remove(id);
match job { match job {
Some(job) => { Some(job) => {
@@ -70,6 +71,6 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> {
} }
Ok(()) Ok(())
} }
None => anyhow::bail!("bash job '{}' not found", id), None => anyhow::bail!("bash job '{id}' not found"),
} }
} }
+12 -12
View File
@@ -17,7 +17,7 @@ use std::io::BufRead;
/// 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).
/// 10_000 lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most /// `10_000` lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
/// command output. The stderr drain thread also uses the same limit. /// command output. The stderr drain thread also uses the same limit.
const MAX_OUTPUT_LINES: usize = 10_000; const MAX_OUTPUT_LINES: usize = 10_000;
@@ -52,7 +52,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES); let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES);
let (pid_tx, pid_rx) = mpsc::channel::<u32>(); let (pid_tx, pid_rx) = mpsc::channel::<u32>();
let cmd = command.clone(); let cmd = command;
let id_for_log = id.clone(); let id_for_log = id.clone();
let thread_id = id.clone(); let thread_id = id.clone();
@@ -66,12 +66,12 @@ pub fn spawn_bash_job(command: String) -> BashJob {
let output_tx = output_tx.clone(); let output_tx = output_tx.clone();
let pid_tx = pid_tx.clone(); let pid_tx = pid_tx.clone();
let id_for_log = id_for_log.clone(); let id_for_log = id_for_log.clone();
move || spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log) move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
}).is_err() }).is_err()
{ {
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log); tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
thread::spawn(move || { thread::spawn(move || {
spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log) spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
}); });
} }
@@ -89,21 +89,21 @@ pub fn spawn_bash_job(command: String) -> BashJob {
/// spawned from both the named Builder and the unnamed fallback without /// spawned from both the named Builder and the unnamed fallback without
/// double-moving the closure. /// double-moving the closure.
fn spawn_bash_thread_body( fn spawn_bash_thread_body(
cmd: String, cmd: &str,
output_tx: std::sync::mpsc::SyncSender<String>, output_tx: &std::sync::mpsc::SyncSender<String>,
pid_tx: std::sync::mpsc::Sender<u32>, pid_tx: &std::sync::mpsc::Sender<u32>,
id_for_log: String, id_for_log: &str,
) { ) {
let mut child = match Command::new("sh") let mut child = match Command::new("sh")
.arg("-c") .arg("-c")
.arg(&cmd) .arg(cmd)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
let _ = output_tx.try_send(format!("__error:{}", e)); let _ = output_tx.try_send(format!("__error:{e}"));
let _ = output_tx.try_send("__exit:-1".to_string()); let _ = output_tx.try_send("__exit:-1".to_string());
return; return;
} }
@@ -124,7 +124,7 @@ fn spawn_bash_thread_body(
std::thread::spawn(move || { std::thread::spawn(move || {
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"); tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
break; break;
} }
@@ -153,7 +153,7 @@ fn spawn_bash_thread_body(
impl BashJob { impl BashJob {
/// Non-blocking poll for the next output line from the job's channel. /// Non-blocking poll for the next output line from the job's channel.
/// ///
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel, /// Flow: `try_recv` the channel → if it's an `__exit:<code>` sentinel,
/// record `exit_code` and return `None` instead of surfacing it as /// record `exit_code` and return `None` instead of surfacing it as
/// output → otherwise return the line. /// output → otherwise return the line.
/// ///
+3 -3
View File
@@ -122,6 +122,7 @@ impl Harness {
/// as risky because their behaviour is unknown. /// as risky because their behaviour is unknown.
/// ///
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`. /// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
#[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)]
pub fn gate_tool_call( pub fn gate_tool_call(
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
@@ -163,8 +164,7 @@ impl Harness {
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r)); let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
if !allowed { if !allowed {
return Verdict::Block(format!( return Verdict::Block(format!(
"output path '{:?}' is outside all workspace roots", "output path '{out_path:?}' is outside all workspace roots"
out_path
)); ));
} }
} }
@@ -286,7 +286,7 @@ impl Harness {
(>= {MIN_REASON_LEN} chars) explaining why it is needed" (>= {MIN_REASON_LEN} chars) explaining why it is needed"
)); ));
} }
} else if args.as_object().map(|m| !m.is_empty()).unwrap_or(false) { } else if args.as_object().is_some_and(|m| !m.is_empty()) {
// Only require reason when there are meaningful arguments // Only require reason when there are meaningful arguments
return Verdict::Block(format!( return Verdict::Block(format!(
"MCP tool '{tool_name}' requires a 'reason' argument \ "MCP tool '{tool_name}' requires a 'reason' argument \
+37 -39
View File
@@ -30,12 +30,12 @@ fn file_path_to_uri(path: &str) -> String {
if cfg!(windows) { if cfg!(windows) {
let path_str = path_str.replace('\\', "/"); let path_str = path_str.replace('\\', "/");
if path_str.starts_with('/') { if path_str.starts_with('/') {
format!("file://{}", path_str) format!("file://{path_str}")
} else { } else {
format!("file:///{}", path_str) format!("file:///{path_str}")
} }
} else { } else {
format!("file://{}", path_str) format!("file://{path_str}")
} }
} }
@@ -48,7 +48,7 @@ impl LspClient {
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
let mut child = cmd.spawn() let mut child = cmd.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{}': {}", command, e))?; .map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
let stdin = child.stdin.take() let stdin = child.stdin.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?; .ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
@@ -106,10 +106,10 @@ impl LspClient {
} }
}); });
let result = client.call_with_timeout("initialize", init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?; let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
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!({}))?;
Ok(client) Ok(client)
} }
@@ -118,11 +118,11 @@ impl LspClient {
&self.server_capabilities &self.server_capabilities
} }
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))
} }
fn call_with_timeout(&mut self, method: &str, params: Value, timeout: Duration) -> anyhow::Result<Value> { fn call_with_timeout(&mut self, method: &str, params: &Value, timeout: Duration) -> anyhow::Result<Value> {
self.next_id += 1; self.next_id += 1;
let id = self.next_id; let id = self.next_id;
let req = json!({ let req = json!({
@@ -135,7 +135,7 @@ impl LspClient {
self.read_response(id, timeout) self.read_response(id, timeout)
} }
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,
@@ -146,14 +146,14 @@ impl LspClient {
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}"))?;
let header = format!("Content-Length: {}\r\n\r\n", body.len()); let header = format!("Content-Length: {}\r\n\r\n", body.len());
self.stdin.write_all(header.as_bytes()) self.stdin.write_all(header.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
self.stdin.write_all(body.as_bytes()) self.stdin.write_all(body.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
self.stdin.flush() self.stdin.flush()
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {}", e))?; .map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
Ok(()) Ok(())
} }
@@ -166,9 +166,9 @@ impl LspClient {
let frame = self.read_frame()?; let frame = self.read_frame()?;
if frame.get("id") == Some(&json!(expected_id)) { if frame.get("id") == Some(&json!(expected_id)) {
if let Some(err) = frame.get("error") { if let Some(err) = frame.get("error") {
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0); let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
anyhow::bail!("LSP error {}: {}", code, msg); anyhow::bail!("LSP error {code}: {msg}");
} }
return Ok(frame.get("result").cloned().unwrap_or(Value::Null)); return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
} }
@@ -179,7 +179,7 @@ impl LspClient {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if Instant::now() > deadline { if Instant::now() > deadline {
anyhow::bail!("timed out waiting for LSP notification '{}'", method); anyhow::bail!("timed out waiting for LSP notification '{method}'");
} }
let frame = self.read_frame()?; let frame = self.read_frame()?;
if frame.get("method") == Some(&json!(method)) { if frame.get("method") == Some(&json!(method)) {
@@ -195,22 +195,21 @@ impl LspClient {
match self.stdout.read_line(&mut line) { match self.stdout.read_line(&mut line) {
Ok(0) => anyhow::bail!("LSP server closed the connection"), Ok(0) => anyhow::bail!("LSP server closed the connection"),
Ok(_) => {} Ok(_) => {}
Err(e) => anyhow::bail!("LSP read error: {}", e), Err(e) => anyhow::bail!("LSP read error: {e}"),
} }
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
break; break;
} }
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") { if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
let length: usize = len_str.trim().parse::<usize>()
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
// Cap Content-Length at 64 MiB to prevent OOM from a // Cap Content-Length at 64 MiB to prevent OOM from a
// malicious or misconfigured LSP server (CWE-400). // malicious or misconfigured LSP server (CWE-400).
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024; const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
let length: usize = len_str.trim().parse::<usize>()
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
if length > MAX_CONTENT_LENGTH { if length > MAX_CONTENT_LENGTH {
anyhow::bail!( anyhow::bail!(
"Content-Length {} exceeds maximum allowed size of {} bytes", "Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
length, MAX_CONTENT_LENGTH,
); );
} }
content_length = Some(length); content_length = Some(length);
@@ -222,17 +221,17 @@ impl LspClient {
let mut body = vec![0u8; length]; let mut body = vec![0u8; length];
self.stdout.read_exact(&mut body) self.stdout.read_exact(&mut body)
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({} bytes): {}", length, e))?; .map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
let json_str = String::from_utf8(body) let json_str = String::from_utf8(body)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {e}"))?;
serde_json::from_str(&json_str) serde_json::from_str(&json_str)
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {}", e)) .map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
} }
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> { pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
self.notify("textDocument/didOpen", json!({ self.notify("textDocument/didOpen", &json!({
"textDocument": { "textDocument": {
"uri": uri, "uri": uri,
"languageId": language_id, "languageId": language_id,
@@ -244,7 +243,7 @@ impl LspClient {
#[allow(dead_code)] #[allow(dead_code)]
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<()> {
self.notify("textDocument/didChange", json!({ self.notify("textDocument/didChange", &json!({
"textDocument": { "textDocument": {
"uri": uri, "uri": uri,
"version": version "version": version
@@ -256,7 +255,7 @@ impl LspClient {
} }
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> { pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
self.notify("textDocument/didClose", json!({ self.notify("textDocument/didClose", &json!({
"textDocument": { "textDocument": {
"uri": uri "uri": uri
} }
@@ -264,28 +263,28 @@ impl LspClient {
} }
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("textDocument/hover", json!({ self.call("textDocument/hover", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character } "position": { "line": line, "character": character }
})) }))
} }
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("textDocument/completion", json!({ self.call("textDocument/completion", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character } "position": { "line": line, "character": character }
})) }))
} }
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> { pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call("textDocument/definition", json!({ self.call("textDocument/definition", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character } "position": { "line": line, "character": character }
})) }))
} }
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("textDocument/references", json!({ self.call("textDocument/references", &json!({
"textDocument": { "uri": uri }, "textDocument": { "uri": uri },
"position": { "line": line, "character": character }, "position": { "line": line, "character": character },
"context": { "context": {
@@ -296,7 +295,7 @@ impl LspClient {
#[allow(dead_code)] #[allow(dead_code)]
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> { pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
self.call("textDocument/documentSymbol", json!({ self.call("textDocument/documentSymbol", &json!({
"textDocument": { "uri": uri } "textDocument": { "uri": uri }
})) }))
} }
@@ -327,7 +326,7 @@ impl LspClient {
/// still proves the process is up and the JSON-RPC channel is live. /// still proves the process is up and the JSON-RPC channel is live.
/// Returns `false` on timeout, EOF, or any read/write error. /// Returns `false` on timeout, EOF, or any read/write error.
/// ///
/// Flow: build request → send_frame → poll frames until id matches /// Flow: build request → `send_frame` → poll frames until id matches
/// (alive) or deadline/read error fires (dead). /// (alive) or deadline/read error fires (dead).
#[allow(dead_code)] #[allow(dead_code)]
pub fn is_alive(&mut self) -> bool { pub fn is_alive(&mut self) -> bool {
@@ -369,19 +368,18 @@ impl LspClient {
/// not block on any reply. /// not block on any reply.
#[allow(dead_code)] #[allow(dead_code)]
pub fn exit(&mut self) -> anyhow::Result<()> { pub fn exit(&mut self) -> anyhow::Result<()> {
self.notify("exit", json!({})) self.notify("exit", &json!({}))
} }
pub fn shutdown(&mut self) -> anyhow::Result<()> { pub fn shutdown(&mut self) {
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!({}));
Ok(())
} }
} }
impl Drop for LspClient { impl Drop for LspClient {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.notify("exit", json!({})); let _ = self.notify("exit", &json!({}));
} }
} }
+18 -28
View File
@@ -70,7 +70,7 @@ impl LspManager {
language_id: &str, language_id: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if self.servers.iter().any(|s| s.name == name) { if self.servers.iter().any(|s| s.name == name) {
anyhow::bail!("LSP server '{}' is already connected", name); anyhow::bail!("LSP server '{name}' is already connected");
} }
let client = LspClient::spawn(command, args)?; let client = LspClient::spawn(command, args)?;
self.servers.push(LspServer { self.servers.push(LspServer {
@@ -101,7 +101,7 @@ impl LspManager {
pub fn disconnect(&mut self, name: &str) -> bool { pub fn disconnect(&mut self, name: &str) -> bool {
if let Some(server) = self.servers.iter().find(|s| s.name == name) { if let Some(server) = self.servers.iter().find(|s| s.name == name) {
if let Ok(mut client) = server.client.lock() { if let Ok(mut client) = server.client.lock() {
let _ = client.shutdown(); client.shutdown();
} }
} }
let len = self.servers.len(); let len = self.servers.len();
@@ -134,7 +134,7 @@ impl LspManager {
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> { pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
path.extension() path.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.map(|s| format!(".{}", s)) .map(|s| format!(".{s}"))
.and_then(|ext| self.find_server_for_extension(&ext)) .and_then(|ext| self.find_server_for_extension(&ext))
} }
@@ -171,21 +171,15 @@ impl LspManager {
/// 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 `tracing::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) -> anyhow::Result<()> { pub fn did_change_file(&mut self, path: &Path) {
let ext = match path.extension().and_then(|e| e.to_str()).map(|s| format!(".{}", s)) { let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
Some(ext) => ext, tracing::warn!("did_change_file: path has no extension: {:?}", path);
None => { return;
tracing::warn!("did_change_file: path has no extension: {:?}", path);
return Ok(());
}
}; };
let server_name = match self.extension_registry.get(&ext) { let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
Some(name) => name.clone(), tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
None => { return;
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
return Ok(());
}
}; };
let uri = path_to_lsp_uri(&path.to_string_lossy()); let uri = path_to_lsp_uri(&path.to_string_lossy());
@@ -194,7 +188,7 @@ impl LspManager {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e); tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
return Ok(()); return;
} }
}; };
@@ -202,12 +196,9 @@ impl LspManager {
.get_language_id(&server_name) .get_language_id(&server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client = match self.get_client(&server_name) { let Some(client) = self.get_client(&server_name) else {
Some(c) => c, tracing::warn!("did_change_file: server '{}' has no client", server_name);
None => { return;
tracing::warn!("did_change_file: server '{}' has no client", server_name);
return Ok(());
}
}; };
let next_version = match self.open_files.get(&uri) { let next_version = match self.open_files.get(&uri) {
@@ -220,7 +211,7 @@ impl LspManager {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e); tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
return Ok(()); return;
} }
}; };
if self.open_files.contains_key(&uri) { if self.open_files.contains_key(&uri) {
@@ -237,7 +228,7 @@ impl LspManager {
uri, uri,
e e
); );
return Ok(()); return;
} }
self.open_files.insert( self.open_files.insert(
@@ -248,7 +239,6 @@ impl LspManager {
}, },
); );
Ok(())
} }
/// Record that `server_name` has an open document at `uri`. /// Record that `server_name` has an open document at `uri`.
@@ -274,9 +264,9 @@ 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) {
for server in self.servers.iter() { for server in &self.servers {
if let Ok(mut client) = server.client.lock() { if let Ok(mut client) = server.client.lock() {
let _ = client.shutdown(); client.shutdown();
} }
} }
self.servers.clear(); self.servers.clear();
+46 -51
View File
@@ -1,10 +1,10 @@
//! 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: `detect_env()` → for each supported server in `supported_servers()`
//! → provision_single() tries install tiers in order → returns //! → `provision_single()` tries install tiers in order → returns
//! ProvisionResult (AlreadyAvailable / Installed / Failed). //! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call auto_connect() to attach available servers //! Caller can then call `auto_connect()` to attach available servers
//! to an existing LspManager. //! to an existing `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.
@@ -28,7 +28,7 @@ pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
/// Result of attempting to make a single language server available. /// Result of attempting to make a single language server available.
/// ///
/// The caller should switch on this variant: AlreadyAvailable and /// The caller should switch on this variant: `AlreadyAvailable` and
/// Installed both mean the binary can be launched; Failed means we /// Installed both mean the binary can be launched; Failed means we
/// gave up and the user needs to install manually (see `manual_instructions`). /// gave up and the user needs to install manually (see `manual_instructions`).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -99,12 +99,13 @@ pub struct InstallTier {
/// Snapshot of the host environment used to decide which install tiers are viable. /// Snapshot of the host environment used to decide which install tiers are viable.
/// ///
/// Populated by `detect_env()` once per provision_all() call so we /// Populated by `detect_env()` once per `provision_all()` call so we
/// don't re-shell out for every server. `is_linux` / `is_macos` are /// don't re-shell out for every server. `is_linux` / `is_macos` are
/// computed at startup (compile time would also work, but keeping the /// computed at startup (compile time would also work, but keeping the
/// shape uniform with the rest of the struct makes the call sites tidy). /// shape uniform with the rest of the struct makes the call sites tidy).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
#[allow(clippy::struct_excessive_bools)]
pub struct EnvInfo { pub struct EnvInfo {
pub has_rustup: bool, pub has_rustup: bool,
pub has_npm: bool, pub has_npm: bool,
@@ -126,7 +127,7 @@ pub struct EnvInfo {
/// ///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix /// Flow: `Command::new("which").arg(binary).output()` → on Unix
/// `which` returns exit 0 + stdout path when found, non-zero /// `which` returns exit 0 + stdout path when found, non-zero
/// otherwise. We return the first stdout line as the PathBuf. /// otherwise. We return the first stdout line as the `PathBuf`.
/// ///
/// Returns None if `which` itself is missing, fails to spawn, or the /// 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 /// binary is not on PATH. We deliberately don't cache this — it's only
@@ -151,7 +152,7 @@ pub fn which(binary: &str) -> Option<PathBuf> {
/// ///
/// Flow: shell out to `which` for each tool in parallel (sequentially, /// Flow: shell out to `which` for each tool in parallel (sequentially,
/// actually — the calls are fast and the ordering doesn't matter) /// actually — the calls are fast and the ordering doesn't matter)
/// → set EnvInfo flags. Linux/macOS are detected via cfg at /// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
/// compile time since `which` won't tell us. /// compile time since `which` won't tell us.
/// ///
/// 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
@@ -185,6 +186,7 @@ pub fn detect_env() -> EnvInfo {
/// Why hard-coded rather than loaded from settings: the set is small, /// Why hard-coded rather than loaded from settings: the set is small,
/// changes rarely, and bundling it lets the provisioner run before any /// changes rarely, and bundling it lets the provisioner run before any
/// user config has been read (e.g. on first launch). /// user config has been read (e.g. on first launch).
#[allow(clippy::too_many_lines)]
pub fn supported_servers() -> Vec<LanguageServerDef> { pub fn supported_servers() -> Vec<LanguageServerDef> {
vec![ vec![
LanguageServerDef { LanguageServerDef {
@@ -307,7 +309,7 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
/// commands tend to emit errors to stderr, and we want to surface /// commands tend to emit errors to stderr, and we want to surface
/// those. /// those.
/// ///
/// 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)> {
let mut command = Command::new(cmd); let mut command = Command::new(cmd);
@@ -334,23 +336,19 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
}) })
}); });
let timeout = Duration::from_secs(180); let timeout = Duration::from_mins(3);
let start = Instant::now(); let start = Instant::now();
let status = loop { let status = loop {
match child.try_wait()? { if let Some(status) = child.try_wait()? { break Ok(status) }
Some(status) => break Ok(status), if start.elapsed() > timeout {
None => { let _ = child.kill();
if start.elapsed() > timeout { let _ = child.wait();
let _ = child.kill(); break Err(std::io::Error::new(
let _ = child.wait(); std::io::ErrorKind::TimedOut,
break Err(std::io::Error::new( format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
std::io::ErrorKind::TimedOut, ));
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
));
}
std::thread::sleep(Duration::from_millis(50));
}
} }
std::thread::sleep(Duration::from_millis(50));
}; };
let stdout = stdout_thread let stdout = stdout_thread
@@ -362,7 +360,7 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
match status { match status {
Ok(s) if s.success() => Ok((true, stdout)), Ok(s) if s.success() => Ok((true, stdout)),
Ok(_) => Ok((false, format!("{}{}", stdout, stderr))), Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
@@ -412,7 +410,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
"-o", &path_str, "-o", &path_str,
url, url,
]; ];
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {}", e))?; let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
if !ok { if !ok {
return Err(format!("download failed: {}", out.trim())); return Err(format!("download failed: {}", out.trim()));
} }
@@ -423,7 +421,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. /// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> { fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
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}"))?;
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"
@@ -440,7 +438,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
download_url(url, &gz, 120)?; download_url(url, &gz, 120)?;
if let Some(cb) = progress { cb("Rust: decompressing..."); } if let Some(cb) = progress { cb("Rust: decompressing..."); }
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()]) let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
.map_err(|e| format!("gunzip spawn: {}", e))?; .map_err(|e| format!("gunzip spawn: {e}"))?;
if !ok { if !ok {
return Err(format!("gunzip: {}", out.trim())); return Err(format!("gunzip: {}", out.trim()));
} }
@@ -452,7 +450,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod: {}", e))?; .map_err(|e| format!("chmod: {e}"))?;
} }
if let Some(cb) = progress { cb("Rust: installed ✓"); } if let Some(cb) = progress { cb("Rust: installed ✓"); }
Ok(target) Ok(target)
@@ -462,7 +460,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
/// and create a launcher script at `bin/jdtls`. /// and create a launcher script at `bin/jdtls`.
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");
@@ -473,7 +471,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let (ok, out) = run_command("tar", &[ let (ok, out) = run_command("tar", &[
"-xzf", tarball.to_str().unwrap_or(""), "-xzf", tarball.to_str().unwrap_or(""),
"-C", base.to_str().unwrap_or("."), "-C", base.to_str().unwrap_or("."),
]).map_err(|e| format!("tar spawn: {}", e))?; ]).map_err(|e| format!("tar spawn: {e}"))?;
if !ok { if !ok {
return Err(format!("tar: {}", out.trim())); return Err(format!("tar: {}", out.trim()));
} }
@@ -484,7 +482,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
} }
let bin_dir = base.join("bin"); let bin_dir = base.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {}", e))?; std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
let launcher = bin_dir.join("jdtls"); let launcher = bin_dir.join("jdtls");
let script = r#"#!/usr/bin/env bash let script = r#"#!/usr/bin/env bash
@@ -505,12 +503,12 @@ exec java \
--add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.base/java.lang=ALL-UNNAMED \
"$@" "$@"
"#; "#;
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {}", e))?; std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod launcher: {}", e))?; .map_err(|e| format!("chmod launcher: {e}"))?;
} }
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); } if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
Ok(launcher) Ok(launcher)
@@ -521,7 +519,7 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res
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),
other => Err(format!("unknown download tier '{}'", other)), other => Err(format!("unknown download tier '{other}'")),
} }
} }
@@ -556,7 +554,7 @@ fn manual_instructions(def: &LanguageServerDef) -> String {
/// Try to provision a single language server. /// Try to provision a single language server.
/// ///
/// Flow: check whether any `binary_names` candidate is already on PATH /// Flow: check whether any `binary_names` candidate is already on PATH
/// → if yes, return AlreadyAvailable → otherwise walk /// → if yes, return `AlreadyAvailable` → otherwise walk
/// `install_tiers` in order, skipping tiers whose `requires` /// `install_tiers` in order, skipping tiers whose `requires`
/// binaries are missing → for each viable tier, run the install /// binaries are missing → for each viable tier, run the install
/// command (120s timeout) → if it succeeds AND the binary now /// command (120s timeout) → if it succeeds AND the binary now
@@ -641,7 +639,7 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
} }
// Normal shell-out tier. // Normal shell-out tier.
let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect(); let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
match run_command(&tier.command, &arg_refs) { match run_command(&tier.command, &arg_refs) {
Ok((true, _)) => { Ok((true, _)) => {
let located = def let located = def
@@ -683,9 +681,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
/// Provision every supported server in order, returning one /// Provision every supported server in order, returning one
/// `ProvisionResult` per server. /// `ProvisionResult` per server.
/// ///
/// Flow: detect_env() once → for each server in supported_servers() /// Flow: `detect_env()` once → for each server in `supported_servers()`
/// call provision_single() → collect results. Order matches /// call `provision_single()` → collect results. Order matches
/// supported_servers() (rust, typescript, go, java). /// `supported_servers()` (rust, typescript, go, java).
#[allow(dead_code)] #[allow(dead_code)]
pub fn provision_all() -> Vec<ProvisionResult> { pub fn provision_all() -> Vec<ProvisionResult> {
let env = detect_env(); let env = detect_env();
@@ -725,7 +723,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
let avail: String = flags.iter() let avail: String = flags.iter()
.filter(|(_, v)| *v).map(|(k, _)| *k) .filter(|(_, v)| *v).map(|(k, _)| *k)
.collect::<Vec<_>>().join(", "); .collect::<Vec<_>>().join(", ");
cb(&format!("LSP: environment ready — {}", avail)); cb(&format!("LSP: environment ready — {avail}"));
} }
supported_servers() supported_servers()
.iter() .iter()
@@ -736,8 +734,8 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
/// For every successful provision result, attach the corresponding /// For every successful provision result, attach the corresponding
/// server to the given `LspManager`. /// server to the given `LspManager`.
/// ///
/// Flow: for each result, if it's AlreadyAvailable or Installed, look /// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
/// up the LanguageServerDef, then call manager.connect() with /// up the `LanguageServerDef`, then call `manager.connect()` with
/// the binary path and empty args. On connect success, log and /// the binary path and empty args. On connect success, log and
/// record the name; on failure, log a warning and skip. /// record the name; on failure, log a warning and skip.
/// Returns the names that successfully connected. /// Returns the names that successfully connected.
@@ -745,7 +743,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
/// Why empty args: most LSP servers don't need CLI flags to start; /// Why empty args: most LSP servers don't need CLI flags to start;
/// the spec for each server lives in the protocol handshake, not the /// the spec for each server lives in the protocol handshake, not the
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server /// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
/// constant in supported_servers(). /// constant in `supported_servers()`.
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> { pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
let defs = supported_servers(); let defs = supported_servers();
let mut connected: Vec<String> = Vec::new(); let mut connected: Vec<String> = Vec::new();
@@ -767,12 +765,9 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
// Sanity: only connect to servers we know about. Protects against // Sanity: only connect to servers we know about. Protects against
// future ProvisionResult variants sneaking in unknown names. // future ProvisionResult variants sneaking in unknown names.
let def = match defs.iter().find(|d| d.name == name) { let Some(def) = defs.iter().find(|d| d.name == name) else {
Some(d) => d, warn!(name = %name, "skipping connect: unknown server");
None => { continue;
warn!(name = %name, "skipping connect: unknown server");
continue;
}
}; };
let mut guard = match manager.lock() { let mut guard = match manager.lock() {
@@ -784,7 +779,7 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
}; };
// Build extension slice for connect_with_extensions. // Build extension slice for connect_with_extensions.
let ext_refs: Vec<&str> = def.extensions.iter().map(|s| s.as_str()).collect(); let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::as_str).collect();
match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) { match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) {
Ok(()) => { Ok(()) => {
+31 -32
View File
@@ -88,7 +88,8 @@ impl StdioChild {
/// ///
/// Return: the `result` value of the matching response, or `Err` on /// Return: the `result` value of the matching response, or `Err` on
/// timeout, EOF, JSON-RPC error, or I/O failure. /// timeout, EOF, JSON-RPC error, or I/O failure.
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> { pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
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;
let req = json!({ let req = json!({
@@ -107,18 +108,17 @@ impl StdioChild {
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS); + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
loop { loop {
if std::time::Instant::now() > deadline { if std::time::Instant::now() > deadline {
anyhow::bail!("MCP call timed out after {}ms", MCP_CALL_TIMEOUT_MS); anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
} }
response_line.clear();
// Read one byte at a time up to MAX_LINE_LENGTH to prevent // Read one byte at a time up to MAX_LINE_LENGTH to prevent
// OOM from a malicious server (CWE-400). BufReader already // OOM from a malicious server (CWE-400). BufReader already
// buffers reads, so byte-by-byte over a buffered reader is // buffers reads, so byte-by-byte over a buffered reader is
// cheap (hits the in-memory buffer). // cheap (hits the in-memory buffer).
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB response_line.clear();
let mut line_truncated = false; let mut line_truncated = false;
loop { loop {
let byte = match self.stdout.fill_buf() { let byte = match self.stdout.fill_buf() {
Ok(buf) if buf.is_empty() => { Ok([]) => {
// EOF without newline // EOF without newline
anyhow::bail!("MCP stdio child process closed unexpectedly"); anyhow::bail!("MCP stdio child process closed unexpectedly");
} }
@@ -127,7 +127,7 @@ impl StdioChild {
self.stdout.consume(1); self.stdout.consume(1);
b b
} }
Err(e) => anyhow::bail!("MCP stdio read error: {}", e), Err(e) => anyhow::bail!("MCP stdio read error: {e}"),
}; };
if byte == b'\n' { if byte == b'\n' {
break; break;
@@ -137,7 +137,7 @@ impl StdioChild {
// Consume rest of line to keep stream in sync // Consume rest of line to keep stream in sync
loop { loop {
let buf = self.stdout.fill_buf() let buf = self.stdout.fill_buf()
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {}", e))?; .map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
if buf.is_empty() { if buf.is_empty() {
anyhow::bail!("MCP stdio child closed mid-line"); anyhow::bail!("MCP stdio child closed mid-line");
} }
@@ -153,8 +153,7 @@ impl StdioChild {
} }
if line_truncated { if line_truncated {
anyhow::bail!( anyhow::bail!(
"MCP response line exceeded {} byte limit", "MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
MAX_LINE_LENGTH,
); );
} }
let trimmed = response_line.trim(); let trimmed = response_line.trim();
@@ -162,10 +161,10 @@ impl StdioChild {
continue; continue;
} }
let resp: Value = serde_json::from_str(trimmed) let resp: Value = serde_json::from_str(trimmed)
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {e}"))?;
if resp.get("id") == Some(&json!(id)) { if resp.get("id") == Some(&json!(id)) {
if let Some(err) = resp.get("error") { if let Some(err) = resp.get("error") {
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); tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
@@ -191,7 +190,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
cmd.stderr(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn() let mut child = cmd.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{}': {}", command, e))?; .map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
let stdin = child.stdin.take() let stdin = child.stdin.take()
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?; .ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
@@ -207,7 +206,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
let deadline = std::time::Instant::now() let deadline = std::time::Instant::now()
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS); + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
let init_result = mcp.call("initialize", json!({ let init_result = mcp.call("initialize", &json!({
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {}, "capabilities": {},
"clientInfo": { "clientInfo": {
@@ -220,9 +219,9 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
anyhow::bail!("MCP initialize timed out"); anyhow::bail!("MCP initialize timed out");
} }
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {}", e))?; init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {e}"))?;
let _ = mcp.call("notifications/initialized", json!({})); let _ = mcp.call("notifications/initialized", &json!({}));
Ok(mcp) Ok(mcp)
} }
@@ -237,23 +236,23 @@ fn 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 {
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {}", e))?; guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
&mut guard &mut guard
} else { } else {
let mut fresh = spawn_stdio_child(command, extra_args)?; let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call("tools/call", json!({ let result = fresh.call("tools/call", &json!({
"name": tool_name, "name": tool_name,
"arguments": tool_args "arguments": tool_args
}))?; }))?;
return extract_text_content(&result); return Ok(extract_text_content(&result));
}; };
let result = child.call("tools/call", json!({ let result = child.call("tools/call", &json!({
"name": tool_name, "name": tool_name,
"arguments": tool_args "arguments": tool_args
}))?; }))?;
extract_text_content(&result) Ok(extract_text_content(&result))
} }
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> { fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
@@ -294,7 +293,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(&body) .json(&body)
.send() .send()
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {}", e))?; .map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -302,42 +301,42 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
tracing::warn!("[mcp] failed to read HTTP response body: {}", e); tracing::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}");
} }
let response: Value = resp.json() let response: Value = resp.json()
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {}", e))?; .map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
if let Some(err) = response.get("error") { if let Some(err) = response.get("error") {
anyhow::bail!("MCP HTTP error: {}", err); anyhow::bail!("MCP HTTP error: {err}");
} }
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"); tracing::warn!("[mcp] HTTP response missing 'result' field");
Value::Null Value::Null
}); });
extract_text_content(&result) Ok(extract_text_content(&result))
} }
fn extract_text_content(result: &Value) -> anyhow::Result<String> { fn extract_text_content(result: &Value) -> String {
if let Some(content) = result.get("content") { if let Some(content) = result.get("content") {
if let Some(arr) = content.as_array() { if let Some(arr) = content.as_array() {
let text: Vec<String> = arr.iter().filter_map(|item| { let text: Vec<String> = arr.iter().filter_map(|item| {
if item.get("type").and_then(|t| t.as_str()) == Some("text") { if item.get("type").and_then(|t| t.as_str()) == Some("text") {
item.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()) item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
} else { } else {
None None
} }
}).collect(); }).collect();
if !text.is_empty() { if !text.is_empty() {
return Ok(text.join("\n")); return text.join("\n");
} }
} }
} }
Ok(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); tracing::warn!("[mcp] failed to pretty-print result: {}", e);
result.to_string() result.to_string()
})) })
} }
/// Registry of connected MCP servers and their tools for the current session. /// Registry of connected MCP servers and their tools for the current session.
@@ -374,7 +373,7 @@ impl crate::tool::Tool for McpToolAdapter {
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> { fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
match &self.transport { match &self.transport {
McpTransport::Stdio { command, args: extra_args } => { McpTransport::Stdio { command, args: extra_args } => {
call_via_stdio(self.child_handle.as_ref().map(|h| h.as_ref()), command, extra_args, &self.tool_name, args) call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
} }
McpTransport::StreamableHttp { url } => { McpTransport::StreamableHttp { url } => {
call_via_http(url, &self.tool_name, args) call_via_http(url, &self.tool_name, args)
@@ -428,7 +427,7 @@ impl McpManager {
}; };
let mut child = spawn_stdio_child(command, extra_args)?; let mut child = spawn_stdio_child(command, extra_args)?;
let result = child.call("tools/list", json!({}))?; let result = child.call("tools/list", &json!({}))?;
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) { let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
tool_list.iter().filter_map(|t| { tool_list.iter().filter_map(|t| {
+2 -2
View File
@@ -66,7 +66,7 @@ impl EditorState {
self.cursor_line += 1; self.cursor_line += 1;
} }
self.cursor_col = self.cursor_col.min( self.cursor_col = self.cursor_col.min(
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0), self.content.get(self.cursor_line).map_or(0, std::string::String::len),
); );
} }
@@ -112,7 +112,7 @@ impl EditorState {
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a /// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
/// line and moves down, `\t` inserts two spaces, everything else inserts /// line and moves down, `\t` inserts two spaces, everything else inserts
/// the char directly → mark state dirty. /// the char directly → mark state dirty.
pub fn handle_editor_input(state: &mut AppStateRest, text: String) { pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
let editor = &mut state.misc.editor; let editor = &mut state.misc.editor;
if editor.is_none() { if editor.is_none() {
return; return;
+3 -2
View File
@@ -1,5 +1,6 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! 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;
@@ -44,7 +45,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
let label = current_effort_str(state); let label = current_effort_str(state);
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info, crate::app::state::types::ToastKind::Info,
format!("Effort: {}", label), format!("Effort: {label}"),
)); ));
state.dirty = true; state.dirty = true;
} }
+11 -14
View File
@@ -1,23 +1,20 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! 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.
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use sha2::Digest; use sha2::Digest;
/// Returns the number of stored pre-edit blobs (snapshots) for this session. /// Returns the number of stored pre-edit blobs (snapshots) for this session.
pub fn rewind_count(state: &AppStateRest) -> usize { pub fn rewind_count(state: &AppStateRest) -> usize {
let conn = match open_session_db(&state.session_dir) { let Ok(conn) = open_session_db(&state.session_dir) else { return 0 };
Ok(c) => c,
Err(_) => return 0,
};
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
.ok() .ok()
.map(|keys| keys.len()) .map_or(0, |keys| keys.len())
.unwrap_or(0)
} }
/// 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).
pub fn rewind_to(state: &mut AppStateRest, index: usize) { pub fn rewind_to(state: &mut AppStateRest, index: usize) {
let conn = match open_session_db(&state.session_dir) { let conn = match open_session_db(&state.session_dir) {
@@ -25,7 +22,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to open session DB: {}", e), format!("Failed to open session DB: {e}"),
)); ));
state.dirty = true; state.dirty = true;
return; return;
@@ -37,7 +34,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to list snapshots: {}", e), format!("Failed to list snapshots: {e}"),
)); ));
state.dirty = true; state.dirty = true;
return; return;
@@ -67,7 +64,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to retrieve snapshot: {}", e), format!("Failed to retrieve snapshot: {e}"),
)); ));
state.dirty = true; state.dirty = true;
return; return;
@@ -81,7 +78,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
.unwrap_or_else(|| state.session_dir.join("snapshot.dat")); .unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
match std::fs::write(&restore_path, &bytes) { match std::fs::write(&restore_path, &bytes) {
Ok(_) => { Ok(()) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success, crate::app::state::types::ToastKind::Success,
format!("Restored {} from snapshot", restore_path.display()), format!("Restored {} from snapshot", restore_path.display()),
@@ -90,7 +87,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
Err(e) => { Err(e) => {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Failed to write restored file: {}", e), format!("Failed to write restored file: {e}"),
)); ));
} }
} }
@@ -101,7 +98,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
ts: chrono::Utc::now().timestamp_millis(), ts: chrono::Utc::now().timestamp_millis(),
tool: "rewind".to_string(), tool: "rewind".to_string(),
path: restore_path.to_string_lossy().to_string(), path: restore_path.to_string_lossy().to_string(),
reason: format!("rewind_to({})", index), reason: format!("rewind_to({index})"),
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)),
bytes_delta: bytes.len() as i64, bytes_delta: bytes.len() as i64,
origin: crate::app::state::types::Origin::Main.tag(), origin: crate::app::state::types::Origin::Main.tag(),
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::model::settings::{Settings, InternetMode};
/// Advance the internet access mode to the next value in the cycle. /// Advance the internet access mode to the next value in the cycle.
/// ///
/// Flow: Off -> ReadOnly -> Full -> Off, wrapping around. /// Flow: Off -> `ReadOnly` -> Full -> Off, wrapping around.
/// ///
/// Why: used by a settings-toggle keybinding to step through modes /// Why: used by a settings-toggle keybinding to step through modes
/// without needing a dropdown/menu. /// without needing a dropdown/menu.
+25 -30
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Adaptive quality-review triggering, build/test probing, staleness //! Adaptive quality-review triggering, build/test probing, staleness
//! sweeps for stored lessons, and the pending-lesson approval workflow. //! sweeps for stored lessons, and the pending-lesson approval workflow.
use std::process::Command; use std::process::Command;
@@ -74,10 +75,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main { if origin != Origin::Main {
return false; return false;
} }
let runtime = match &state.session_runtime { let Some(runtime) = &state.session_runtime else { return false };
Some(r) => r,
None => return false,
};
if !state.settings.review_enabled { if !state.settings.review_enabled {
return false; return false;
} }
@@ -122,8 +120,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
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)?;
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string())) let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
.unwrap_or_else(|| (cmd.clone(), String::new()));
let Ok(mut child) = Command::new(&cmd_prog) let Ok(mut child) = Command::new(&cmd_prog)
.args(cmd_args.split_whitespace()) .args(cmd_args.split_whitespace())
@@ -143,7 +140,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
let output = child.wait_with_output().ok(); let output = child.wait_with_output().ok();
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default(); let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
return Some(ProbeResult { return Some(ProbeResult {
command: cmd.clone(), command: cmd.clone(),
passed: status.success(), passed: status.success(),
@@ -201,10 +198,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
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")?;
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
return Some("npm test 2>&1".to_string()); return Some("npm test 2>&1".to_string());
} }
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
return Some("npm run build 2>&1".to_string()); return Some("npm run build 2>&1".to_string());
} }
} }
@@ -306,14 +303,15 @@ fn truncate_output(s: &str, max: usize) -> String {
/// Return: `Ok(())` once the review has been kicked off; errors only /// Return: `Ok(())` once the review has been kicked off; errors only
/// 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) -> anyhow::Result<()> { #[allow(clippy::unnecessary_debug_formatting)]
pub fn trigger_review(state: &mut AppStateRest) {
let def = AgentDefinition::new( let def = AgentDefinition::new(
"quality-reviewer".to_string(), "quality-reviewer".to_string(),
"reviewer".to_string(), "reviewer".to_string(),
); );
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = state.session_dir.clone(); ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces = state.workspace_roots.clone(); ctx.workspaces.clone_from(&state.workspace_roots);
let probe_result = probe_build_test( let probe_result = probe_build_test(
&state.workspace_roots, &state.workspace_roots,
state.settings.verify_command.as_deref(), state.settings.verify_command.as_deref(),
@@ -333,21 +331,20 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(), None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
}; };
let session_dir = &state.session_dir;
ctx.system_prompt = format!( ctx.system_prompt = format!(
"You are a code quality reviewer. Review the recent code changes \ "You are a code quality reviewer. Review the recent code changes \
for correctness, and adherence to best practices. \ for correctness, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \ Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \ inspect the session files and provide a concise review verdict. \
Session directory: {:?}\n\n\ Session directory: {session_dir:?}\n\n\
Build/Test Probe:\n{}\n\n\ Build/Test Probe:\n{probe_note}\n\n\
When writing a lesson via remember(), set tags appropriately:\n\ When writing a lesson via remember(), set tags appropriately:\n\
- If build/test verification printed any FAILED/ERROR lines, tag\n\ - If build/test verification printed any FAILED/ERROR lines, tag\n\
the lesson as \"confidence: verified\" (backed by a real failure).\n\ the lesson as \"confidence: verified\" (backed by a real failure).\n\
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\ - If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
(reviewer judgment only).\n\ (reviewer judgment only).\n\
Check for duplicate lessons via recall before writing a new one.", Check for duplicate lessons via recall before writing a new one.",
state.session_dir,
probe_note,
); );
// Use a drain thread for subagent events (so blocking_send never // Use a drain thread for subagent events (so blocking_send never
@@ -359,17 +356,17 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let mut rx = rx; let mut rx = rx;
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[review] tool call: {}", _tool); tracing::debug!("[review] tool call: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[review] tool result: {}", _tool); tracing::debug!("[review] tool result: {}", tool);
} }
SubagentEvent::StepCompleted { _step, .. } => { SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[review] step {} completed", _step); tracing::trace!("[review] step completed");
} }
SubagentEvent::StepFailed { _step, _error } => { SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[review] step {} failed: {}", _step, _error); tracing::warn!("[review] step {} failed: {}", step, error);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[review] completed"); tracing::debug!("[review] completed");
@@ -380,13 +377,13 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let turn_events = state.turn_events.clone(); let turn_events = state.turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match result { let message = match result {
Ok(verdict) => { Ok(verdict) => {
let first_line = verdict.lines().next().unwrap_or(&verdict); let first_line = verdict.lines().next().unwrap_or(&verdict);
format!("Quality review: {}", first_line) format!("Quality review: {first_line}")
} }
Err(e) => format!("Quality review failed: {}", e), Err(e) => format!("Quality review failed: {e}"),
}; };
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 {
@@ -400,8 +397,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
ToastKind::Info, ToastKind::Info,
"Quality review triggered".to_string(), "Quality review triggered".to_string(),
)); ));
Ok(())
} }
const STALE_AFTER_DAYS: i64 = 60; const STALE_AFTER_DAYS: i64 = 60;
+88 -98
View File
@@ -6,7 +6,7 @@
//! 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
//! execute tool calls via `Harness`, archive messages to SQLite, log edits) //! execute tool calls via `Harness`, archive messages to `SQLite`, log edits)
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued //! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts, //! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
//! usage counters). //! usage counters).
@@ -16,7 +16,10 @@
//! 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.
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fmt::Write;
use crate::app::harness::Verdict; use crate::app::harness::Verdict;
use sha2::Digest; use sha2::Digest;
@@ -30,7 +33,7 @@ use crate::dto::chat::message::{ChatMessage, Role};
/// 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`.
/// ///
/// Step bounds intentionally left unbounded (usize::MAX) so the agent can /// Step bounds intentionally left unbounded (`usize::MAX`) so the agent can
/// continue across as many turns as needed. Each iteration still honours /// continue across as many turns as needed. Each iteration still honours
/// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is /// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
/// observable and cancellable from the UI. /// observable and cancellable from the UI.
@@ -99,6 +102,7 @@ pub enum Action {
/// need to know how to *produce* actions. /// need to know how to *produce* actions.
/// ///
/// Return: nothing; `state` is mutated in place. /// Return: nothing; `state` is mutated in place.
#[allow(clippy::too_many_lines)]
pub fn apply_action(state: &mut AppStateRest, action: Action) { pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action { match action {
Action::ForceQuit => { Action::ForceQuit => {
@@ -168,37 +172,36 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
Ok(abs_path) => { Ok(abs_path) => {
let content = std::fs::read_to_string(&abs_path) let content = std::fs::read_to_string(&abs_path)
.unwrap_or_default(); .unwrap_or_default();
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect(); let lines: Vec<String> = content.lines().map(std::string::ToString::to_string).collect();
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),
); );
state.misc.editor = Some(ed); state.misc.editor = Some(ed);
state.misc.overlay = Overlay::Editor; state.misc.overlay = Overlay::Editor;
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path))); state.push_toast(Toast::new(ToastKind::Info, format!("Editing {path}")));
} }
Err(e) => { Err(e) => {
state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e))); state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {path}: {e}")));
} }
} }
state.dirty = true; state.dirty = true;
} }
Action::McpAdd { name, command } => { Action::McpAdd { name, command } => {
let extra_args: Vec<String> = command.split_whitespace().map(|s| s.to_string()).collect(); let extra_args: Vec<String> = command.split_whitespace().map(std::string::ToString::to_string).collect();
let cmd = extra_args.first().cloned().unwrap_or_default(); let cmd = extra_args.first().cloned().unwrap_or_default();
let args: Vec<String> = extra_args.into_iter().skip(1).collect(); let args: Vec<String> = extra_args.into_iter().skip(1).collect();
match state.mcp_manager.connect_stdio(&name, &cmd, &args) { match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
Ok(_) => { Ok(()) => {
let tool_count = state.mcp_manager.servers.last() let tool_count = state.mcp_manager.servers.last()
.map(|s| s.tools.len()) .map_or(0, |s| s.tools.len());
.unwrap_or(0);
state.push_toast(Toast::new(ToastKind::Success, state.push_toast(Toast::new(ToastKind::Success,
format!("Connected MCP server '{}' ({} tools)", name, tool_count))); format!("Connected MCP server '{name}' ({tool_count} tools)")));
state.dirty = true; state.dirty = true;
} }
Err(e) => { Err(e) => {
state.push_toast(Toast::new(ToastKind::Error, state.push_toast(Toast::new(ToastKind::Error,
format!("MCP connect failed: {}", e))); format!("MCP connect failed: {e}")));
} }
} }
} }
@@ -238,7 +241,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
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}"),
}; };
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 {
@@ -247,7 +250,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}); });
} }
}); });
let toast = Toast::new(ToastKind::Info, format!("Opening browser for {} login...", provider)); let toast = Toast::new(ToastKind::Info, format!("Opening browser for {provider} login..."));
state.push_toast(toast); state.push_toast(toast);
state.dirty = true; state.dirty = true;
} }
@@ -325,13 +328,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
let display_path = path.unwrap_or_default(); let display_path = path.unwrap_or_default();
let display = if tool_name == "read" { let display = if tool_name == "read" {
let line_count = output.lines().count(); let line_count = output.lines().count();
if !display_path.is_empty() { if display_path.is_empty() {
format!("read: {} ({} lines)", display_path, line_count) format!("read: {line_count} line(s)")
} else { } else {
format!("read: {} line(s)", line_count) format!("read: {display_path} ({line_count} lines)")
} }
} else { } else {
format!("{}: {}", tool_name, output) format!("{tool_name}: {output}")
}; };
state.push_transcript(ChatMessageDisplay::new( state.push_transcript(ChatMessageDisplay::new(
Role::Tool, Role::Tool,
@@ -356,7 +359,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
} }
if should_trigger_review(state, Origin::Main) { if should_trigger_review(state, Origin::Main) {
let _ = trigger_review(state); trigger_review(state);
} }
} else if kind == "review" { } else if kind == "review" {
let counted = if let Some(ref mut rt) = state.session_runtime { let counted = if let Some(ref mut rt) = state.session_runtime {
@@ -422,7 +425,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}); });
state.push_transcript(ChatMessageDisplay::new( state.push_transcript(ChatMessageDisplay::new(
crate::dto::chat::message::Role::System, crate::dto::chat::message::Role::System,
format!("{}", message), format!("{message}"),
)); ));
if state.misc.overlay == Overlay::Workflow { if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
@@ -437,7 +440,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}); });
state.push_transcript(ChatMessageDisplay::new( state.push_transcript(ChatMessageDisplay::new(
crate::dto::chat::message::Role::System, crate::dto::chat::message::Role::System,
format!("{}", message), format!("{message}"),
)); ));
if state.misc.overlay == Overlay::Workflow { if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
@@ -486,7 +489,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.push_toast(long_toast); state.push_toast(long_toast);
state.push_transcript(ChatMessageDisplay::new( state.push_transcript(ChatMessageDisplay::new(
crate::dto::chat::message::Role::System, crate::dto::chat::message::Role::System,
format!("Error: {}", msg), format!("Error: {msg}"),
)); ));
turn_finished = true; turn_finished = true;
} }
@@ -548,7 +551,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
let total_chars: usize = rt.messages.iter() let total_chars: usize = rt.messages.iter()
.filter_map(|m| m.content.as_deref()) .filter_map(|m| m.content.as_deref())
.map(|c| c.len()) .map(str::len)
.sum(); .sum();
let token_estimate = total_chars / 3; let token_estimate = total_chars / 3;
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None); rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
@@ -566,7 +569,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
refresh_lesson_counters(&state.memory_dir, rt); refresh_lesson_counters(&state.memory_dir, rt);
} }
state.push_toast(Toast::new(ToastKind::Success, state.push_toast(Toast::new(ToastKind::Success,
format!("accepted lesson: {}", name))); format!("accepted lesson: {name}")));
state.dirty = true; state.dirty = true;
} }
Action::LessonReject { name } => { Action::LessonReject { name } => {
@@ -579,7 +582,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
refresh_lesson_counters(&state.memory_dir, rt); refresh_lesson_counters(&state.memory_dir, rt);
} }
state.push_toast(Toast::new(ToastKind::Info, state.push_toast(Toast::new(ToastKind::Info,
format!("rejected lesson: {}", name))); format!("rejected lesson: {name}")));
state.dirty = true; state.dirty = true;
} }
Action::LessonDelete { name } => { Action::LessonDelete { name } => {
@@ -587,7 +590,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
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);
} }
state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {}", name))); state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {name}")));
state.dirty = true; state.dirty = true;
} }
Action::RunPipeline { mode } => { Action::RunPipeline { mode } => {
@@ -606,10 +609,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
"status" => { "status" => {
let current = state.misc.pipeline_override.as_deref().unwrap_or("auto"); let current = state.misc.pipeline_override.as_deref().unwrap_or("auto");
state.push_toast(Toast::new(ToastKind::Info, format!("Pipeline mode: {} (use /pipeline full|quick|skip to change)", current))); state.push_toast(Toast::new(ToastKind::Info, format!("Pipeline mode: {current} (use /pipeline full|quick|skip to change)")));
} }
_ => { _ => {
state.push_toast(Toast::new(ToastKind::Error, format!("Unknown pipeline mode: {} (use: full, quick, skip)", mode))); state.push_toast(Toast::new(ToastKind::Error, format!("Unknown pipeline mode: {mode} (use: full, quick, skip)")));
} }
} }
state.dirty = true; state.dirty = true;
@@ -644,8 +647,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents // "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
// "prompt1 -> prompt2" → Pipeline of 2 stages // "prompt1 -> prompt2" → Pipeline of 2 stages
// "prompt" → single Agent // "prompt" → single Agent
let parts_pipe: Vec<&str> = script.split('|').map(|s| s.trim()).collect(); let parts_pipe: Vec<&str> = script.split('|').map(str::trim).collect();
let parts_arrow: Vec<&str> = script.split("->").map(|s| s.trim()).collect(); let parts_arrow: Vec<&str> = script.split("->").map(str::trim).collect();
let primitive = if parts_pipe.len() > 1 { let primitive = if parts_pipe.len() > 1 {
ScriptPrimitive::Parallel( ScriptPrimitive::Parallel(
@@ -680,12 +683,12 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
let args: HashMap<String, String> = HashMap::new(); let args: HashMap<String, String> = HashMap::new();
let result = crate::app::workflow::engine::run_workflow_tracked( let result = crate::app::workflow::engine::run_workflow_tracked(
&wf, &args, Some(live), &session_dir, &workspace_roots, &wf, &args, Some(&live), &session_dir, &workspace_roots,
); );
let (kind, message) = match result { let (kind, message) = match result {
Ok(summary) => ("workflow_done".to_string(), summary), Ok(summary) => ("workflow_done".to_string(), summary),
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {}", e)), Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {e}")),
}; };
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
@@ -793,7 +796,7 @@ fn spawn_turn(state: &AppStateRest) {
abort_flag, abort_flag,
pipeline_mode, pipeline_mode,
}; };
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 {
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()));
@@ -837,7 +840,7 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let mut out = String::new(); let mut out = String::new();
out.push_str("Current Workspace Directory Structure:\n"); out.push_str("Current Workspace Directory Structure:\n");
for root in roots { for root in roots {
out.push_str(&format!("Root: {}\n", root.display())); writeln!(out, "Root: {}", root.display()).unwrap();
let walker = ignore::WalkBuilder::new(root) let walker = ignore::WalkBuilder::new(root)
.hidden(true) .hidden(true)
.git_ignore(true) .git_ignore(true)
@@ -847,9 +850,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let path = entry.path(); let path = entry.path();
if let Ok(rel) = path.strip_prefix(root) { if let Ok(rel) = path.strip_prefix(root) {
if rel.as_os_str().is_empty() { continue; } if rel.as_os_str().is_empty() { continue; }
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let prefix = if is_dir { "[DIR] " } else { " " }; let prefix = if is_dir { "[DIR] " } else { " " };
out.push_str(&format!(" {}{}\n", prefix, rel.display())); writeln!(out, " {}{}", prefix, rel.display()).unwrap();
count += 1; count += 1;
if count > 1000 { if count > 1000 {
out.push_str(" ... (truncated)\n"); out.push_str(" ... (truncated)\n");
@@ -881,7 +884,7 @@ fn build_memory_section(memory_dir: &std::path::Path) -> String {
} }
let mut section = String::from("\n\n--- Persistent Memory ---\n"); let mut section = String::from("\n\n--- Persistent Memory ---\n");
section.push_str(&format!("Total entries: {}\n\n", names.len())); write!(section, "Total entries: {}\n\n", names.len()).unwrap();
for name in &names { for name in &names {
if section.len() > 3000 { if section.len() > 3000 {
@@ -892,7 +895,7 @@ fn build_memory_section(memory_dir: &std::path::Path) -> String {
if mem.lifecycle == "stale" { if mem.lifecycle == "stale" {
continue; continue;
} }
section.push_str(&format!("## [{}] {}\n{}\n\n", mem.kind, mem.name, mem.content)); write!(section, "## [{}] {}\n{}\n\n", mem.kind, mem.name, mem.content).unwrap();
} }
} }
section.push_str("---"); section.push_str("---");
@@ -940,13 +943,13 @@ fn refresh_lesson_counters(memory_dir: &std::path::Path, rt: &mut crate::app::st
} }
} }
/// Persist a `ChatMessage` to the SQLite message log, if a database /// Persist a `ChatMessage` to the `SQLite` message log, if a database
/// connection is available. /// connection is available.
/// ///
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`. /// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
/// Errors are silently ignored. /// Errors are silently ignored.
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) { fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
if let Some(ref arc) = db { if let Some(arc) = db {
if let Ok(conn) = arc.lock() { if let Ok(conn) = arc.lock() {
let _ = crate::model::msglog::insert_message(&conn, session_id, msg); let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
} }
@@ -979,7 +982,7 @@ const MAX_AUTO_REVIEWS_PER_TURN: usize = 2;
/// through `Harness::gate_tool_call`) or unwrap the final assistant /// through `Harness::gate_tool_call`) or unwrap the final assistant
/// message → check for unfinished todo.md tasks (auto-retry with a /// message → check for unfinished todo.md tasks (auto-retry with a
/// system message if any remain) → finalise with `Done` and an `edits` /// system message if any remain) → finalise with `Done` and an `edits`
/// SystemNote. /// `SystemNote`.
/// ///
/// On streaming failure: retry once with a non-streaming call → if that /// On streaming failure: retry once with a non-streaming call → if that
/// also fails and there are unfinished tasks, sleep 5s and loop back; /// also fails and there are unfinished tasks, sleep 5s and loop back;
@@ -990,11 +993,13 @@ const MAX_AUTO_REVIEWS_PER_TURN: usize = 2;
/// ///
/// Return: `Ok(())` on successful completion, or an error from the LLM /// Return: `Ok(())` on successful completion, or an error from the LLM
/// API after retries are exhausted. /// API after retries are exhausted.
#[allow(clippy::too_many_lines)]
fn run_agent_turn( fn run_agent_turn(
tc: TurnCtx, tc: &TurnCtx,
messages: &[ChatMessage], messages: &[ChatMessage],
events_q: &std::sync::Arc<std::sync::Mutex<VecDeque<TurnEvent>>>, events_q: &std::sync::Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
const MAX_TODO_RETRIES: usize = 5;
let mut msgs = messages.to_vec(); let mut msgs = messages.to_vec();
let mut edits_this_turn = 0u32; let mut edits_this_turn = 0u32;
let mut edited_paths: Vec<String> = Vec::new(); let mut edited_paths: Vec<String> = Vec::new();
@@ -1016,7 +1021,7 @@ fn run_agent_turn(
); );
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) { if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
let sys = ChatMessage::system(system_text); let sys = ChatMessage::system(system_text);
archive_message(&tc.db, &tc.session_id, &sys); archive_message(tc.db.as_ref(), &tc.session_id, &sys);
msgs.insert(0, sys); msgs.insert(0, sys);
} }
@@ -1032,24 +1037,21 @@ fn run_agent_turn(
.count(); .count();
let should_pipeline = if user_msg_count <= 2 { let should_pipeline = if user_msg_count <= 2 {
let user_request = msgs.iter() let user_request = msgs.iter()
.rev() .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User))
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
.next()
.and_then(|m| m.content.as_deref()) .and_then(|m| m.content.as_deref())
.unwrap_or(""); .unwrap_or("");
if !user_request.is_empty() { if user_request.is_empty() {
false
} else {
match tc.pipeline_mode.as_deref() { match tc.pipeline_mode.as_deref() {
Some("skip") => { Some("skip") => {
tracing::debug!("[ceo] pipeline skipped via /pipeline skip"); tracing::debug!("[ceo] pipeline skipped via /pipeline skip");
false false
} }
Some("full") => true, Some("full" | "quick") => true,
Some("quick") => true,
_ => crate::app::workflow::company::is_complex_request(user_request), _ => crate::app::workflow::company::is_complex_request(user_request),
} }
} else {
false
} }
} else { } else {
false false
@@ -1057,9 +1059,7 @@ fn run_agent_turn(
if should_pipeline { if should_pipeline {
let user_request = msgs.iter() let user_request = msgs.iter()
.rev() .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User))
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
.next()
.and_then(|m| m.content.as_deref()) .and_then(|m| m.content.as_deref())
.unwrap_or(""); .unwrap_or("");
@@ -1102,26 +1102,23 @@ fn run_agent_turn(
Ok(summary) => { Ok(summary) => {
tracing::info!("[ceo] company pipeline completed successfully"); tracing::info!("[ceo] company pipeline completed successfully");
let pipeline_msg = ChatMessage::system(format!( let pipeline_msg = ChatMessage::system(format!(
"[Company Pipeline: {}]\n{}", "[Company Pipeline: {mode_label}]\n{summary}",
mode_label,
summary,
)); ));
archive_message(&tc.db, &tc.session_id, &pipeline_msg); archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
msgs.push(pipeline_msg); msgs.push(pipeline_msg);
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(), kind: "pipeline".to_string(),
message: format!("Company pipeline ({}) complete. CEO reviewing results...", mode_label), message: format!("Company pipeline ({mode_label}) complete. CEO reviewing results..."),
}); });
} }
} }
Err(e) => { Err(e) => {
tracing::warn!("[ceo] company pipeline failed: {}", e); tracing::warn!("[ceo] company pipeline failed: {}", e);
let fail_msg = ChatMessage::system(format!( let fail_msg = ChatMessage::system(format!(
"[Pipeline Note] The company pipeline encountered issues: {}.\n\ "[Pipeline Note] The company pipeline encountered issues: {e}.\n\
Proceeding with direct execution as fallback.", Proceeding with direct execution as fallback.",
e,
)); ));
msgs.push(fail_msg); msgs.push(fail_msg);
} }
@@ -1132,15 +1129,13 @@ fn run_agent_turn(
let mut turn_step = 0usize; let mut turn_step = 0usize;
let mut todo_retry_count = 0usize; let mut todo_retry_count = 0usize;
const MAX_TODO_RETRIES: usize = 5;
loop { loop {
turn_step += 1; turn_step += 1;
if turn_step > MAX_TURN_STEPS { if turn_step > MAX_TURN_STEPS {
anyhow::bail!( anyhow::bail!(
"turn exceeded maximum steps ({}) — possible runaway loop. \ "turn exceeded maximum steps ({MAX_TURN_STEPS}) — possible runaway loop. \
aborting to prevent excessive token usage", aborting to prevent excessive token usage",
MAX_TURN_STEPS,
); );
} }
if turn_start_ms.elapsed().as_millis() as u64 > MAX_TURN_TIMEOUT_MS { if turn_start_ms.elapsed().as_millis() as u64 > MAX_TURN_TIMEOUT_MS {
@@ -1152,7 +1147,7 @@ fn run_agent_turn(
} }
let total_chars: usize = msgs.iter() let total_chars: usize = msgs.iter()
.filter_map(|m| m.content.as_deref()) .filter_map(|m| m.content.as_deref())
.map(|c| c.len()) .map(str::len)
.sum(); .sum();
let token_estimate = total_chars / 4; let token_estimate = total_chars / 4;
let max_wire_tokens = tc.context_window; let max_wire_tokens = tc.context_window;
@@ -1168,7 +1163,7 @@ fn run_agent_turn(
} }
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version // Also update our local `msgs` variable so the rest of the loop operates on the compacted version
msgs = compacted.clone(); msgs.clone_from(&compacted);
compacted compacted
} else { } else {
prev_shaped = false; prev_shaped = false;
@@ -1251,15 +1246,14 @@ fn run_agent_turn(
todo_retry_count += 1; todo_retry_count += 1;
if todo_retry_count > MAX_TODO_RETRIES { if todo_retry_count > MAX_TODO_RETRIES {
anyhow::bail!( anyhow::bail!(
"exhausted {} todo-retries — giving up on unfinished tasks. \ "exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
Edit todo.md manually or ask me to focus on specific items.", Edit todo.md manually or ask me to focus on specific items.",
MAX_TODO_RETRIES,
); );
} }
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "task_retry".to_string(), kind: "task_retry".to_string(),
message: format!("Network/API error: {}. Auto-retrying to finish tasks... (retry {}/{})", api_err, todo_retry_count, MAX_TODO_RETRIES), message: format!("Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"),
}); });
} }
std::thread::sleep(std::time::Duration::from_secs(5)); std::thread::sleep(std::time::Duration::from_secs(5));
@@ -1283,7 +1277,7 @@ fn run_agent_turn(
let content = response.content.clone().unwrap_or_default(); let content = response.content.clone().unwrap_or_default();
if has_tool_calls { if has_tool_calls {
let tool_calls = response.tool_calls.clone().unwrap_or_default(); let tool_calls = response.tool_calls.clone().unwrap_or_default();
archive_message(&tc.db, &tc.session_id, &response); archive_message(tc.db.as_ref(), &tc.session_id, &response);
msgs.push(response); msgs.push(response);
for tool_call in tool_calls { for tool_call in tool_calls {
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
@@ -1298,7 +1292,7 @@ fn run_agent_turn(
); );
let ws_roots: Vec<&std::path::Path> = let ws_roots: Vec<&std::path::Path> =
tc.workspace_roots.iter().map(|p| p.as_path()).collect(); tc.workspace_roots.iter().map(std::path::PathBuf::as_path).collect();
let verdict = crate::app::harness::Harness::gate_tool_call( let verdict = crate::app::harness::Harness::gate_tool_call(
&tool_name, &tool_name,
&args, &args,
@@ -1316,12 +1310,12 @@ fn run_agent_turn(
&args, &args,
&tc.edit_log_session_dir, &tc.edit_log_session_dir,
&tc.session_id, &tc.session_id,
&tc.db, tc.db.as_ref(),
) { ) {
Ok(result) => (result, false, is_edit_tool), Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false), Err(e) => (e.to_string(), true, false),
}, },
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false), Verdict::Block(reason) => (format!("Blocked: {reason}"), true, false),
}; };
if is_edit { if is_edit {
@@ -1332,7 +1326,7 @@ fn run_agent_turn(
// background subagent tracking. // background subagent tracking.
let edit_path = args.get("path") let edit_path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .map(std::string::ToString::to_string);
if let Some(ref p) = edit_path { if let Some(ref p) = edit_path {
edited_paths.push(p.clone()); edited_paths.push(p.clone());
@@ -1353,7 +1347,7 @@ fn run_agent_turn(
Ok(verdict) => { Ok(verdict) => {
let elapsed = review_start.elapsed().as_millis(); let elapsed = review_start.elapsed().as_millis();
let review_msg = ChatMessage::tool_result( let review_msg = ChatMessage::tool_result(
format!("auto-review-{}", inline_reviews_count), format!("auto-review-{inline_reviews_count}"),
format!( format!(
"[Auto inline review: {} ({}ms)]\n{}", "[Auto inline review: {} ({}ms)]\n{}",
p, p,
@@ -1361,7 +1355,7 @@ fn run_agent_turn(
verdict.trim(), verdict.trim(),
), ),
); );
archive_message(&tc.db, &tc.session_id, &review_msg); archive_message(tc.db.as_ref(), &tc.session_id, &review_msg);
msgs.push(review_msg); msgs.push(review_msg);
tracing::info!( tracing::info!(
"[auto-review] inline review for '{}' completed in {}ms: {}", "[auto-review] inline review for '{}' completed in {}ms: {}",
@@ -1381,7 +1375,7 @@ fn run_agent_turn(
} }
let tool_path = args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string);
{ {
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
@@ -1396,12 +1390,12 @@ fn run_agent_turn(
} }
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
archive_message(&tc.db, &tc.session_id, &tool_msg); archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
msgs.push(tool_msg); msgs.push(tool_msg);
} }
} else { } else {
if !content.is_empty() { if !content.is_empty() {
archive_message(&tc.db, &tc.session_id, &response); archive_message(tc.db.as_ref(), &tc.session_id, &response);
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
if stream_started { if stream_started {
q.push_back(TurnEvent::StreamDone(response.clone())); q.push_back(TurnEvent::StreamDone(response.clone()));
@@ -1425,15 +1419,15 @@ fn run_agent_turn(
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "task_retry".to_string(), kind: "task_retry".to_string(),
message: format!("Giving up after {} retries — some todo items remain unfinished. Edit todo.md manually or ask again.", MAX_TODO_RETRIES), message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."),
}); });
} }
break; break;
} }
let sys_text = format!("You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished. (Retry {}/{})", todo_retry_count, MAX_TODO_RETRIES); let sys_text = format!("You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished. (Retry {todo_retry_count}/{MAX_TODO_RETRIES})");
let sys_text_clone = sys_text.clone(); let sys_text_clone = sys_text.clone();
let msg = ChatMessage::system(sys_text); let msg = ChatMessage::system(sys_text);
archive_message(&tc.db, &tc.session_id, &msg); archive_message(tc.db.as_ref(), &tc.session_id, &msg);
msgs.push(msg); msgs.push(msg);
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
@@ -1510,13 +1504,13 @@ fn execute_one_tool(
args: &serde_json::Value, args: &serde_json::Value,
session_dir: &std::path::Path, session_dir: &std::path::Path,
session_id: &str, session_id: &str,
db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
for tool in tools { for tool in tools {
if tool.name() == name { if tool.name() == name {
// Snapshot current file content before write/edit for rewind // Snapshot current file content before write/edit for rewind
if (name == "write" || name == "edit") && !tool_call_id.is_empty() { if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
if let Some(ref arc) = db { if let Some(arc) = db {
if let Ok(conn) = arc.lock() { if let Ok(conn) = arc.lock() {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) { if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
@@ -1544,13 +1538,12 @@ fn execute_one_tool(
let hash = sha2::Sha256::digest( let hash = sha2::Sha256::digest(
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(), content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
); );
format!("{:x}", hash) format!("{hash:x}")
}; };
let bytes_delta = if name == "write" { let bytes_delta = if name == "write" {
args.get("content") args.get("content")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.len() as i64) .map_or(0, |s| s.len() as i64)
.unwrap_or(0)
} else { } else {
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
@@ -1572,7 +1565,7 @@ fn execute_one_tool(
return Ok(result); return Ok(result);
} }
} }
anyhow::bail!("tool not found: {}", name) anyhow::bail!("tool not found: {name}")
} }
/// Optionally push a review-available toast at the end of a turn that /// Optionally push a review-available toast at the end of a turn that
@@ -1591,14 +1584,13 @@ fn maybe_trigger_review(state: &mut AppStateRest) {
let edit_count = state let edit_count = state
.session_runtime .session_runtime
.as_ref() .as_ref()
.map(|rt| rt.edit_count) .map_or(0, |rt| rt.edit_count);
.unwrap_or(0);
if edit_count == 0 { if edit_count == 0 {
return; return;
} }
state.push_toast(Toast::new( state.push_toast(Toast::new(
ToastKind::Info, ToastKind::Info,
format!("{} file(s) modified this session. Review available.", edit_count), format!("{edit_count} file(s) modified this session. Review available."),
)); ));
} }
@@ -1698,13 +1690,13 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
let code = server.wait_for_code(120_000, &state_token)?; let code = server.wait_for_code(120_000, &state_token)?;
manager.exchange_code(&code, &redirect_uri, verifier.as_str()) manager.exchange_code(&code, &redirect_uri, verifier.as_str())
.map_err(|e| anyhow::anyhow!("{}", e))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
if let Some(ref token) = manager.token { if let Some(ref token) = manager.token {
let token_path = dirs::config_dir() let token_path = dirs::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".")) .unwrap_or_else(|| std::path::PathBuf::from("."))
.join("zesdex") .join("zesdex")
.join(format!("oauth_{}.json", provider)); .join(format!("oauth_{provider}.json"));
if let Some(parent) = token_path.parent() { if let Some(parent) = token_path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
@@ -1713,7 +1705,7 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
} }
} }
Ok(format!("Successfully authenticated with {}.", provider)) Ok(format!("Successfully authenticated with {provider}."))
} }
/// Spawn a background thread that checks API reachability via a lightweight HEAD /// Spawn a background thread that checks API reachability via a lightweight HEAD
@@ -1722,16 +1714,14 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
/// ///
/// 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 3s connect / 5s total timeout → HEAD the `/models` endpoint → push
/// a `connectivity` SystemNote with the result. /// a `connectivity` `SystemNote` with the result.
/// ///
/// Why: runs off the event loop so a slow/TIMEOUT network does not block the TUI. /// Why: runs off the event loop so a slow/TIMEOUT network does not block the TUI.
fn spawn_api_connectivity_check(state: &AppStateRest) { fn spawn_api_connectivity_check(state: &AppStateRest) {
let base_url = state let base_url = state
.app_config .app_config
.providers .providers
.get(&state.settings.provider) .get(&state.settings.provider).map_or_else(|| crate::service::provider::DEFAULT_BASE_URL.to_string(), |p| p.api_base.clone());
.map(|p| p.api_base.clone())
.unwrap_or_else(|| crate::service::provider::DEFAULT_BASE_URL.to_string());
let turn_events = state.turn_events.clone(); let turn_events = state.turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
message: format!("unknown command: {}", cmd), message: format!("unknown command: {cmd}"),
}] }]
} }
} }
+5 -4
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Short-send / message shaping: compacts long conversation histories so //! Short-send / message shaping: compacts long conversation histories so
//! they fit within the provider's context window before being sent to the //! they fit within the provider's context window before being sent to the
//! LLM API. //! LLM API.
@@ -59,10 +60,10 @@ pub fn shape_messages(
// Always keep the very first message (System Prompt) which we don't count here // Always keep the very first message (System Prompt) which we don't count here
// as we just blindly preserve it later. // as we just blindly preserve it later.
let mut msgs_to_eval = messages.to_vec(); let mut msgs_to_eval = messages.to_vec();
let first = if !msgs_to_eval.is_empty() { let first = if msgs_to_eval.is_empty() {
Some(msgs_to_eval.remove(0))
} else {
None None
} else {
Some(msgs_to_eval.remove(0))
}; };
// Iterate backwards from the most recent to oldest // Iterate backwards from the most recent to oldest
@@ -105,7 +106,7 @@ pub fn shape_messages(
match llm.chat_with_tools_non_streaming(&req_msgs, None) { match llm.chat_with_tools_non_streaming(&req_msgs, None) {
Ok(resp) => { Ok(resp) => {
if let Some(content) = resp.0.content { if let Some(content) = resp.0.content {
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content); summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]");
} }
} }
Err(e) => { Err(e) => {
+16 -25
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into //! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done). //! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn; pub mod turn;
@@ -88,6 +89,7 @@ impl SseParser {
/// provider-specific parsing layer. /// provider-specific parsing layer.
/// ///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame. /// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
#[allow(clippy::too_many_lines)]
fn flush_event(&mut self) -> Vec<StreamEvent> { fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n"); let data = self.data_lines.join("\n");
self.data_lines.clear(); self.data_lines.clear();
@@ -107,15 +109,15 @@ impl SseParser {
}; };
if let Some(usage) = value.get("usage") { if let Some(usage) = value.get("usage") {
if !usage.is_null() { if !usage.is_null() {
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| { let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] prompt_tokens missing in usage chunk"); tracing::warn!("[stream] prompt_tokens missing in usage chunk");
0 0
}); });
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| { let completion_tokens = usage.get("completion_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] completion_tokens missing in usage chunk"); tracing::warn!("[stream] completion_tokens missing in usage chunk");
0 0
}); });
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64()) let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64)
.unwrap_or_else(|| { .unwrap_or_else(|| {
tracing::warn!("[stream] total_tokens missing in usage chunk"); tracing::warn!("[stream] total_tokens missing in usage chunk");
prompt_tokens + completion_tokens prompt_tokens + completion_tokens
@@ -126,12 +128,11 @@ impl SseParser {
// in the same chunk; emitting both prevents content loss. // in the same chunk; emitting both prevents content loss.
let has_other_content = value.get("choices") let has_other_content = value.get("choices")
.and_then(|c| c.as_array()) .and_then(|c| c.as_array())
.map(|arr| arr.iter().any(|ch| { .is_some_and(|arr| arr.iter().any(|ch| {
ch.get("delta").and_then(|d| d.get("content")).is_some() ch.get("delta").and_then(|d| d.get("content")).is_some()
|| ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some() || ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some()
|| ch.get("delta").and_then(|d| d.get("tool_calls")).is_some() || ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
})) }));
.unwrap_or(false);
if !has_other_content { if !has_other_content {
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }]; return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
} }
@@ -139,21 +140,11 @@ impl SseParser {
} }
match event_type.as_str() { match event_type.as_str() {
"message.stop" => vec![StreamEvent::Done], "message.stop" => vec![StreamEvent::Done],
"message.start" => vec![],
"message.delta" | "" => { "message.delta" | "" => {
let delta = match value.get("delta").or_else(|| value.get("choices")) { let Some(delta) = value.get("delta").or_else(|| value.get("choices")) else { return vec![] };
Some(d) => d,
None => return vec![],
};
if let Some(choices) = delta.as_array() { if let Some(choices) = delta.as_array() {
let choice = match choices.first() { let Some(choice) = choices.first() else { return vec![] };
Some(c) => c, let Some(d) = choice.get("delta") else { return vec![] };
None => return vec![],
};
let d = match choice.get("delta") {
Some(v) => v,
None => return vec![],
};
// Content token // Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) { if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
@@ -169,15 +160,15 @@ impl SseParser {
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) { if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
let mut events = Vec::with_capacity(tool_calls.len()); let mut events = Vec::with_capacity(tool_calls.len());
for tc in tool_calls { for tc in tool_calls {
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| { let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] tool call delta missing index, defaulting to 0"); tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
0 0
}) as usize; }) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
let name = tc.get("function") let name = tc.get("function")
.and_then(|f| f.get("name")) .and_then(|f| f.get("name"))
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.map(|s| s.to_string()); .map(std::string::ToString::to_string);
let args_delta = tc.get("function") let args_delta = tc.get("function")
.and_then(|f| f.get("arguments")) .and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str()) .and_then(|a| a.as_str())
@@ -253,15 +244,15 @@ pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
} }
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
if let Some(tc) = tool_calls.first() { if let Some(tc) = tool_calls.first() {
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| { let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0"); tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0");
0 0
}) as usize; }) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
let name = tc.get("function") let name = tc.get("function")
.and_then(|f| f.get("name")) .and_then(|f| f.get("name"))
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.map(|s| s.to_string()); .map(std::string::ToString::to_string);
let args = tc.get("function") let args = tc.get("function")
.and_then(|f| f.get("arguments")) .and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str()) .and_then(|a| a.as_str())
+2 -2
View File
@@ -84,12 +84,12 @@ impl StreamedTurn {
let tc = &mut self.tool_calls[*index]; let tc = &mut self.tool_calls[*index];
if let Some(new_id) = id { if let Some(new_id) = id {
if !new_id.is_empty() { if !new_id.is_empty() {
tc.id = new_id.clone(); tc.id.clone_from(new_id);
} }
} }
if let Some(new_name) = name { if let Some(new_name) = name {
if !new_name.is_empty() { if !new_name.is_empty() {
tc.name = new_name.clone(); tc.name.clone_from(new_name);
} }
} }
tc.arguments.push_str(arguments_delta); tc.arguments.push_str(arguments_delta);
+10 -9
View File
@@ -139,7 +139,7 @@ impl InputState {
self.autocomplete_candidates = COMMANDS self.autocomplete_candidates = COMMANDS
.iter() .iter()
.filter(|c| c.starts_with(&prefix)) .filter(|c| c.starts_with(&prefix))
.map(|c| c.to_string()) .map(std::string::ToString::to_string)
.collect(); .collect();
self.autocomplete_prefix = prefix; self.autocomplete_prefix = prefix;
self.autocomplete_idx = 0; self.autocomplete_idx = 0;
@@ -178,10 +178,10 @@ impl InputState {
pub fn tab_complete(&mut self) { pub fn tab_complete(&mut self) {
// Legacy inline tab-complete — used as a fallback when the dropdown // Legacy inline tab-complete — used as a fallback when the dropdown
// isn't visible yet. Opens the dropdown on the first Tab press. // isn't visible yet. Opens the dropdown on the first Tab press.
if !self.autocomplete_visible { if self.autocomplete_visible {
self.open_autocomplete();
} else {
self.cycle_autocomplete(true); self.cycle_autocomplete(true);
} else {
self.open_autocomplete();
} }
} }
@@ -232,7 +232,7 @@ impl InputState {
.open(path) .open(path)
{ {
use std::io::Write; use std::io::Write;
let _ = writeln!(file, "{}", result); let _ = writeln!(file, "{result}");
} }
} }
} }
@@ -294,10 +294,11 @@ pub struct MiscState {
pub tick_count: u64, pub tick_count: u64,
pub todo_content: String, pub todo_content: String,
/// Pipeline mode override set by `/pipeline` command. /// Pipeline mode override set by `/pipeline` command.
/// - `None`: auto-detect (default) /// - `None`: auto-detect (default)
/// - `Some("full")`: force full pipeline /// - `Some("full")`: force full pipeline
/// - `Some("quick")`: force quick pipeline /// - `Some("quick")`: force quick pipeline
/// - `Some("skip")`: skip pipeline, handle directly /// - `Some("skip")`: skip pipeline, handle directly
///
/// Consumed on the next agent turn. /// Consumed on the next agent turn.
pub pipeline_override: Option<String>, pub pipeline_override: Option<String>,
} }
+16 -24
View File
@@ -84,7 +84,7 @@ impl AppStateRest {
/// Why: falls back to `memory_dir` itself (with a warning) when it has /// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be /// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails. /// read, so construction never fails.
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self { pub fn new(workspace_roots: Vec<PathBuf>, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self {
let settings = Settings::load(); let settings = Settings::load();
let app_config = AppConfig::load(); let app_config = AppConfig::load();
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| { let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
@@ -93,27 +93,25 @@ impl AppStateRest {
}).join("worktrees"); }).join("worktrees");
let dir_cache = DirCache::new(); let dir_cache = DirCache::new();
let session_id = session_dir let session_id = session_dir
.file_name() .file_name().map_or_else(|| {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| {
tracing::warn!("[state] session_dir has no file_name component, using empty session_id"); tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
String::new() String::new()
}); }, |n| n.to_string_lossy().to_string());
let mut state = AppStateRest { let mut state = AppStateRest {
settings, settings,
app_config, app_config,
workspace_roots, workspace_roots,
session_id, session_id,
session_dir: session_dir.clone(), session_dir: session_dir.to_path_buf(),
memory_dir, memory_dir,
worktrees_dir, worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight: Arc::new(Mutex::new(false)), turn_in_flight: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)), dir_cache: Arc::new(RwLock::new(dir_cache)),
edit_log: EditLog::new(&session_dir), edit_log: EditLog::new(session_dir),
session_runtime: Some(SessionRuntime::new(session_dir.clone())), session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: WorkflowEngine::new(), workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(), mcp_manager: McpManager::new(),
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())), lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
@@ -135,9 +133,7 @@ impl AppStateRest {
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes()); hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = format!("{:x}", hasher.finalize()); let hash_hex = format!("{:x}", hasher.finalize());
let folder_name = abs_root.file_name() let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "root".to_string());
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
let history_dir = base_dir.join("history"); let history_dir = base_dir.join("history");
let _ = std::fs::create_dir_all(&history_dir); let _ = std::fs::create_dir_all(&history_dir);
@@ -146,7 +142,7 @@ impl AppStateRest {
if let Ok(content) = std::fs::read_to_string(&history_file) { if let Ok(content) = std::fs::read_to_string(&history_file) {
let history: Vec<String> = content let history: Vec<String> = content
.lines() .lines()
.map(|s| s.to_string()) .map(std::string::ToString::to_string)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(); .collect();
state.input.history = history; state.input.history = history;
@@ -192,12 +188,12 @@ impl AppStateRest {
let connected = provisioner::auto_connect(&lsp_mgr, &results); let connected = provisioner::auto_connect(&lsp_mgr, &results);
for name in &connected { for name in &connected {
tracing::info!("LSP: {} connected", name); tracing::info!("LSP: {} connected", name);
let m = format!("LSP: {} connected ✓", name); push_msg(&msg_queue, &m); let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m);
} }
for r in &results { for r in &results {
if let ProvisionResult::Failed { language, server_name, reason, .. } = r { if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
tracing::warn!("LSP {} ({}): {}", server_name, language, reason); tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
let m = format!("LSP: {} ({}) ✗ - {}", server_name, language, reason); push_msg(&msg_queue, &m); let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m);
} }
} }
if connected.is_empty() { if connected.is_empty() {
@@ -216,10 +212,10 @@ impl AppStateRest {
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather /// Return: `false` (and logs a warning) if the mutex is poisoned, rather
/// than propagating a panic. /// than propagating a panic.
pub fn turn_in_flight(&self) -> bool { pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| { self.turn_in_flight.lock().map_or_else(|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned"); tracing::warn!("[state] turn_in_flight mutex poisoned");
false false
}) }, |g| *g)
} }
/// Shut down every running LSP server process. /// Shut down every running LSP server process.
@@ -259,17 +255,13 @@ impl AppStateRest {
/// never fails even on a shallow path. /// never fails even on a shallow path.
pub fn store_base_dir(&self) -> std::path::PathBuf { pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir.parent() self.session_dir.parent()
.and_then(|p| p.parent()) .and_then(|p| p.parent()).map_or_else(|| {
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display()); tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
self.session_dir.parent() self.session_dir.parent().map_or_else(|| {
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display()); tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
self.session_dir.clone() self.session_dir.clone()
}) }, std::path::Path::to_path_buf)
}) }, std::path::Path::to_path_buf)
} }
/// Build a `ToolCtx` for tool calls originating from the main agent. /// Build a `ToolCtx` for tool calls originating from the main agent.
+2 -1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Shared small state types: toasts, overlays, the transcript cache, //! Shared small state types: toasts, overlays, the transcript cache,
//! tool execution model, and call origin tags. //! tool execution model, and call origin tags.
@@ -109,7 +110,7 @@ pub enum Origin {
impl Origin { impl Origin {
/// Short string tag for this origin, used in filenames and logs. /// Short string tag for this origin, used in filenames and logs.
pub fn tag(&self) -> String { pub fn tag(self) -> String {
match self { match self {
Origin::Main => "main".to_string(), Origin::Main => "main".to_string(),
Origin::SubAgent => "subagent".to_string(), Origin::SubAgent => "subagent".to_string(),
+47 -41
View File
@@ -72,12 +72,18 @@ fn is_production_code(path: &str) -> bool {
if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") { if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") {
return false; return false;
} }
// Only source files // Only source files — use Path::extension() to avoid clippy
lower.ends_with(".rs") || lower.ends_with(".ts") || lower.ends_with(".tsx") // case_sensitive_file_extension_comparisons lint
|| lower.ends_with(".js") || lower.ends_with(".jsx") || lower.ends_with(".go") std::path::Path::new(&lower)
|| lower.ends_with(".py") || lower.ends_with(".java") || lower.ends_with(".kt") .extension()
|| lower.ends_with(".swift") || lower.ends_with(".c") || lower.ends_with(".cpp") .and_then(|ext| ext.to_str())
|| lower.ends_with(".h") || lower.ends_with(".hpp") .is_some_and(|ext| {
matches!(
ext,
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
| "c" | "cpp" | "h" | "hpp"
)
})
} }
/// ─── Inline Quick Review (synchronous, feeds back to LLM) ─── /// ─── Inline Quick Review (synchronous, feeds back to LLM) ───
@@ -111,7 +117,7 @@ pub fn spawn_quick_review(
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(QUICK_REVIEW_MAX_STEPS); .with_max_steps(QUICK_REVIEW_MAX_STEPS);
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec(); ctx.workspaces = workspaces.to_vec();
@@ -119,11 +125,11 @@ pub fn spawn_quick_review(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[auto-review] tool call: {}", _tool); tracing::debug!("[auto-review] tool call: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[auto-review] tool result: {}", _tool); tracing::debug!("[auto-review] tool result: {}", tool);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[auto-review] completed"); tracing::debug!("[auto-review] completed");
@@ -133,7 +139,7 @@ pub fn spawn_quick_review(
} }
}); });
let verdict = run_subagent(ctx, tx)?; let verdict = run_subagent(&ctx, &tx)?;
tracing::info!( tracing::info!(
"[auto-review] quick review for '{}': {}", "[auto-review] quick review for '{}': {}",
file_path, file_path,
@@ -142,7 +148,7 @@ pub fn spawn_quick_review(
Ok(verdict) Ok(verdict)
} }
/// ─── Background Subagent Spawners (async, report via SystemNote) ─── /// ─── Background Subagent Spawners (async, report via `SystemNote`) ───
/// ///
/// Spawn a background subagent that generates tests for modified files. /// Spawn a background subagent that generates tests for modified files.
/// ///
@@ -185,7 +191,7 @@ pub fn spawn_background_test_gen(
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); .with_max_steps(BG_SUBAGENT_MAX_STEPS);
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = sd; ctx.session_dir = sd;
ctx.workspaces = ws; ctx.workspaces = ws;
@@ -193,17 +199,17 @@ pub fn spawn_background_test_gen(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-test-gen] tool: {}", _tool); tracing::debug!("[bg-test-gen] tool: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-test-gen] result: {}", _tool); tracing::debug!("[bg-test-gen] result: {}", tool);
} }
SubagentEvent::StepCompleted { _step, .. } => { SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[bg-test-gen] step {} done", _step); tracing::trace!("[bg-test-gen] step done");
} }
SubagentEvent::StepFailed { _step, _error } => { SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[bg-test-gen] step {} failed: {}", _step, _error); tracing::warn!("[bg-test-gen] step {} failed: {}", step, error);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-test-gen] completed"); tracing::debug!("[bg-test-gen] completed");
@@ -212,13 +218,13 @@ pub fn spawn_background_test_gen(
} }
}); });
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Auto test-gen: {}", first) format!("Auto test-gen: {first}")
} }
Err(e) => format!("Auto test-gen failed: {}", e), Err(e) => format!("Auto test-gen failed: {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
@@ -265,7 +271,7 @@ pub fn spawn_background_arch_review(
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); .with_max_steps(BG_SUBAGENT_MAX_STEPS);
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = sd; ctx.session_dir = sd;
ctx.workspaces = ws; ctx.workspaces = ws;
@@ -273,11 +279,11 @@ pub fn spawn_background_arch_review(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-arch] tool: {}", _tool); tracing::debug!("[bg-arch] tool: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-arch] result: {}", _tool); tracing::debug!("[bg-arch] result: {}", tool);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-arch] completed"); tracing::debug!("[bg-arch] completed");
@@ -287,13 +293,13 @@ pub fn spawn_background_arch_review(
} }
}); });
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Architecture review: {}", first) format!("Architecture review: {first}")
} }
Err(e) => format!("Architecture review failed: {}", e), Err(e) => format!("Architecture review failed: {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
@@ -351,7 +357,7 @@ pub fn spawn_background_security_review(
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); .with_max_steps(BG_SUBAGENT_MAX_STEPS);
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = sd; ctx.session_dir = sd;
ctx.workspaces = ws; ctx.workspaces = ws;
@@ -359,11 +365,11 @@ pub fn spawn_background_security_review(
let _drain = std::thread::spawn(move || { let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, .. } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-security] tool: {}", _tool); tracing::debug!("[bg-security] tool: {}", tool);
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-security] result: {}", _tool); tracing::debug!("[bg-security] result: {}", tool);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-security] completed"); tracing::debug!("[bg-security] completed");
@@ -373,13 +379,13 @@ pub fn spawn_background_security_review(
} }
}); });
let result = run_subagent(ctx, tx); let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Security review: {}", first) format!("Security review: {first}")
} }
Err(e) => format!("Security review failed: {}", e), Err(e) => format!("Security review failed: {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
+2 -2
View File
@@ -37,10 +37,10 @@ pub struct SubagentContext {
/// ///
/// Return: a context with empty `system_prompt`, empty `workspaces`, /// Return: a context with empty `system_prompt`, empty `workspaces`,
/// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list. /// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list.
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext { pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
if def.role == "reviewer" { if def.role == "reviewer" {
REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect() REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect()
} else { } else {
Vec::new() Vec::new()
} }
+1 -1
View File
@@ -16,7 +16,7 @@
use crate::app::subagent::spawn::AgentDefinition; use crate::app::subagent::spawn::AgentDefinition;
/// Division roles — used as both the `role` field in AgentDefinition /// Division roles — used as both the `role` field in `AgentDefinition`
/// and as the key for pipeline routing. /// and as the key for pipeline routing.
pub mod roles { pub mod roles {
/// Strategy Division: plans architecture, creates diagrams, breaks down work. /// Strategy Division: plans architecture, creates diagrams, breaks down work.
+40 -41
View File
@@ -7,6 +7,7 @@
//! bash exfiltration and destructive-pattern detection) so that subagents //! bash exfiltration and destructive-pattern detection) so that subagents
//! are not a weaker link than the main agent. //! are not a weaker link than the main agent.
use std::fmt::Write;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef; use crate::dto::provider::request::ToolDef;
@@ -143,8 +144,7 @@ fn gate_subagent_tool_call(
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or(""); let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason.trim().len() < MIN_REASON_LEN { if reason.trim().len() < MIN_REASON_LEN {
return Some(format!( return Some(format!(
"{} requires a non-trivial 'reason' (>= {} chars) explaining why", "{tool_name} requires a non-trivial 'reason' (>= {MIN_REASON_LEN} chars) explaining why",
tool_name, MIN_REASON_LEN,
)); ));
} }
} }
@@ -157,9 +157,7 @@ fn gate_subagent_tool_call(
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
// For edits, scanning old+new together catches stubs in both // For edits, scanning old+new together catches stubs in both
return if contains_any(old, STUB_PATTERNS) { return if contains_any(old, STUB_PATTERNS) || contains_any(new, STUB_PATTERNS) {
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
} else if contains_any(new, STUB_PATTERNS) {
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string()) Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
} else if contains_any(new, DENIAL_PATTERNS) { } else if contains_any(new, DENIAL_PATTERNS) {
Some("content contains denial/punt pattern; implement properly instead of skipping".to_string()) Some("content contains denial/punt pattern; implement properly instead of skipping".to_string())
@@ -202,13 +200,13 @@ fn gate_subagent_tool_call(
if !is_standard { if !is_standard {
for pat in EXFIL_PATTERNS { for pat in EXFIL_PATTERNS {
if cmd.contains(pat) { if cmd.contains(pat) {
return Some(format!("potential data-exfiltration command blocked (matched '{}')", pat)); return Some(format!("potential data-exfiltration command blocked (matched '{pat}')"));
} }
} }
} }
for pat in SENSITIVE_PATH_PATTERNS { for pat in SENSITIVE_PATH_PATTERNS {
if cmd.contains(pat) { if cmd.contains(pat) {
return Some(format!("refused to read/write sensitive path '{}'", pat)); return Some(format!("refused to read/write sensitive path '{pat}'"));
} }
} }
let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~", let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~",
@@ -216,7 +214,7 @@ fn gate_subagent_tool_call(
"chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "]; "chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "];
for pat in &dangerous { for pat in &dangerous {
if cmd.contains(pat) { if cmd.contains(pat) {
return Some(format!("destructive command pattern blocked: {}", pat)); return Some(format!("destructive command pattern blocked: {pat}"));
} }
} }
if contains_any(cmd, STUB_PATTERNS) { if contains_any(cmd, STUB_PATTERNS) {
@@ -251,7 +249,7 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let mut out = String::new(); let mut out = String::new();
out.push_str("Current Workspace Directory Structure:\n"); out.push_str("Current Workspace Directory Structure:\n");
for root in roots { for root in roots {
out.push_str(&format!("Root: {}\n", root.display())); writeln!(out, "Root: {}", root.display()).unwrap();
let walker = ignore::WalkBuilder::new(root) let walker = ignore::WalkBuilder::new(root)
.hidden(true) .hidden(true)
.git_ignore(true) .git_ignore(true)
@@ -261,9 +259,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
let path = entry.path(); let path = entry.path();
if let Ok(rel) = path.strip_prefix(root) { if let Ok(rel) = path.strip_prefix(root) {
if rel.as_os_str().is_empty() { continue; } if rel.as_os_str().is_empty() { continue; }
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let prefix = if is_dir { "[DIR] " } else { " " }; let prefix = if is_dir { "[DIR] " } else { " " };
out.push_str(&format!(" {}{}\n", prefix, rel.display())); writeln!(out, " {}{}", prefix, rel.display()).unwrap();
count += 1; count += 1;
if count > 1000 { if count > 1000 {
out.push_str(" ... (truncated)\n"); out.push_str(" ... (truncated)\n");
@@ -291,7 +289,8 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
/// ///
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM /// Return: the concatenated text output, or an `anyhow::Error` if the LLM
/// call fails at any step. /// call fails at any step.
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> { #[allow(clippy::too_many_lines)]
pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
let mut output = String::new(); let mut output = String::new();
let mut messages: Vec<ChatMessage> = Vec::new(); let mut messages: Vec<ChatMessage> = Vec::new();
@@ -328,10 +327,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
// be cancelled from the parent (mirrors main agent behaviour). // be cancelled from the parent (mirrors main agent behaviour).
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
let _ = tx.blocking_send(SubagentEvent::StepFailed { let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step, step,
_error: "subagent aborted by parent".to_string(), error: "subagent aborted by parent".to_string(),
}); });
anyhow::bail!("subagent aborted by parent at step {}", step); anyhow::bail!("subagent aborted by parent at step {step}");
} }
// Use the structured tool-calling API so the LLM can request tools with // Use the structured tool-calling API so the LLM can request tools with
@@ -340,10 +339,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
let _ = tx.blocking_send(SubagentEvent::StepFailed { let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step, step,
_error: e.to_string(), error: e.to_string(),
}); });
anyhow::bail!("subagent call failed at step {}: {}", step, e); anyhow::bail!("subagent call failed at step {step}: {e}");
} }
}; };
@@ -361,10 +360,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
// Check abort flag before each tool execution // Check abort flag before each tool execution
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
let _ = tx.blocking_send(SubagentEvent::StepFailed { let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step, step,
_error: "subagent aborted by parent during tool execution".to_string(), error: "subagent aborted by parent during tool execution".to_string(),
}); });
anyhow::bail!("subagent aborted by parent during tool call at step {}", step); anyhow::bail!("subagent aborted by parent during tool call at step {step}");
} }
let tool_name = &tool_call.function.name; let tool_name = &tool_call.function.name;
@@ -373,28 +372,28 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed; let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
let _ = tx.blocking_send(SubagentEvent::ToolCall { let _ = tx.blocking_send(SubagentEvent::ToolCall {
_tool: tool_name.clone(), tool: tool_name.clone(),
_args: args.clone(), args: args.clone(),
}); });
// Level 1: allowlist check — is this tool even permitted? // Level 1: allowlist check — is this tool even permitted?
if !generally_allowed { if !generally_allowed {
let msg = format!("tool '{}' not allowed for this subagent", tool_name); let msg = format!("tool '{tool_name}' not allowed for this subagent");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: msg, output: msg,
}); });
continue; continue;
} }
// Level 2: risky tool check — risky tools require explicit permission // Level 2: risky tool check — risky tools require explicit permission
if tool_is_risky(tool_name) && !explicitly_allowed { if tool_is_risky(tool_name) && !explicitly_allowed {
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name); let msg = format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: msg, output: msg,
}); });
continue; continue;
} }
@@ -404,34 +403,34 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
// stub/denial/assumption scanning, bash exfiltration, destructive // stub/denial/assumption scanning, bash exfiltration, destructive
// commands, sensitive path reads). // commands, sensitive path reads).
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) { if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
let msg = format!("Blocked by subagent gate: {}", block_reason); let msg = format!("Blocked by subagent gate: {block_reason}");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: msg, output: msg,
}); });
continue; continue;
} }
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) { let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => tool.run(&tool_ctx, &args), Some(tool) => tool.run(&tool_ctx, &args),
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)), None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
}; };
match result { match result {
Ok(output_text) => { Ok(output_text) => {
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: output_text, output: output_text,
}); });
} }
Err(e) => { Err(e) => {
let msg = format!("tool '{}' failed: {}", tool_name, e); let msg = format!("tool '{tool_name}' failed: {e}");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(), tool: tool_name.clone(),
_output: msg, output: msg,
}); });
} }
} }
@@ -443,8 +442,8 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
output.push('\n'); output.push('\n');
} }
let _ = tx.blocking_send(SubagentEvent::StepCompleted { let _ = tx.blocking_send(SubagentEvent::StepCompleted {
_step: step, step,
_output: content.clone(), output: content.clone(),
}); });
// Break only when we got real content; empty means something went wrong // Break only when we got real content; empty means something went wrong
if !content.is_empty() { if !content.is_empty() {
@@ -453,6 +452,6 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
} }
} }
let _ = tx.blocking_send(SubagentEvent::Completed { _output: output.clone() }); let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
Ok(output) Ok(output)
} }
+14 -9
View File
@@ -8,22 +8,27 @@ use serde_json::Value;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SubagentEvent { pub enum SubagentEvent {
StepCompleted { StepCompleted {
_step: usize, #[allow(dead_code)]
_output: String, step: usize,
#[allow(dead_code)]
output: String,
}, },
StepFailed { StepFailed {
_step: usize, step: usize,
_error: String, error: String,
}, },
Completed { Completed {
_output: String, #[allow(dead_code)]
output: String,
}, },
ToolCall { ToolCall {
_tool: String, tool: String,
_args: Value, #[allow(dead_code)]
args: Value,
}, },
ToolResult { ToolResult {
_tool: String, tool: String,
_output: String, #[allow(dead_code)]
output: String,
}, },
} }
+1 -1
View File
@@ -1,4 +1,4 @@
//! AgentDefinition -- declarative specification for instantiating a //! `AgentDefinition` -- declarative specification for instantiating a
//! subagent from workflow scripts or programmatic calls. //! subagent from workflow scripts or programmatic calls.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+9 -12
View File
@@ -21,6 +21,7 @@
//! ``` //! ```
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Write;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus}; use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript}; use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
@@ -64,9 +65,7 @@ pub fn run_company_pipeline(
let wf = WorkflowScript { let wf = WorkflowScript {
name: "company-pipeline".to_string(), name: "company-pipeline".to_string(),
description: format!( description: "Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation".to_string(),
"Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation",
),
script: ScriptPrimitive::Pipeline(pipeline_scripts), script: ScriptPrimitive::Pipeline(pipeline_scripts),
options: ScriptOptions { options: ScriptOptions {
max_concurrency: 1, // sequential by design max_concurrency: 1, // sequential by design
@@ -197,21 +196,19 @@ fn build_executive_summary(
divisions: &[division::Division], divisions: &[division::Division],
) -> String { ) -> String {
let mut summary = String::new(); let mut summary = String::new();
summary.push_str(&format!("Pipeline for: {}\n", request)); writeln!(summary, "Pipeline for: {request}").unwrap();
for (i, div) in divisions.iter().enumerate() { for (i, div) in divisions.iter().enumerate() {
let verdict = results.get(i) let verdict = results.get(i).map_or_else(|| "".to_string(), |r| {
.map(|r| {
r.lines().next().unwrap_or(r) r.lines().next().unwrap_or(r)
.chars().take(100).collect::<String>() .chars().take(100).collect::<String>()
}) });
.unwrap_or_else(|| "".to_string());
summary.push_str(&format!(" {}: {}\n", div.name, verdict)); writeln!(summary, " {}: {}", div.name, verdict).unwrap();
} }
if !findings.is_empty() { if !findings.is_empty() {
summary.push_str(&format!(" Notes: {} cross-division finding(s)\n", findings.len())); writeln!(summary, " Notes: {} cross-division finding(s)", findings.len()).unwrap();
} }
summary summary
@@ -223,7 +220,7 @@ fn build_executive_summary(
/// Simple = single file, minor fix, quick lookup, config change. /// Simple = single file, minor fix, quick lookup, config change.
/// Complex = new feature, multi-file refactor, architecture change. /// Complex = new feature, multi-file refactor, architecture change.
/// ///
/// Used by the auto-CEO pipeline trigger in run_agent_turn to decide /// Used by the auto-CEO pipeline trigger in `run_agent_turn` to decide
/// whether to delegate to the full company pipeline or handle directly. /// whether to delegate to the full company pipeline or handle directly.
/// ///
/// Heuristics: /// Heuristics:
@@ -249,7 +246,7 @@ pub fn is_complex_request(request: &str) -> bool {
return false; return false;
} }
// Multi-line/multi-sentence → likely complex // Multi-line/multi-sentence → likely complex
let sentences = trimmed.split(|c| c == '.' || c == '!' || c == '?') let sentences = trimmed.split(['.', '!', '?'])
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.count(); .count();
if sentences >= 3 { if sentences >= 3 {
+27 -27
View File
@@ -83,7 +83,7 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// any findings from earlier sibling agents. Updates live state before and /// any findings from earlier sibling agents. Updates live state before and
/// after to reflect Running → Completed/Failed transitions. /// after to reflect Running → Completed/Failed transitions.
/// ///
/// Flow: push agent as `Running` → build SubagentContext with prompt + /// Flow: push agent as `Running` → build `SubagentContext` with prompt +
/// findings preamble, linking the `workflow_findings` Arc so the subagent's /// findings preamble, linking the `workflow_findings` Arc so the subagent's
/// `note_finding` tool pushes into the same vec → call `run_subagent` /// `note_finding` tool pushes into the same vec → call `run_subagent`
/// (draining the event channel into a consumer so events are not blocked) /// (draining the event channel into a consumer so events are not blocked)
@@ -98,11 +98,12 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// a stuck stage from blocking the entire pipeline forever. /// a stuck stage from blocking the entire pipeline forever.
/// ///
/// Return: the agent's text output, or an error on failure. /// Return: the agent's text output, or an error on failure.
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
fn spawn_single_agent( fn spawn_single_agent(
agent_id: &str, agent_id: &str,
agent_name: &str, agent_name: &str,
prompt: &str, prompt: &str,
findings_snapshot: Vec<String>, findings_snapshot: &[String],
findings: &Arc<Mutex<Vec<String>>>, findings: &Arc<Mutex<Vec<String>>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
@@ -134,7 +135,7 @@ fn spawn_single_agent(
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string()) let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string())
.with_max_steps(50); .with_max_steps(50);
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec(); ctx.workspaces = workspaces.to_vec();
@@ -152,7 +153,7 @@ fn spawn_single_agent(
) )
}; };
ctx.system_prompt = format!("{}{}", prompt, findings_section); ctx.system_prompt = format!("{prompt}{findings_section}");
// Link the shared findings Arc so note_finding calls within this // Link the shared findings Arc so note_finding calls within this
// subagent write into the same vec visible to sibling agents. // subagent write into the same vec visible to sibling agents.
ctx.workflow_findings = Some(findings.clone()); ctx.workflow_findings = Some(findings.clone());
@@ -174,8 +175,8 @@ fn spawn_single_agent(
let mut rx = rx; let mut rx = rx;
while let Some(event) = rx.blocking_recv() { while let Some(event) = rx.blocking_recv() {
match &event { match &event {
SubagentEvent::ToolCall { _tool, _args } => { SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[subagent] tool call: {}", _tool); tracing::debug!("[subagent] tool call: {}", tool);
// Push intra-division progress: which tool is running // Push intra-division progress: which tool is running
if let Some(ref f) = drain_live { if let Some(ref f) = drain_live {
f( f(
@@ -186,13 +187,13 @@ fn spawn_single_agent(
started_at: Some(drain_started_at), started_at: Some(drain_started_at),
completed_at: None, completed_at: None,
error: None, error: None,
progress: Some(format!("tool: {}", _tool)), progress: Some(format!("tool: {tool}")),
}, },
); );
} }
} }
SubagentEvent::ToolResult { _tool, .. } => { SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[subagent] tool result: {}", _tool); tracing::debug!("[subagent] tool result: {}", tool);
if let Some(ref f) = drain_live { if let Some(ref f) = drain_live {
f( f(
drain_agent_id.clone(), drain_agent_id.clone(),
@@ -202,16 +203,16 @@ fn spawn_single_agent(
started_at: Some(drain_started_at), started_at: Some(drain_started_at),
completed_at: None, completed_at: None,
error: None, error: None,
progress: Some(format!("done: {}", _tool)), progress: Some(format!("done: {tool}")),
}, },
); );
} }
} }
SubagentEvent::StepCompleted { _step, .. } => { SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[subagent] step {} completed", _step); tracing::trace!("[subagent] step completed");
} }
SubagentEvent::StepFailed { _step, _error } => { SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[subagent] step {} failed: {}", _step, _error); tracing::warn!("[subagent] step {} failed: {}", step, error);
} }
SubagentEvent::Completed { .. } => { SubagentEvent::Completed { .. } => {
tracing::debug!("[subagent] completed"); tracing::debug!("[subagent] completed");
@@ -230,17 +231,16 @@ fn spawn_single_agent(
let timeout_ctx = ctx; let timeout_ctx = ctx;
let timeout_tx = tx; let timeout_tx = tx;
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = done_tx.send(run_subagent(timeout_ctx, timeout_tx)); let _ = done_tx.send(run_subagent(&timeout_ctx, &timeout_tx));
}); });
match done_rx.recv_timeout(Duration::from_millis(timeout)) { match done_rx.recv_timeout(Duration::from_millis(timeout)) {
Ok(r) => r, Ok(r) => r,
Err(_) => Err(anyhow::anyhow!( Err(_) => Err(anyhow::anyhow!(
"subagent '{}' timed out after {}ms", "subagent '{agent_name}' timed out after {timeout}ms",
agent_name, timeout,
)), )),
} }
} else { } else {
run_subagent(ctx, tx) run_subagent(&ctx, &tx)
}; };
let completed_at = chrono::Utc::now().timestamp_millis(); let completed_at = chrono::Utc::now().timestamp_millis();
@@ -299,6 +299,7 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// ///
/// Return: a `Vec<String>` of all agent outputs (or error strings) in /// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted. /// the order they were submitted.
#[allow(clippy::too_many_arguments)]
pub fn execute_primitive( pub fn execute_primitive(
primitive: &ScriptPrimitive, primitive: &ScriptPrimitive,
args: &HashMap<String, String>, args: &HashMap<String, String>,
@@ -316,7 +317,7 @@ pub fn execute_primitive(
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default(); let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
let agent_id = uuid::Uuid::new_v4().to_string(); let agent_id = uuid::Uuid::new_v4().to_string();
let agent_name = resolved.chars().take(40).collect::<String>(); let agent_name = resolved.chars().take(40).collect::<String>();
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) { match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) {
Ok(text) => Ok(vec![text]), Ok(text) => Ok(vec![text]),
Err(e) => { Err(e) => {
if continue_on_error { if continue_on_error {
@@ -380,7 +381,7 @@ pub fn execute_primitive(
for (_, res) in locked.drain(..) { for (_, res) in locked.drain(..) {
match res { match res {
Ok(outputs) => all.extend(outputs), Ok(outputs) => all.extend(outputs),
Err(e) => all.push(format!("agent error: {}", e)), Err(e) => all.push(format!("agent error: {e}")),
} }
} }
Ok(all) Ok(all)
@@ -399,7 +400,7 @@ pub fn execute_primitive(
Ok(outputs) => all.extend(outputs), Ok(outputs) => all.extend(outputs),
Err(e) => { Err(e) => {
if continue_on_error { if continue_on_error {
all.push(format!("pipeline stage {} error: {}", idx, e)); all.push(format!("pipeline stage {idx} error: {e}"));
} else { } else {
return Err(e); return Err(e);
} }
@@ -437,13 +438,13 @@ pub fn run_workflow(
/// ///
/// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a /// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a
/// global static, so concurrent `run_workflow_tracked` calls from different /// global static, so concurrent `run_workflow_tracked` calls from different
/// spawn_agents invocations remain fully isolated. /// `spawn_agents` invocations remain fully isolated.
/// ///
/// Return: a human-readable summary string. /// Return: a human-readable summary string.
pub fn run_workflow_tracked( pub fn run_workflow_tracked(
script: &WorkflowScript, script: &WorkflowScript,
args: &HashMap<String, String>, args: &HashMap<String, String>,
live: Option<LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
@@ -453,11 +454,10 @@ pub fn run_workflow_tracked(
10 10
}; };
let live_ref = live.as_ref();
let findings = Arc::new(Mutex::new(Vec::new())); let findings = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive( let results = execute_primitive(
&script.script, args, concurrency_cap, &script.script, args, concurrency_cap,
script.options.continue_on_error, live_ref, script.options.continue_on_error, live,
session_dir, workspaces, &findings, session_dir, workspaces, &findings,
script.options.timeout_ms, script.options.timeout_ms,
)?; )?;
@@ -489,7 +489,7 @@ pub fn run_workflow_tracked(
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String { fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
let mut result = template.to_string(); let mut result = template.to_string();
for (key, value) in args { for (key, value) in args {
result = result.replace(&format!("{{{{{}}}}}", key), value); result = result.replace(&format!("{{{{{key}}}}}"), value);
} }
result result
} }
@@ -535,7 +535,7 @@ struct SemaphoreGuard<'a> {
sem: &'a Semaphore, sem: &'a Semaphore,
} }
impl<'a> Drop for SemaphoreGuard<'a> { impl Drop for SemaphoreGuard<'_> {
fn drop(&mut self) { fn drop(&mut self) {
let mut count = self.sem.count.lock().unwrap_or_else(|e| { let mut count = self.sem.count.lock().unwrap_or_else(|e| {
tracing::warn!("[semaphore] mutex poisoned in drop, recovering"); tracing::warn!("[semaphore] mutex poisoned in drop, recovering");
+1 -1
View File
@@ -87,7 +87,7 @@ pub fn parse_command(text: &str) -> Command {
mode: arg1.to_string(), mode: arg1.to_string(),
} }
} }
"/pipeline" => Command::Unknown(format!("/pipeline {} (use: full|quick|skip)", arg1)), "/pipeline" => Command::Unknown(format!("/pipeline {arg1} (use: full|quick|skip)")),
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
} }
} }
+11 -20
View File
@@ -21,6 +21,7 @@ use crate::controller::command::parse_command;
/// Why: when Editor overlay is active, all key events are consumed by the /// Why: when Editor overlay is active, all key events are consumed by the
/// editor handler and never reach the main action dispatch. Return `Vec` /// editor handler and never reach the main action dispatch. Return `Vec`
/// so that a single key press can trigger multiple actions. /// so that a single key press can trigger multiple actions.
#[allow(clippy::too_many_lines)]
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> { pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// While Editor overlay is active, route input directly to the editor handler // While Editor overlay is active, route input directly to the editor handler
if state.misc.overlay == Overlay::Editor { if state.misc.overlay == Overlay::Editor {
@@ -34,7 +35,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
if let Err(e) = std::fs::write(&ed.path, &content) { if let Err(e) = std::fs::write(&ed.path, &content) {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error, crate::app::state::types::ToastKind::Error,
format!("Save failed: {}", e), format!("Save failed: {e}"),
)); ));
} else { } else {
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
@@ -58,11 +59,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
return vec![]; return vec![];
} }
KeyCode::Enter => { KeyCode::Enter => {
crate::app::mode::editor::handle_editor_input(state, "\n".to_string()); crate::app::mode::editor::handle_editor_input(state, "\n");
return vec![]; return vec![];
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
crate::app::mode::editor::handle_editor_input(state, c.to_string()); crate::app::mode::editor::handle_editor_input(state, &c.to_string());
return vec![]; return vec![];
} }
_ => return vec![], _ => return vec![],
@@ -93,25 +94,15 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
} }
KeyCode::Enter | KeyCode::Char('a') => { KeyCode::Enter | KeyCode::Char('a') => {
let items = crate::app::mode::learning::get_learning_items(state); let items = crate::app::mode::learning::get_learning_items(state);
if let Some(item) = items.get(state.misc.selected_index) { if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
match item { return vec![Action::LessonAccept { name: name.clone() }];
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
return vec![Action::LessonAccept { name: name.clone() }];
}
_ => {}
}
} }
return vec![]; return vec![];
} }
KeyCode::Char('r') => { KeyCode::Char('r') => {
let items = crate::app::mode::learning::get_learning_items(state); let items = crate::app::mode::learning::get_learning_items(state);
if let Some(item) = items.get(state.misc.selected_index) { if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
match item { return vec![Action::LessonReject { name: name.clone() }];
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
return vec![Action::LessonReject { name: name.clone() }];
}
_ => {}
}
} }
return vec![]; return vec![];
} }
@@ -341,8 +332,8 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider); tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider);
"claude-opus-4-8".to_string() "claude-opus-4-8".to_string()
}); });
state.settings.provider = provider.clone(); state.settings.provider.clone_from(provider);
state.settings.model = model.clone(); state.settings.model.clone_from(&model);
if let Some(ref key) = cfg.default_api_key { if let Some(ref key) = cfg.default_api_key {
state.settings.api_keys.insert(provider.clone(), key.clone()); state.settings.api_keys.insert(provider.clone(), key.clone());
} else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) { } else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) {
@@ -351,7 +342,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
let _ = state.settings.save(); let _ = state.settings.save();
state.push_toast(crate::app::state::types::Toast::new( state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success, crate::app::state::types::ToastKind::Success,
format!("Switched to {} / {}", provider, model), format!("Switched to {provider} / {model}"),
)); ));
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@ use serde_json::Value;
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider. /// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider.
/// ///
/// Flow: constructed from the current message history plus optional /// Flow: constructed from the current message history plus optional
/// generation knobs (temperature, max_tokens, tools, etc.) and serialized /// generation knobs (temperature, `max_tokens`, tools, etc.) and serialized
/// directly into the HTTP request body. /// directly into the HTTP request body.
/// ///
/// Return: not a function, but the value that becomes the JSON request /// Return: not a function, but the value that becomes the JSON request
+2 -2
View File
@@ -19,8 +19,8 @@ pub struct Connection {
impl Connection { impl Connection {
/// Wrap an already-connected/accepted `UnixStream`. /// Wrap an already-connected/accepted `UnixStream`.
pub fn from_stream(stream: UnixStream) -> Result<Self> { pub fn from_stream(stream: UnixStream) -> Self {
Ok(Connection { inner: stream }) Connection { inner: stream }
} }
/// Open a new Unix-socket connection to `path`. /// Open a new Unix-socket connection to `path`.
+3 -2
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Length-prefixed binary framing and JSON (de)serialization helpers for //! Length-prefixed binary framing and JSON (de)serialization helpers for
//! the IPC wire protocol. //! the IPC wire protocol.
//! //!
@@ -26,7 +27,7 @@ pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> { pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
let len = data.len(); let len = data.len();
if len > MAX_FRAME_SIZE { if len > MAX_FRAME_SIZE {
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len); anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit");
} }
let len_bytes = (len as u32).to_be_bytes(); let len_bytes = (len as u32).to_be_bytes();
writer.write_all(&len_bytes)?; writer.write_all(&len_bytes)?;
@@ -52,7 +53,7 @@ pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
} }
let len = u32::from_be_bytes(len_buf) as usize; let len = u32::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_SIZE { if len > MAX_FRAME_SIZE {
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len); anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit");
} }
let mut buf = vec![0u8; len]; let mut buf = vec![0u8; len];
reader.read_exact(&mut buf)?; reader.read_exact(&mut buf)?;
+1 -1
View File
@@ -30,6 +30,6 @@ impl IpcServer {
/// Block until a client connects, then wrap it as a `Connection`. /// Block until a client connects, then wrap it as a `Connection`.
pub fn accept(&self) -> Result<Connection> { pub fn accept(&self) -> Result<Connection> {
let (stream, _addr) = self.listener.accept()?; let (stream, _addr) = self.listener.accept()?;
Connection::from_stream(stream) Ok(Connection::from_stream(stream))
} }
} }
+5 -6
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Zesdex binary entry point. //! Zesdex binary entry point.
//! //!
//! Parses `--daemon` / `--attach <id>` flags to select one of three //! Parses `--daemon` / `--attach <id>` flags to select one of three
@@ -106,7 +107,7 @@ fn run_single_process() -> Result<()> {
let workspace_roots = vec![std::env::current_dir()?]; let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new( let mut state = app::state::rest::AppStateRest::new(
workspace_roots.clone(), workspace_roots.clone(),
session_dir, &session_dir,
store.memory_dir, store.memory_dir,
); );
state.sessions = model::session::Session::list(&store.base_dir); state.sessions = model::session::Session::list(&store.base_dir);
@@ -129,7 +130,7 @@ fn run_single_process() -> Result<()> {
let _ = disable_raw_mode(); let _ = disable_raw_mode();
if let Err(e) = run_result { if let Err(e) = run_result {
let _ = writeln!(restore_stdout, "error: {}", e); let _ = writeln!(restore_stdout, "error: {e}");
let _ = restore_stdout.flush(); let _ = restore_stdout.flush();
} }
@@ -262,7 +263,6 @@ fn apply_client_update(
state.transcript_cache.messages = payload.messages.into_iter().map(|m| { state.transcript_cache.messages = payload.messages.into_iter().map(|m| {
app::state::rest::ChatMessageDisplay { app::state::rest::ChatMessageDisplay {
role: match m.role.as_str() { role: match m.role.as_str() {
"User" => crate::dto::chat::message::Role::User,
"Assistant" => crate::dto::chat::message::Role::Assistant, "Assistant" => crate::dto::chat::message::Role::Assistant,
"System" => crate::dto::chat::message::Role::System, "System" => crate::dto::chat::message::Role::System,
"Tool" => crate::dto::chat::message::Role::Tool, "Tool" => crate::dto::chat::message::Role::Tool,
@@ -300,7 +300,6 @@ fn apply_client_update(
state.misc.toasts = payload.toasts.into_iter().map(|t| { state.misc.toasts = payload.toasts.into_iter().map(|t| {
Toast { Toast {
kind: match t.kind.as_str() { kind: match t.kind.as_str() {
"Info" => ToastKind::Info,
"Success" => ToastKind::Success, "Success" => ToastKind::Success,
"Warning" => ToastKind::Warning, "Warning" => ToastKind::Warning,
"Error" => ToastKind::Error, "Error" => ToastKind::Error,
@@ -350,7 +349,7 @@ fn run_daemon() -> Result<()> {
let workspace_roots = vec![std::env::current_dir()?]; let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new( let mut state = app::state::rest::AppStateRest::new(
workspace_roots.clone(), workspace_roots.clone(),
session_dir, &session_dir,
store.memory_dir, store.memory_dir,
); );
state.sessions = model::session::Session::list(&store.base_dir); state.sessions = model::session::Session::list(&store.base_dir);
@@ -479,7 +478,7 @@ fn run_attach(session_id: &str) -> Result<()> {
std::fs::create_dir_all(&session_dir)?; std::fs::create_dir_all(&session_dir)?;
let mut client_state = app::state::rest::AppStateRest::new( let mut client_state = app::state::rest::AppStateRest::new(
workspace_roots, workspace_roots,
session_dir, &session_dir,
store.memory_dir, store.memory_dir,
); );
client_state.session_id = session_id.to_string(); client_state.session_id = session_id.to_string();
+3 -9
View File
@@ -43,18 +43,12 @@ impl EditLog {
/// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk /// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk
/// regardless of the in-memory limit. /// regardless of the in-memory limit.
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> { fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
let Ok(file) = std::fs::File::open(path) else { return Vec::new() };
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut entries: Vec<EditLogEntry> = Vec::new(); let mut entries: Vec<EditLogEntry> = Vec::new();
for line in reader.lines() { for line in reader.lines() {
let line = match line { let Ok(line) = line else { continue };
Ok(l) => l,
Err(_) => continue,
};
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) { if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
// Keep only the most recent entries in memory // Keep only the most recent entries in memory
if entries.len() >= MAX_MEMORY_ENTRIES { if entries.len() >= MAX_MEMORY_ENTRIES {
@@ -81,6 +75,7 @@ impl EditLog {
/// Return: `Ok(())` on success; an `io::Error` if serialization or /// Return: `Ok(())` on success; an `io::Error` if serialization or
/// any filesystem operation fails. /// any filesystem operation fails.
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
use std::io::Write;
let line = serde_json::to_string(&entry)? + "\n"; let line = serde_json::to_string(&entry)? + "\n";
// Ensure parent directory exists; fall back to the current // Ensure parent directory exists; fall back to the current
// directory if path has no parent (should not happen in practice // directory if path has no parent (should not happen in practice
@@ -92,7 +87,6 @@ impl EditLog {
.create(true) .create(true)
.append(true) .append(true)
.open(&self.path)?; .open(&self.path)?;
use std::io::Write;
file.write_all(line.as_bytes())?; file.write_all(line.as_bytes())?;
file.sync_all()?; file.sync_all()?;
self.entries.push(entry); self.entries.push(entry);
+11 -13
View File
@@ -57,7 +57,7 @@ impl Memory {
/// to nothing, so a path is always produced. /// to nothing, so a path is always produced.
pub fn path(memory_dir: &Path, name: &str) -> PathBuf { pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
slug_path(memory_dir, &format!("{}.md", slug)) slug_path(memory_dir, &format!("{slug}.md"))
} }
/// Serialize this memory to markdown-with-frontmatter and write it /// Serialize this memory to markdown-with-frontmatter and write it
@@ -72,14 +72,15 @@ impl Memory {
/// ///
/// Return: `Ok(())` on success, or an `io::Error` from directory /// Return: `Ok(())` on success, or an `io::Error` from directory
/// creation, the temp write, or the rename. /// creation, the temp write, or the rename.
#[allow(clippy::suspicious_open_options)]
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> { pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
let path = Self::path(memory_dir, &self.name); let path = Self::path(memory_dir, &self.name);
let parent = path.parent().unwrap(); let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default(); let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {o}")).unwrap_or_default();
let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default(); let scope_line = self.scope.as_ref().map(|s| format!("scope: {s}")).unwrap_or_default();
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default(); let before_line = self.before_snippet.as_ref().map(|s| format!("before: {s}")).unwrap_or_default();
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default(); let after_line = self.after_snippet.as_ref().map(|s| format!("after: {s}")).unwrap_or_default();
let prov_line = if self.provenances.is_empty() { let prov_line = if self.provenances.is_empty() {
String::new() String::new()
} else { } else {
@@ -95,11 +96,11 @@ impl Memory {
// Write to temp file with fsync for crash safety (prevents // Write to temp file with fsync for crash safety (prevents
// partial writes surviving a power loss). // partial writes surviving a power loss).
{ {
use std::io::Write;
let mut f = std::fs::OpenOptions::new() let mut f = std::fs::OpenOptions::new()
.create(true) .create(true)
.write(true) .write(true)
.open(&tmp)?; .open(&tmp)?;
use std::io::Write;
f.write_all(content.as_bytes())?; f.write_all(content.as_bytes())?;
f.sync_all()?; f.sync_all()?;
} }
@@ -161,7 +162,7 @@ impl Memory {
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
provenances: front.get("provenances").cloned() provenances: front.get("provenances").cloned()
.map(|s| s.split(", ").map(|p| p.to_string()).collect()) .map(|s| s.split(", ").map(std::string::ToString::to_string).collect())
.unwrap_or_default(), .unwrap_or_default(),
}) })
} }
@@ -185,13 +186,10 @@ impl Memory {
/// Return: slugs (without extension); empty `Vec` if the directory /// Return: slugs (without extension); empty `Vec` if the directory
/// can't be read. /// can't be read.
pub fn list(memory_dir: &Path) -> Vec<String> { pub fn list(memory_dir: &Path) -> Vec<String> {
let entries = match std::fs::read_dir(memory_dir) { let Ok(entries) = std::fs::read_dir(memory_dir) else { return Vec::new() };
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries entries
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false)) .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| { .filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string(); let name = e.file_name().to_string_lossy().to_string();
if name == "MEMORY.md" { return None; } if name == "MEMORY.md" { return None; }
+1 -1
View File
@@ -1,5 +1,5 @@
//! Persistence and domain model layer: sessions, conversations, memory, //! Persistence and domain model layer: sessions, conversations, memory,
//! message log (SQLite), edit log, and app/settings config. //! message log (`SQLite`), edit log, and app/settings config.
pub mod app_config; pub mod app_config;
pub mod editlog; pub mod editlog;
+4 -4
View File
@@ -1,4 +1,4 @@
//! Binary blob storage in the message-log SQLite database (e.g. images, //! Binary blob storage in the message-log `SQLite` database (e.g. images,
//! attachments), keyed by session id and an arbitrary blob key. //! attachments), keyed by session id and an arbitrary blob key.
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
@@ -9,7 +9,7 @@ use anyhow::Result;
/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs` /// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs`
/// keyed on `(session_id, blob_key)`. /// keyed on `(session_id, blob_key)`.
/// ///
/// Return: `Ok(())` on success, or the underlying SQLite error. /// Return: `Ok(())` on success, or the underlying `SQLite` error.
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> { pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
let created_at = chrono::Utc::now().timestamp_millis(); let created_at = chrono::Utc::now().timestamp_millis();
conn.execute( conn.execute(
@@ -22,7 +22,7 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
/// Fetch a blob's bytes for a session by key. /// Fetch a blob's bytes for a session by key.
/// ///
/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row /// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row
/// exists, `Err` for any other SQLite failure. /// exists, `Err` for any other `SQLite` failure.
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> { pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
let result = conn.query_row( let result = conn.query_row(
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
@@ -52,7 +52,7 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul
/// List all blob keys stored for a session, oldest first. /// List all blob keys stored for a session, oldest first.
/// ///
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the /// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
/// underlying SQLite error. /// underlying `SQLite` error.
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> { pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC" "SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
+1 -1
View File
@@ -12,7 +12,7 @@ pub use query::insert_message;
/// schema is initialized. /// schema is initialized.
/// ///
/// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs → /// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs →
/// open a SQLite connection → run `schema::init_schema`. /// open a `SQLite` connection → run `schema::init_schema`.
/// ///
/// Return: an open, schema-ready `Connection`, or an error if any step /// Return: an open, schema-ready `Connection`, or an error if any step
/// fails. /// fails.
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::dto::chat::message::{ChatMessage, Role};
/// Insert a chat message into the session's message log. /// Insert a chat message into the session's message log.
/// ///
/// Flow: extract optional content/tool_call_id/tool_name → serialize /// Flow: extract optional `content/tool_call_id/tool_name` → serialize
/// `tool_calls` to a JSON string if present → map `Role` to its string /// `tool_calls` to a JSON string if present → map `Role` to its string
/// column value → `INSERT` the row with the current timestamp. /// column value → `INSERT` the row with the current timestamp.
/// ///
+2 -2
View File
@@ -1,4 +1,4 @@
//! SQLite schema definition for the message log database. //! `SQLite` schema definition for the message log database.
use rusqlite::Connection; use rusqlite::Connection;
use anyhow::Result; use anyhow::Result;
@@ -9,7 +9,7 @@ use anyhow::Result;
/// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe /// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe
/// to call on every `open_or_create`. /// to call on every `open_or_create`.
/// ///
/// Return: `Ok(())` on success, or the underlying SQLite error. /// Return: `Ok(())` on success, or the underlying `SQLite` error.
pub fn init_schema(conn: &Connection) -> Result<()> { pub fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
conn.execute_batch( conn.execute_batch(
+3 -6
View File
@@ -89,7 +89,7 @@ impl Session {
if id.contains('/') || id.contains('\\') || id.contains("..") { if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(std::io::Error::new( return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput, std::io::ErrorKind::InvalidInput,
format!("invalid session id '{}': must not contain path separators", id), format!("invalid session id '{id}': must not contain path separators"),
)); ));
} }
let path = base_dir.join("sessions").join(id).join("session.json"); let path = base_dir.join("sessions").join(id).join("session.json");
@@ -108,12 +108,9 @@ impl Session {
/// contains no valid sessions. /// contains no valid sessions.
pub fn list(base_dir: &Path) -> Vec<Self> { pub fn list(base_dir: &Path) -> Vec<Self> {
let sessions_dir = base_dir.join("sessions"); let sessions_dir = base_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) { let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Vec::new() };
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries entries
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.path().is_dir()) .filter(|e| e.path().is_dir())
.filter_map(|e| { .filter_map(|e| {
let id = e.file_name().to_string_lossy().to_string(); let id = e.file_name().to_string_lossy().to_string();
+9 -12
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! PID-file based advisory lock preventing two processes from operating on //! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently. //! the same session directory concurrently.
@@ -37,6 +38,7 @@ impl SessionLock {
/// ///
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live /// Return: `Ok(true)` if acquired, `Ok(false)` if another live
/// process holds it, `Err` on I/O failure. /// process holds it, `Err` on I/O failure.
#[allow(clippy::suspicious_open_options)]
pub fn try_lock(&self) -> std::io::Result<bool> { pub fn try_lock(&self) -> std::io::Result<bool> {
// Phase 1: try atomic create. If it succeeds, the lock is ours. // Phase 1: try atomic create. If it succeeds, the lock is ours.
match fs::OpenOptions::new() match fs::OpenOptions::new()
@@ -90,6 +92,7 @@ impl SessionLock {
/// Check whether a process with the given PID is currently alive and /// Check whether a process with the given PID is currently alive and
/// is actually a zesdex process (not a recycled PID from a different /// is actually a zesdex process (not a recycled PID from a different
/// program). /// program).
#[allow(clippy::unused_self)]
fn is_alive(&self, pid: u32) -> bool { fn is_alive(&self, pid: u32) -> bool {
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal // whether the process exists and the caller has permission to signal
@@ -102,18 +105,12 @@ impl SessionLock {
// from a different program would answer kill but shouldn't hold // from a different program would answer kill but shouldn't hold
// our lock). This is best-effort — /proc may not be available // our lock). This is best-effort — /proc may not be available
// on all platforms. // on all platforms.
let proc_exe = std::path::PathBuf::from(format!("/proc/{}/exe", pid)); let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
match std::fs::read_link(&proc_exe) { if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() {
Ok(target) => match std::env::current_exe() { if target != exe {
Ok(exe) => { return false;
if target != exe { }
return false; } else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ }
}
}
Err(_) => { /* cannot resolve own exe, trust kill check */ }
},
Err(_) => { /* /proc unavailable, trust kill check */ }
}
true true
} }
} }
+1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects. //! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
use std::io::{Read, Write}; use std::io::{Read, Write};
+5 -5
View File
@@ -72,13 +72,13 @@ impl OAuthManager {
.post(&self.config.token_url) .post(&self.config.token_url)
.form(&params) .form(&params)
.send() .send()
.map_err(|e| format!("token request failed: {}", e))?; .map_err(|e| format!("token request failed: {e}"))?;
let status = resp.status(); let status = resp.status();
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?; let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
if !status.is_success() { if !status.is_success() {
return Err(format!("token endpoint returned {}: {}", status, body)); return Err(format!("token endpoint returned {status}: {body}"));
} }
let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string(); let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string();
@@ -87,7 +87,7 @@ impl OAuthManager {
self.token = Some(OAuthToken { self.token = Some(OAuthToken {
access_token, access_token,
refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()), refresh_token: body["refresh_token"].as_str().map(std::string::ToString::to_string),
expires_at: now + expires_in, expires_at: now + expires_in,
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
}); });
@@ -98,7 +98,7 @@ impl OAuthManager {
/// Build the provider's authorization URL with PKCE and state params attached. /// Build the provider's authorization URL with PKCE and state params attached.
/// ///
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously /// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
/// this silently fell back to https://example.com, which produced a valid-looking /// this silently fell back to <https://example.com>, which produced a valid-looking
/// auth URL pointing at the wrong server and leaked client credentials in /// auth URL pointing at the wrong server and leaked client credentials in
/// query params. Returning an empty string signals failure to callers, who /// query params. Returning an empty string signals failure to callers, who
/// can prompt the user to fix the OAuth config instead of starting a flow /// can prompt the user to fix the OAuth config instead of starting a flow
+1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows. //! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows.
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+10 -10
View File
@@ -13,7 +13,7 @@ pub(crate) const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
pub const DEFAULT_API_KEY: &str = ""; pub const DEFAULT_API_KEY: &str = "";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const REQUEST_TIMEOUT: Duration = Duration::from_mins(1);
/// Blocking HTTP client for a single LLM provider endpoint. /// Blocking HTTP client for a single LLM provider endpoint.
/// ///
@@ -29,9 +29,9 @@ pub struct LlmClient {
impl LlmClient { impl LlmClient {
/// Construct a client, falling back to built-in defaults for empty inputs. /// Construct a client, falling back to built-in defaults for empty inputs.
/// ///
/// Flow: empty api_key/model → substitute defaults → build reqwest client /// Flow: empty `api_key/model` → substitute defaults → build reqwest client
/// with connect/request timeouts → if TLS config fails, retry with just /// with connect/request timeouts → if TLS config fails, retry with just
/// request timeout (no connect timeout) → normalize base_url. /// request timeout (no connect timeout) → normalize `base_url`.
/// ///
/// Why: empty strings are treated as "unset" rather than errors so callers /// Why: empty strings are treated as "unset" rather than errors so callers
/// can pass through unconfigured settings without special-casing them. /// can pass through unconfigured settings without special-casing them.
@@ -126,11 +126,11 @@ impl LlmClient {
let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> { let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> {
let resp = http_req.json(&req).send().map_err(|e| { let resp = http_req.json(&req).send().map_err(|e| {
if e.is_timeout() { if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT) anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
} else if e.is_connect() { } else if e.is_connect() {
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url) anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
} else { } else {
anyhow::anyhow!("API request failed: {}", e) anyhow::anyhow!("API request failed: {e}")
} }
})?; })?;
@@ -142,7 +142,7 @@ impl LlmClient {
let data: crate::dto::provider::response::ChatResponse = resp.json()?; let data: crate::dto::provider::response::ChatResponse = resp.json()?;
let usage = data.usage.map(|u| { let usage = data.usage.map(|u| {
(u.prompt_tokens.unwrap_or(0) as u64, u.completion_tokens.unwrap_or(0) as u64) (u64::from(u.prompt_tokens.unwrap_or(0)), u64::from(u.completion_tokens.unwrap_or(0)))
}); });
let message = data let message = data
.choices .choices
@@ -259,11 +259,11 @@ impl LlmClient {
let resp = http_req.json(req).send().map_err(|e| { let resp = http_req.json(req).send().map_err(|e| {
if e.is_timeout() { if e.is_timeout() {
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT) anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
} else if e.is_connect() { } else if e.is_connect() {
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url) anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
} else { } else {
anyhow::anyhow!("API request failed: {}", e) anyhow::anyhow!("API request failed: {e}")
} }
})?; })?;
@@ -282,7 +282,7 @@ impl LlmClient {
loop { loop {
let n = reader.read(&mut chunk_buf) let n = reader.read(&mut chunk_buf)
.map_err(|e| anyhow::anyhow!("stream read error: {}", e))?; .map_err(|e| anyhow::anyhow!("stream read error: {e}"))?;
if n == 0 { if n == 0 {
break; break;
} }
@@ -306,7 +306,7 @@ impl LlmClient {
usage = Some((*prompt_tokens, *completion_tokens)); usage = Some((*prompt_tokens, *completion_tokens));
} }
StreamEvent::Error(msg) => { StreamEvent::Error(msg) => {
anyhow::bail!("stream error: {}", msg); anyhow::bail!("stream error: {msg}");
} }
StreamEvent::Done => { StreamEvent::Done => {
turn.apply_event(&event); turn.apply_event(&event);
+3 -3
View File
@@ -43,7 +43,7 @@ impl Tool for BashOutput {
} }
match crate::app::bgbash::control::bash_output(&job_id) { match crate::app::bgbash::control::bash_output(&job_id) {
Some(lines) => Ok(lines.join("\n")), Some(lines) => Ok(lines.join("\n")),
None => Ok(format!("No new output from job '{}'", job_id)), None => Ok(format!("No new output from job '{job_id}'")),
} }
} }
} }
@@ -82,11 +82,11 @@ impl Tool for BashKill {
anyhow::bail!("invalid job_id format: expected UUID"); anyhow::bail!("invalid job_id format: expected UUID");
} }
crate::app::bgbash::control::bash_kill(&job_id)?; crate::app::bgbash::control::bash_kill(&job_id)?;
Ok(format!("Killed background job '{}'", job_id)) Ok(format!("Killed background job '{job_id}'"))
} }
} }
/// Validate that a job_id matches UUID v4 format (hex with dashes). /// Validate that a `job_id` matches UUID v4 format (hex with dashes).
fn is_valid_job_id(id: &str) -> bool { fn is_valid_job_id(id: &str) -> bool {
// UUID v4 format: 8-4-4-4-12 hex digits // UUID v4 format: 8-4-4-4-12 hex digits
let parts: Vec<&str> = id.split('-').collect(); let parts: Vec<&str> = id.split('-').collect();
+7 -7
View File
@@ -47,24 +47,24 @@ impl Tool for Delete {
} }
let metadata = path.metadata() let metadata = path.metadata()
.map_err(|e| anyhow!("failed to read metadata for '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to read metadata for '{rel}': {e}"))?;
if metadata.is_dir() { if metadata.is_dir() {
let is_empty = fs::read_dir(&path) let is_empty = fs::read_dir(&path)
.map_err(|e| anyhow!("failed to read directory '{}': {}", rel, e))? .map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
.next() .next()
.is_none(); .is_none();
if is_empty { if is_empty {
fs::remove_dir(&path) fs::remove_dir(&path)
.map_err(|e| anyhow!("failed to remove directory '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to remove directory '{rel}': {e}"))?;
Ok(format!("removed empty directory {}", rel)) Ok(format!("removed empty directory {rel}"))
} else { } else {
anyhow::bail!("directory '{}' is not empty (refusing to delete)", rel); anyhow::bail!("directory '{rel}' is not empty (refusing to delete)");
} }
} else { } else {
fs::remove_file(&path) fs::remove_file(&path)
.map_err(|e| anyhow!("failed to delete '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to delete '{rel}': {e}"))?;
Ok(format!("deleted {}", rel)) Ok(format!("deleted {rel}"))
} }
} }
} }
+9 -11
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Tool: `edit` — replace a substring in a file with a new string. //! Tool: `edit` — replace a substring in a file with a new string.
use std::fs; use std::fs;
@@ -70,25 +71,24 @@ impl Tool for Edit {
anyhow::bail!("'old' must be a non-empty string; use 'write' to replace entire file contents"); anyhow::bail!("'old' must be a non-empty string; use 'write' to replace entire file contents");
} }
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks); let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false); let replace_all = args.get("replace_all").and_then(serde_json::Value::as_bool).unwrap_or(false);
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() { if !path.exists() {
anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display()); anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display());
} }
if path.is_dir() { if path.is_dir() {
anyhow::bail!("'{}' is a directory, not a file", rel); anyhow::bail!("'{rel}' is a directory, not a file");
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
if !content.contains(&old) { if !content.contains(&old) {
anyhow::bail!("old string not found in '{}'", rel); anyhow::bail!("old string not found in '{rel}'");
} }
if !replace_all { if !replace_all {
let count = content.matches(&old).count(); let count = content.matches(&old).count();
if count > 1 { if count > 1 {
anyhow::bail!( anyhow::bail!(
"old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.", "old string appears {count} times in '{rel}'. Set replace_all=true to replace all occurrences, or provide a more specific match."
count, rel
); );
} }
} }
@@ -98,7 +98,7 @@ impl Tool for Edit {
content.replacen(&old, &new_str, 1) content.replacen(&old, &new_str, 1)
}; };
fs::write(&path, &new_content) fs::write(&path, &new_content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
let bytes_diff = if new_content.len() > content.len() { let bytes_diff = if new_content.len() > content.len() {
new_content.len() - content.len() new_content.len() - content.len()
} else { } else {
@@ -108,10 +108,8 @@ impl Tool for Edit {
// Never fail the edit because of this — LSP errors are surfaced as a // Never fail the edit because of this — LSP errors are surfaced as a
// trailing annotation on the success message instead. // trailing annotation on the success message instead.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) { lsp.did_change_file(&path);
Ok(()) => String::new(), String::new()
Err(e) => format!(" (LSP: {})", e),
}
} else { } else {
String::new() String::new()
}; };
+6 -6
View File
@@ -12,8 +12,8 @@ use anyhow::{Result, anyhow};
pub fn arg_str(args: &Value, name: &str) -> Result<String> { pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name) args.get(name)
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(std::string::ToString::to_string)
.ok_or_else(|| anyhow!("missing required argument: {}", name)) .ok_or_else(|| anyhow!("missing required argument: {name}"))
} }
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist. /// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
@@ -25,17 +25,17 @@ pub fn arg_str(args: &Value, name: &str) -> Result<String> {
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String { pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let in_ws = ctx.workspaces.iter().any(|w| { let in_ws = ctx.workspaces.iter().any(|w| {
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf()); let wc = w.canonicalize().unwrap_or_else(|_| w.clone());
canon.starts_with(&wc) canon.starts_with(&wc)
}); });
if !in_ws { if in_ws {
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
} else {
format!( format!(
"path '{}' is outside all workspace roots. Workspace roots: {}", "path '{}' is outside all workspace roots. Workspace roots: {}",
rel, rel,
ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ") ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ")
) )
} else {
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
} }
} }
+4 -3
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Tool: `read` — display file contents with line numbers. //! Tool: `read` — display file contents with line numbers.
use std::fs; use std::fs;
@@ -47,7 +48,7 @@ impl Tool for Read {
/// exist; a "is a directory" message if the path points at a directory. /// exist; a "is a directory" message if the path points at a directory.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = arg_str(args, "path")?; let rel = arg_str(args, "path")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize); let limit = args.get("limit").and_then(serde_json::Value::as_u64).map(|v| v as usize);
let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) { let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) {
Ok(p) => p, Ok(p) => p,
Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)), Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)),
@@ -56,10 +57,10 @@ impl Tool for Read {
return Ok(not_found_help(ctx, &path, &rel)); return Ok(not_found_help(ctx, &path, &rel));
} }
if path.is_dir() { if path.is_dir() {
return Ok(format!("'{}' is a directory, not a file. Use ls or glob to list directory contents.", rel)); return Ok(format!("'{rel}' is a directory, not a file. Use ls or glob to list directory contents."));
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let total = lines.len(); let total = lines.len();
let take = limit.unwrap_or(total).min(total); let take = limit.unwrap_or(total).min(total);
+4 -6
View File
@@ -60,18 +60,16 @@ impl Tool for Write {
let path = resolve_path(&ctx.workspaces, &rel)?; let path = resolve_path(&ctx.workspaces, &rel)?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|e| anyhow!("failed to create parent directories for '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?;
} }
fs::write(&path, &content) fs::write(&path, &content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
// Notify the LSP server of the on-disk change so diagnostics stay in // Notify the LSP server of the on-disk change so diagnostics stay in
// sync. Never fails the write itself: a lock failure or LSP error is // sync. Never fails the write itself: a lock failure or LSP error is
// folded into the returned message instead of propagated as an Err. // folded into the returned message instead of propagated as an Err.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) { lsp.did_change_file(&path);
Ok(()) => String::new(), String::new()
Err(e) => format!(" (LSP: {})", e),
}
} else { } else {
String::new() String::new()
}; };
+2 -2
View File
@@ -48,11 +48,11 @@ impl Tool for GitCred {
.arg("credential") .arg("credential")
.arg(operation) .arg(operation)
.output() .output()
.map_err(|e| anyhow!("git credential failed: {}", e))?; .map_err(|e| anyhow!("git credential failed: {e}"))?;
if output.status.success() { if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(format!("{}{}", stdout, stderr)) Ok(format!("{stdout}{stderr}"))
} else { } else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim()) anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
+3 -3
View File
@@ -58,7 +58,7 @@ impl Tool for GitOperator {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect() .collect()
}) })
.ok_or_else(|| anyhow!("missing required argument: args"))?; .ok_or_else(|| anyhow!("missing required argument: args"))?;
@@ -67,12 +67,12 @@ impl Tool for GitOperator {
// of which tool the model uses. // of which tool the model uses.
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" ")); let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter) crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
.map_err(|e| anyhow!("blocked: {}", e))?; .map_err(|e| anyhow!("blocked: {e}"))?;
let output = Command::new("git") let output = Command::new("git")
.arg(&operation) .arg(&operation)
.args(&arg_list) .args(&arg_list)
.output() .output()
.map_err(|e| anyhow!("git {} failed: {}", operation, e))?; .map_err(|e| anyhow!("git {operation} failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) }; let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
+4 -4
View File
@@ -37,7 +37,7 @@ impl Tool for GitWorktree {
/// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`. /// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`.
/// ///
/// Flow: extract name/base_ref → create worktree dir under `ctx.worktrees_dir` → /// Flow: extract `name/base_ref` → create worktree dir under `ctx.worktrees_dir` →
/// spawn `git worktree add` → combine stdout/stderr. /// spawn `git worktree add` → combine stdout/stderr.
/// ///
/// Return: success message with combined output on success; error including exit /// Return: success message with combined output on success; error including exit
@@ -56,18 +56,18 @@ impl Tool for GitWorktree {
.to_string(); .to_string();
let worktree_path = ctx.worktrees_dir.join(&name); let worktree_path = ctx.worktrees_dir.join(&name);
std::fs::create_dir_all(&worktree_path) std::fs::create_dir_all(&worktree_path)
.map_err(|e| anyhow!("failed to create worktree directory: {}", e))?; .map_err(|e| anyhow!("failed to create worktree directory: {e}"))?;
let output = Command::new("git") let output = Command::new("git")
.args(["worktree", "add", "--checkout"]) .args(["worktree", "add", "--checkout"])
.arg(worktree_path.display().to_string()) .arg(worktree_path.display().to_string())
.arg(&base_ref) .arg(&base_ref)
.output() .output()
.map_err(|e| anyhow!("git worktree add failed: {}", e))?; .map_err(|e| anyhow!("git worktree add failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) }; let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
if output.status.success() { if output.status.success() {
Ok(format!("created worktree '{}' from '{}'\n{}", name, base_ref, combined)) Ok(format!("created worktree '{name}' from '{base_ref}'\n{combined}"))
} else { } else {
anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim()) anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim())
} }
+60 -63
View File
@@ -1,3 +1,5 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
use std::fmt::Write;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
@@ -59,7 +61,7 @@ impl Tool for LspConnect {
.unwrap_or_default(); .unwrap_or_default();
let mut manager = ctx.lsp_manager.lock() let mut manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
manager.connect(name, command, &extra_args, language_id)?; manager.connect(name, command, &extra_args, language_id)?;
// Auto-register this server's known extensions so lsp_diagnostics / // Auto-register this server's known extensions so lsp_diagnostics /
@@ -79,8 +81,7 @@ impl Tool for LspConnect {
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
Ok(format!( Ok(format!(
"Connected to LSP server '{}' (language: {})\nServer capabilities:\n{}", "Connected to LSP server '{name}' (language: {language_id})\nServer capabilities:\n{caps_summary}"
name, language_id, caps_summary
)) ))
} }
} }
@@ -132,15 +133,15 @@ impl Tool for LspDiagnostics {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found"))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
match client.collect_diagnostics(&uri, &language_id, text) { match client.collect_diagnostics(&uri, &language_id, text) {
Ok(diags) => { Ok(diags) => {
@@ -151,7 +152,7 @@ impl Tool for LspDiagnostics {
let mut output = String::from("Diagnostics:\n"); let mut output = String::from("Diagnostics:\n");
for d in &diags_array { for d in &diags_array {
let range = d.get("range").and_then(|r| r.get("start")); let range = d.get("range").and_then(|r| r.get("start"));
let severity = match d.get("severity").and_then(|s| s.as_i64()).unwrap_or(0) { let severity = match d.get("severity").and_then(serde_json::Value::as_i64).unwrap_or(0) {
1 => "ERROR", 1 => "ERROR",
2 => "WARNING", 2 => "WARNING",
3 => "INFO", 3 => "INFO",
@@ -159,13 +160,13 @@ impl Tool for LspDiagnostics {
_ => "NOTE", _ => "NOTE",
}; };
let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?"); let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?");
let line = range.and_then(|r| r.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); let line = range.and_then(|r| r.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let col = range.and_then(|r| r.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); let col = range.and_then(|r| r.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let code = d.get("code") let code = d.get("code")
.and_then(|c| c.as_str().or_else(|| c.as_i64().map(|n| Box::leak(Box::new(n.to_string()))).map(|s| s.as_str()))) .and_then(|c| c.as_str().or_else(|| c.as_i64().map(|n| Box::leak(Box::new(n.to_string()))).map(|s| s.as_str())))
.unwrap_or(""); .unwrap_or("");
let code_str = if code.is_empty() { String::new() } else { format!(" [{}]", code) }; let code_str = if code.is_empty() { String::new() } else { format!(" [{code}]") };
output.push_str(&format!(" {}:{}:{} - {}{}: {}\n", rel_path, line + 1, col, severity, code_str, message)); writeln!(output, " {}:{}:{} - {}{}: {}", rel_path, line + 1, col, severity, code_str, message).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -226,10 +227,10 @@ impl Tool for LspHover {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -238,20 +239,20 @@ impl Tool for LspHover {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| { .unwrap_or_else(|| {
args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string() args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string()
}); });
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.hover(&uri, line, column); let result = client.hover(&uri, line, column);
@@ -267,9 +268,9 @@ impl Tool for LspHover {
let mut output = String::new(); let mut output = String::new();
if let Some(range_val) = range { if let Some(range_val) = range {
if let Some(start) = range_val.get("start") { if let Some(start) = range_val.get("start") {
let rl = start.get("line").and_then(|l| l.as_i64()).unwrap_or(0); let rl = start.get("line").and_then(serde_json::Value::as_i64).unwrap_or(0);
let rc = start.get("character").and_then(|c| c.as_i64()).unwrap_or(0); let rc = start.get("character").and_then(serde_json::Value::as_i64).unwrap_or(0);
output.push_str(&format!("Range: {}:{}\n", rl + 1, rc + 1)); writeln!(output, "Range: {}:{}", rl + 1, rc + 1).unwrap();
} }
} }
if let Some(contents_val) = contents { if let Some(contents_val) = contents {
@@ -292,7 +293,7 @@ fn format_hover_contents(contents: &Value) -> String {
} }
Value::Object(map) => { Value::Object(map) => {
if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) { if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) {
out.push_str(&format!("[{kind}] ")); write!(out, "[{kind}] ").unwrap();
} }
if let Some(value) = map.get("value").and_then(|v| v.as_str()) { if let Some(value) = map.get("value").and_then(|v| v.as_str()) {
out.push_str(value); out.push_str(value);
@@ -355,10 +356,10 @@ impl Tool for LspCompletion {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -367,18 +368,18 @@ impl Tool for LspCompletion {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.completion(&uri, line, column); let result = client.completion(&uri, line, column);
@@ -401,7 +402,7 @@ impl Tool for LspCompletion {
let mut output = format!("{} completion suggestions at {}:{}:\n", items.len(), line + 1, column + 1); let mut output = format!("{} completion suggestions at {}:{}:\n", items.len(), line + 1, column + 1);
for (i, item) in items.iter().enumerate().take(50) { for (i, item) in items.iter().enumerate().take(50) {
let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?"); let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?");
let kind = match item.get("kind").and_then(|k| k.as_i64()).unwrap_or(0) { let kind = match item.get("kind").and_then(serde_json::Value::as_i64).unwrap_or(0) {
1 => "Text", 1 => "Text",
2 => "Method", 2 => "Method",
3 => "Function", 3 => "Function",
@@ -430,11 +431,11 @@ impl Tool for LspCompletion {
_ => "Other", _ => "Other",
}; };
let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or(""); let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or("");
let detail_str = if detail.is_empty() { String::new() } else { format!(" - {}", detail) }; let detail_str = if detail.is_empty() { String::new() } else { format!(" - {detail}") };
output.push_str(&format!(" {}. [{}] {}{}\n", i + 1, kind, label, detail_str)); writeln!(output, " {}. [{}] {}{}", i + 1, kind, label, detail_str).unwrap();
} }
if items.len() > 50 { if items.len() > 50 {
output.push_str(&format!(" ... and {} more\n", items.len() - 50)); writeln!(output, " ... and {} more", items.len() - 50).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -485,10 +486,10 @@ impl Tool for LspDefinition {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -497,18 +498,18 @@ impl Tool for LspDefinition {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.goto_definition(&uri, line, column); let result = client.goto_definition(&uri, line, column);
@@ -534,13 +535,13 @@ impl Tool for LspDefinition {
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
let target_range = loc.get("range").or_else(|| loc.get("targetRange")); let target_range = loc.get("range").or_else(|| loc.get("targetRange"));
let target_start = target_range.and_then(|r| r.get("start")); let target_start = target_range.and_then(|r| r.get("start"));
let tl = target_start.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); let tl = target_start.and_then(|s| s.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let tc = target_start.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); let tc = target_start.and_then(|s| s.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, tl + 1, tc + 1)); writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap();
} }
if locations.len() > 10 { if locations.len() > 10 {
output.push_str(&format!(" ... and {} more locations\n", locations.len() - 10)); writeln!(output, " ... and {} more", locations.len() - 10).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -591,10 +592,10 @@ impl Tool for LspReferences {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args.get("line") let line = args.get("line")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32; .ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?; let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str(); let server_name = server_name.as_str();
@@ -603,18 +604,18 @@ impl Tool for LspReferences {
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path) let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx.lsp_manager.lock() let manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name) let language_id = manager.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string()); .unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name) let client_arc = manager.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; .ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
drop(manager); drop(manager);
let mut client = client_arc.lock() let mut client = client_arc.lock()
.map_err(|e| anyhow!("LSP client lock error: {}", e))?; .map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?; client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.references(&uri, line, column); let result = client.references(&uri, line, column);
@@ -631,13 +632,13 @@ impl Tool for LspReferences {
for (i, loc) in locations.iter().enumerate().take(50) { for (i, loc) in locations.iter().enumerate().take(50) {
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
let range = loc.get("range").and_then(|r| r.get("start")); let range = loc.get("range").and_then(|r| r.get("start"));
let rl = range.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); let rl = range.and_then(|s| s.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let rc = range.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); let rc = range.and_then(|s| s.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, rl + 1, rc + 1)); writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap();
} }
if locations.len() > 50 { if locations.len() > 50 {
output.push_str(&format!(" ... and {} more references\n", locations.len() - 50)); writeln!(output, " ... and {} more references", locations.len() - 50).unwrap();
} }
Ok(output) Ok(output)
} }
@@ -676,12 +677,12 @@ impl Tool for LspDisconnect {
.ok_or_else(|| anyhow!("missing required argument: name"))?; .ok_or_else(|| anyhow!("missing required argument: name"))?;
let mut manager = ctx.lsp_manager.lock() let mut manager = ctx.lsp_manager.lock()
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
if manager.disconnect(name) { if manager.disconnect(name) {
Ok(format!("Disconnected from LSP server '{}'", name)) Ok(format!("Disconnected from LSP server '{name}'"))
} else { } else {
Err(anyhow!("LSP server '{}' not found", name)) Err(anyhow!("LSP server '{name}' not found"))
} }
} }
} }
@@ -718,7 +719,7 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] {
/// connected server's language is known to use that extension. /// connected server's language is known to use that extension.
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> { fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?; let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?;
let dot_ext = format!(".{}", ext); let dot_ext = format!(".{ext}");
if let Ok(mgr) = ctx.lsp_manager.lock() { if let Ok(mgr) = ctx.lsp_manager.lock() {
for s in &mgr.servers { for s in &mgr.servers {
let exts = known_extensions_for(&s.language_id); let exts = known_extensions_for(&s.language_id);
@@ -754,15 +755,13 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String
let ext = std::path::Path::new(path) let ext = std::path::Path::new(path)
.extension() .extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str()).map_or_else(|| "<none>".to_string(), |e| format!(".{e}"));
.map(|e| format!(".{}", e))
.unwrap_or_else(|| "<none>".to_string());
let available = ctx.lsp_manager.lock().ok() let available = ctx.lsp_manager.lock().ok()
.map(|mgr| { .map(|mgr| {
mgr.list_servers() mgr.list_servers()
.iter() .iter()
.map(|(name, lang, _)| format!("{} ({})", name, lang)) .map(|(name, lang, _)| format!("{name} ({lang})"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
}) })
@@ -770,8 +769,6 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String
let available = if available.is_empty() { "none".to_string() } else { available }; let available = if available.is_empty() { "none".to_string() } else { available };
Err(anyhow!( Err(anyhow!(
"LSP server not found for extension '{}'. Use lsp_connect to connect one. Available servers: {}", "LSP server not found for extension '{ext}'. Use lsp_connect to connect one. Available servers: {available}"
ext,
available
)) ))
} }
+2 -2
View File
@@ -43,8 +43,8 @@ impl Tool for Forget {
.ok_or_else(|| anyhow!("missing required argument: name"))?; .ok_or_else(|| anyhow!("missing required argument: name"))?;
Memory::remove(&ctx.memory_dir, name) Memory::remove(&ctx.memory_dir, name)
.map_err(|e| anyhow!("failed to remove memory '{}': {}", name, e))?; .map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?;
Ok(format!("removed memory '{}'", name)) Ok(format!("removed memory '{name}'"))
} }
} }
+10 -9
View File
@@ -1,5 +1,6 @@
//! Tool for reading a single memory entry or listing the whole memory index. //! Tool for reading a single memory entry or listing the whole memory index.
use std::fmt::Write;
use serde_json::{json, Value}; use serde_json::{json, Value};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::super::Tool; use super::super::Tool;
@@ -39,10 +40,10 @@ impl Tool for Recall {
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if let Some(name) = args.get("name").and_then(|v| v.as_str()) { if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
if name.is_empty() { if name.is_empty() {
return list_all(ctx); return Ok(list_all(ctx));
} }
let memory = Memory::read(&ctx.memory_dir, name) let memory = Memory::read(&ctx.memory_dir, name)
.map_err(|e| anyhow!("memory '{}' not found: {}", name, e))?; .map_err(|e| anyhow!("memory '{name}' not found: {e}"))?;
Ok(format!( Ok(format!(
"---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}", "---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}",
memory.name, memory.name,
@@ -52,7 +53,7 @@ impl Tool for Recall {
memory.content, memory.content,
)) ))
} else { } else {
list_all(ctx) Ok(list_all(ctx))
} }
} }
} }
@@ -63,18 +64,18 @@ impl Tool for Recall {
/// fall back to bare name if the file can't be parsed. /// fall back to bare name if the file can't be parsed.
/// ///
/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)"). /// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)").
fn list_all(ctx: &ToolCtx) -> Result<String> { fn list_all(ctx: &ToolCtx) -> String {
let names = Memory::list(&ctx.memory_dir); let names = Memory::list(&ctx.memory_dir);
if names.is_empty() { if names.is_empty() {
return Ok("(no memory entries)".to_string()); return "(no memory entries)".to_string();
} }
let mut lines = format!("Memory index ({} entries):\n", names.len()); let mut lines = String::new();
for name in &names { for name in &names {
if let Ok(mem) = Memory::read(&ctx.memory_dir, name) { if let Ok(mem) = Memory::read(&ctx.memory_dir, name) {
lines.push_str(&format!("- {} [{}]: {}\n", name, mem.kind, mem.description)); let _ = writeln!(lines, "- {} [{}]: {}", name, mem.kind, mem.description);
} else { } else {
lines.push_str(&format!("- {}\n", name)); let _ = writeln!(lines, "- {name}");
} }
} }
Ok(lines) lines
} }
+2 -2
View File
@@ -89,8 +89,8 @@ impl Tool for Remember {
}; };
memory.write(&ctx.memory_dir) memory.write(&ctx.memory_dir)
.map_err(|e| anyhow!("failed to write memory '{}': {}", name, e))?; .map_err(|e| anyhow!("failed to write memory '{name}': {e}"))?;
Ok(format!("saved memory '{}' ({})", name, kind)) Ok(format!("saved memory '{name}' ({kind})"))
} }
} }
+15 -20
View File
@@ -228,7 +228,7 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
} else { } else {
(0, rel) (0, rel)
}; };
let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?; let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
let abs = if path.is_empty() { let abs = if path.is_empty() {
base.clone() base.clone()
} else { } else {
@@ -239,32 +239,27 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
// workspace root first and then resolve parent-dir (`../`) traversal // workspace root first and then resolve parent-dir (`../`) traversal
// component-by-component so that `Path::starts_with` cannot be // component-by-component so that `Path::starts_with` cannot be
// bypassed by unnormalised intermediate segments. // bypassed by unnormalised intermediate segments.
let canon = match abs.canonicalize() { let canon = if let Ok(c) = abs.canonicalize() { c } else {
Ok(c) => c, let base_canon = workspaces
Err(_) => { .iter().find_map(|w| w.canonicalize().ok())
let base_canon = workspaces .unwrap_or_else(|| base.clone());
.iter() let mut resolved = base_canon.clone();
.filter_map(|w| w.canonicalize().ok()) if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
.next() for comp in rel_components.components() {
.unwrap_or_else(|| base.clone()); match comp {
let mut resolved = base_canon.clone(); std::path::Component::ParentDir => {
if let Ok(rel_components) = abs.strip_prefix(&base_canon) { resolved.pop();
for comp in rel_components.components() {
match comp {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
c => resolved.push(c),
} }
std::path::Component::CurDir => {}
c => resolved.push(c),
} }
} }
resolved
} }
resolved
}; };
if workspaces.iter().any(|w| canon.starts_with(w)) { if workspaces.iter().any(|w| canon.starts_with(w)) {
Ok(canon) Ok(canon)
} else { } else {
anyhow::bail!("path '{}' is outside all workspace roots", rel) anyhow::bail!("path '{rel}' is outside all workspace roots")
} }
} }
+8 -8
View File
@@ -59,10 +59,10 @@ impl Tool for Grep {
.to_string(); .to_string();
let path = resolve_path(&ctx.workspaces, &rel)?; let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() { if !path.exists() {
anyhow::bail!("path '{}' does not exist", rel); anyhow::bail!("path '{rel}' does not exist");
} }
if !path.is_dir() { if !path.is_dir() {
anyhow::bail!("path '{}' is not a directory", rel); anyhow::bail!("path '{rel}' is not a directory");
} }
let mut results: Vec<(String, usize, String)> = Vec::new(); let mut results: Vec<(String, usize, String)> = Vec::new();
for entry in Walk::new(&path).flatten() { for entry in Walk::new(&path).flatten() {
@@ -83,10 +83,10 @@ impl Tool for Grep {
} }
} }
if results.is_empty() { if results.is_empty() {
return Ok(format!("no matches found for '{}' in {}", pattern, rel)); return Ok(format!("no matches found for '{pattern}' in {rel}"));
} }
let output = results.iter() let output = results.iter()
.map(|(f, line, text)| format!("{}:{}:{}", f, line, text)) .map(|(f, line, text)| format!("{f}:{line}:{text}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
Ok(format!("found {} matches:\n{}", results.len(), output)) Ok(format!("found {} matches:\n{}", results.len(), output))
@@ -144,14 +144,14 @@ impl Tool for Glob {
.to_string(); .to_string();
let root = resolve_path(&ctx.workspaces, &rel)?; let root = resolve_path(&ctx.workspaces, &rel)?;
if !root.exists() || !root.is_dir() { if !root.exists() || !root.is_dir() {
anyhow::bail!("path '{}' is not a valid directory", rel); anyhow::bail!("path '{rel}' is not a valid directory");
} }
let mut builder = GlobSetBuilder::new(); let mut builder = GlobSetBuilder::new();
let full_pattern = root.join(&pat_str).display().to_string(); let full_pattern = root.join(&pat_str).display().to_string();
builder.add(GlobBuilder::new(&full_pattern).build() builder.add(GlobBuilder::new(&full_pattern).build()
.map_err(|e| anyhow!("invalid glob pattern '{}': {}", pat_str, e))?); .map_err(|e| anyhow!("invalid glob pattern '{pat_str}': {e}"))?);
let glob_set = builder.build() let glob_set = builder.build()
.map_err(|e| anyhow!("failed to build glob set: {}", e))?; .map_err(|e| anyhow!("failed to build glob set: {e}"))?;
let mut matches: Vec<String> = Vec::new(); let mut matches: Vec<String> = Vec::new();
for entry in Walk::new(&root).flatten() { for entry in Walk::new(&root).flatten() {
let p = entry.path(); let p = entry.path();
@@ -165,7 +165,7 @@ impl Tool for Glob {
} }
matches.sort(); matches.sort();
if matches.is_empty() { if matches.is_empty() {
return Ok(format!("no files match '{}' in {}", pat_str, rel)); return Ok(format!("no files match '{pat_str}' in {rel}"));
} }
Ok(matches.join("\n")) Ok(matches.join("\n"))
} }
+10 -10
View File
@@ -63,13 +63,13 @@ impl Tool for Bash {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: command"))? .ok_or_else(|| anyhow!("missing required argument: command"))?
.to_string(); .to_string();
let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120_000).min(600_000); let timeout_ms = args.get("timeout").and_then(serde_json::Value::as_u64).unwrap_or(120_000).min(600_000);
// Only gate destructive git operations; credential reads are allowed // Only gate destructive git operations; credential reads are allowed
// locally since the AI needs access, and the real threat is committing // locally since the AI needs access, and the real threat is committing
// secrets to a public repo (handled by git pre-commit hooks / user). // secrets to a public repo (handled by git pre-commit hooks / user).
super::shell_filter::git::check_git_destructive(&cmd) super::shell_filter::git::check_git_destructive(&cmd)
.map_err(|e| anyhow!("blocked: {}", e))?; .map_err(|e| anyhow!("blocked: {e}"))?;
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false); let run_in_background = args.get("run_in_background").and_then(serde_json::Value::as_bool).unwrap_or(false);
if run_in_background { if run_in_background {
let job = crate::app::bgbash::job::spawn_bash_job(cmd); let job = crate::app::bgbash::job::spawn_bash_job(cmd);
return Ok(format!("Background job: {}", job.id)); return Ok(format!("Background job: {}", job.id));
@@ -80,7 +80,7 @@ impl Tool for Bash {
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
.spawn() .spawn()
.map_err(|e| anyhow!("failed to spawn bash: {}", e))?; .map_err(|e| anyhow!("failed to spawn bash: {e}"))?;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let timeout = Duration::from_millis(timeout_ms); let timeout = Duration::from_millis(timeout_ms);
loop { loop {
@@ -88,16 +88,16 @@ impl Tool for Bash {
Ok(Some(status)) => { Ok(Some(status)) => {
let elapsed = start.elapsed().as_secs_f64(); let elapsed = start.elapsed().as_secs_f64();
let output = child.wait_with_output() let output = child.wait_with_output()
.map_err(|e| anyhow!("failed to collect output: {}", e))?; .map_err(|e| anyhow!("failed to collect output: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
let trimmed = combined.trim().to_string(); let trimmed = combined.trim().to_string();
if status.success() { if status.success() {
return Ok(if trimmed.is_empty() { return Ok(if trimmed.is_empty() {
format!("Command completed in {:.2}s (exit code 0)", elapsed) format!("Command completed in {elapsed:.2}s (exit code 0)")
} else { } else {
format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed) format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
}); });
} }
return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed)); return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed));
@@ -106,12 +106,12 @@ impl Tool for Bash {
if start.elapsed() > timeout { if start.elapsed() > timeout {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
anyhow::bail!("command timed out after {}ms", timeout_ms); anyhow::bail!("command timed out after {timeout_ms}ms");
} }
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
Err(e) => { Err(e) => {
anyhow::bail!("failed to wait for command: {}", e); anyhow::bail!("failed to wait for command: {e}");
} }
} }
} }
+2 -2
View File
@@ -56,7 +56,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes); let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
for pattern in &patterns { for pattern in &patterns {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) { if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
anyhow::bail!("destructive git operation blocked: '{}'", pattern); anyhow::bail!("destructive git operation blocked: '{pattern}'");
} }
} }
// Additional check: any `+` prefixed refspec in a `git push` is a // Additional check: any `+` prefixed refspec in a `git push` is a
@@ -69,7 +69,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
&cmd_no_quotes &cmd_no_quotes
}; };
if check_push.contains("push") { if check_push.contains("push") {
let push_end = cmd_no_quotes.find("push").map(|i| i + 4).unwrap_or(0); let push_end = cmd_no_quotes.find("push").map_or(0, |i| i + 4);
let after_push = &cmd_no_quotes[push_end..]; let after_push = &cmd_no_quotes[push_end..];
if after_push.contains('+') { if after_push.contains('+') {
anyhow::bail!("destructive git operation blocked: force push via +refspec"); anyhow::bail!("destructive git operation blocked: force push via +refspec");
+2 -2
View File
@@ -34,7 +34,7 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
Some('\'') => decoded.push('\''), Some('\'') => decoded.push('\''),
Some('x' | 'X') => { Some('x' | 'X') => {
// \xHH — hex escape (2 hex digits) // \xHH — hex escape (2 hex digits)
let hex: String = chars.by_ref().take(2).take_while(|c| c.is_ascii_hexdigit()).collect(); let hex: String = chars.by_ref().take(2).take_while(char::is_ascii_hexdigit).collect();
if hex.len() == 2 { if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) { if let Ok(byte) = u8::from_str_radix(&hex, 16) {
decoded.push(byte as char); decoded.push(byte as char);
@@ -47,7 +47,7 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
} }
Some('u') => { Some('u') => {
// \uNNNN — unicode escape (4 hex digits) // \uNNNN — unicode escape (4 hex digits)
let hex: String = chars.by_ref().take(4).take_while(|c| c.is_ascii_hexdigit()).collect(); let hex: String = chars.by_ref().take(4).take_while(char::is_ascii_hexdigit).collect();
if hex.len() == 4 { if hex.len() == 4 {
if let Ok(code) = u32::from_str_radix(&hex, 16) { if let Ok(code) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(code) { if let Some(c) = char::from_u32(code) {
+21 -22
View File
@@ -51,12 +51,13 @@ impl Tool for SpawnAgents {
}) })
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
use std::sync::{Arc, Mutex};
let agents: Vec<String> = args.get("agents") let agents: Vec<String> = args.get("agents")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("missing required argument: agents"))? .ok_or_else(|| anyhow!("missing required argument: agents"))?
.iter() .iter()
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect(); .collect();
if agents.is_empty() { if agents.is_empty() {
@@ -67,9 +68,8 @@ impl Tool for SpawnAgents {
} }
let max_concurrency = args.get("max_concurrency") let max_concurrency = args.get("max_concurrency")
.and_then(|v| v.as_u64()) .and_then(serde_json::Value::as_u64)
.map(|v| v.min(10) as usize) .map_or(10, |v| v.min(10) as usize);
.unwrap_or(10);
let agent_count = agents.len(); let agent_count = agents.len();
let primitives: Vec<ScriptPrimitive> = agents let primitives: Vec<ScriptPrimitive> = agents
@@ -78,8 +78,8 @@ impl Tool for SpawnAgents {
.collect(); .collect();
let wf = WorkflowScript { let wf = WorkflowScript {
name: format!("parallel-{}-agents", agent_count), name: format!("parallel-{agent_count}-agents"),
description: format!("Auto-spawned parallel workflow with {} agents", agent_count), description: format!("Auto-spawned parallel workflow with {agent_count} agents"),
script: ScriptPrimitive::Parallel(primitives), script: ScriptPrimitive::Parallel(primitives),
options: ScriptOptions { options: ScriptOptions {
max_concurrency, max_concurrency,
@@ -88,8 +88,7 @@ impl Tool for SpawnAgents {
}, },
}; };
use std::sync::{Arc, Mutex}; let live: Option<crate::app::workflow::engine::LiveStateFn> = ctx.turn_events.as_ref().map(|turn_events| {
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
let turn_events = turn_events.clone(); let turn_events = turn_events.clone();
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| { let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| {
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
@@ -113,12 +112,12 @@ impl Tool for SpawnAgents {
max_concurrency, max_concurrency,
true, true,
live.as_ref(), live.as_ref(),
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
&findings, &findings,
None, // no per-agent timeout for spawn_agents None, // no per-agent timeout for spawn_agents
)?; )?;
format_results(results, "parallel") Ok(format_results(&results, "parallel"))
} }
} }
@@ -150,12 +149,13 @@ impl Tool for SpawnPipeline {
}) })
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
use std::sync::{Arc, Mutex};
let stages: Vec<String> = args.get("stages") let stages: Vec<String> = args.get("stages")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("missing required argument: stages"))? .ok_or_else(|| anyhow!("missing required argument: stages"))?
.iter() .iter()
.filter_map(|v| v.as_str().map(|s| s.to_string())) .filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect(); .collect();
if stages.is_empty() { if stages.is_empty() {
@@ -178,8 +178,7 @@ impl Tool for SpawnPipeline {
}, },
}; };
use std::sync::{Arc, Mutex}; let live: Option<crate::app::workflow::engine::LiveStateFn> = ctx.turn_events.as_ref().map(|turn_events| {
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
let turn_events = turn_events.clone(); let turn_events = turn_events.clone();
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| { let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| {
if let Ok(mut q) = turn_events.lock() { if let Ok(mut q) = turn_events.lock() {
@@ -202,24 +201,24 @@ impl Tool for SpawnPipeline {
1, 1,
false, false,
live.as_ref(), live.as_ref(),
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
&findings, &findings,
None, // no per-agent timeout for spawn_pipeline None, // no per-agent timeout for spawn_pipeline
)?; )?;
format_results(results, "pipeline") Ok(format_results(&results, "pipeline"))
} }
} }
/// Format a list of agent results into a readable summary string. /// Format a list of agent results into a readable summary string.
fn format_results(results: Vec<String>, mode: &str) -> Result<String> { fn format_results(results: &[String], mode: &str) -> String {
if results.is_empty() { if results.is_empty() {
return Ok(format!("{} workflow completed with no output", mode)); return format!("{mode} workflow completed with no output");
} }
let formatted: Vec<String> = results let formatted: Vec<String> = results
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, r)| format!("=== Agent {} ===\n{}", i + 1, r.trim())) .map(|(i, r)| format!("=== Agent {} ===\n{}", i + 1, r.trim()))
.collect(); .collect();
Ok(formatted.join("\n\n")) formatted.join("\n\n")
} }
+3 -3
View File
@@ -69,19 +69,19 @@ impl Tool for DirCacheUpdate {
// Create a one-shot runtime so this tool works from any thread (the // Create a one-shot runtime so this tool works from any thread (the
// agent turn runs on a std::thread that has no tokio context). // agent turn runs on a std::thread that has no tokio context).
let rt = tokio::runtime::Runtime::new() let rt = tokio::runtime::Runtime::new()
.map_err(|e| anyhow!("failed to create temp runtime: {}", e))?; .map_err(|e| anyhow!("failed to create temp runtime: {e}"))?;
rt.block_on(async { rt.block_on(async {
let cache = dc.write().await; let cache = dc.write().await;
cache.set(entries).await; cache.set(entries).await;
}); });
Ok(format!("cached {} entries for {}", count, rel)) Ok(format!("cached {count} entries for {rel}"))
} }
} }
/// Non-recursively list the immediate entries of `path`. /// Non-recursively list the immediate entries of `path`.
/// ///
/// Flow: read_dir → flatten Ok entries → collect their paths. /// Flow: `read_dir` → flatten Ok entries → collect their paths.
/// ///
/// Why: silently skips unreadable entries (e.g. permission errors) /// Why: silently skips unreadable entries (e.g. permission errors)
/// rather than failing the whole cache update. /// rather than failing the whole cache update.
+4 -4
View File
@@ -67,13 +67,13 @@ impl Tool for DirList {
} }
let entries: Vec<String> = fs::read_dir(&path) let entries: Vec<String> = fs::read_dir(&path)
.map_err(|e| anyhow!("failed to read directory '{}': {}", rel, e))? .map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.map(|e| { .map(|e| {
let name = e.file_name().to_string_lossy().to_string(); let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
if is_dir { if is_dir {
format!("{}/", name) format!("{name}/")
} else { } else {
name name
} }
+1 -1
View File
@@ -39,6 +39,6 @@ impl Tool for Pong {
let msg = args.get("message") let msg = args.get("message")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("pong"); .unwrap_or("pong");
Ok(format!("pong: {}", msg)) Ok(format!("pong: {msg}"))
} }
} }
+4 -4
View File
@@ -36,9 +36,9 @@ impl Tool for Todofinish {
} }
let content = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path)
.map_err(|e| anyhow!("failed to read todo.md: {}", e))?; .map_err(|e| anyhow!("failed to read todo.md: {e}"))?;
let task_index = args.get("task_index").and_then(|v| v.as_i64()); let task_index = args.get("task_index").and_then(serde_json::Value::as_i64);
let mut new_content = String::new(); let mut new_content = String::new();
let mut task_count = 0; let mut task_count = 0;
@@ -70,10 +70,10 @@ impl Tool for Todofinish {
} }
std::fs::write(&path, new_content) std::fs::write(&path, new_content)
.map_err(|e| anyhow!("failed to write to todo.md: {}", e))?; .map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
if let Some(idx) = task_index { if let Some(idx) = task_index {
Ok(format!("Successfully marked task {} as finished.", idx)) Ok(format!("Successfully marked task {idx} as finished."))
} else { } else {
Ok("Successfully marked ALL tasks as finished.".to_string()) Ok("Successfully marked ALL tasks as finished.".to_string())
} }
+4 -4
View File
@@ -59,17 +59,17 @@ impl Tool for Todowrite {
let path: PathBuf = ctx.session_dir.join("todo.md"); let path: PathBuf = ctx.session_dir.join("todo.md");
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S"); let timestamp = now.format("%Y-%m-%d %H:%M:%S");
let line = format!("- [ ] {} ({})\n", task, timestamp); let line = format!("- [ ] {task} ({timestamp})\n");
fs::OpenOptions::new() fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(&path) .open(&path)
.map_err(|e| anyhow!("failed to open todo.md: {}", e))? .map_err(|e| anyhow!("failed to open todo.md: {e}"))?
.write_all(line.as_bytes()) .write_all(line.as_bytes())
.map_err(|e| anyhow!("failed to write to todo.md: {}", e))?; .map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
Ok(format!("added task to todo.md: {}", task)) Ok(format!("added task to todo.md: {task}"))
} }
} }
+10 -10
View File
@@ -58,14 +58,14 @@ impl Tool for WorkflowRun {
/// ///
/// Return: the workflow engine's output string, or an error if the /// Return: the workflow engine's output string, or an error if the
/// script argument is missing or fails to parse as JSON. /// script argument is missing or fails to parse as JSON.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let script_str = args.get("script") let script_str = args.get("script")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: script"))?; .ok_or_else(|| anyhow!("missing required argument: script"))?;
let workflow_script: crate::app::workflow::script::WorkflowScript = let workflow_script: crate::app::workflow::script::WorkflowScript =
serde_json::from_str(script_str) serde_json::from_str(script_str)
.map_err(|e| anyhow!("failed to parse workflow script: {}", e))?; .map_err(|e| anyhow!("failed to parse workflow script: {e}"))?;
let workflow_args: std::collections::HashMap<String, String> = args.get("args") let workflow_args: std::collections::HashMap<String, String> = args.get("args")
.and_then(|v| v.as_object()) .and_then(|v| v.as_object())
@@ -77,7 +77,7 @@ impl Tool for WorkflowRun {
.unwrap_or_default(); .unwrap_or_default();
crate::app::workflow::engine::run_workflow( crate::app::workflow::engine::run_workflow(
&workflow_script, &workflow_args, &_ctx.session_dir, &_ctx.workspaces, &workflow_script, &workflow_args, &ctx.session_dir, &ctx.workspaces,
) )
} }
} }
@@ -177,7 +177,7 @@ impl Tool for CompanyPipeline {
}) })
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let request = args.get("request") let request = args.get("request")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: request"))?; .ok_or_else(|| anyhow!("missing required argument: request"))?;
@@ -190,17 +190,17 @@ impl Tool for CompanyPipeline {
"quick" => { "quick" => {
crate::app::workflow::company::run_company_pipeline_quick( crate::app::workflow::company::run_company_pipeline_quick(
request, request,
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
_ctx.turn_events.as_ref(), ctx.turn_events.as_ref(),
) )
} }
_ => { _ => {
crate::app::workflow::company::run_company_pipeline( crate::app::workflow::company::run_company_pipeline(
request, request,
&_ctx.session_dir, &ctx.session_dir,
&_ctx.workspaces, &ctx.workspaces,
_ctx.turn_events.as_ref(), ctx.turn_events.as_ref(),
) )
} }
} }
+8 -6
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Chat transcript panel rendering — message cards with role badges. //! Chat transcript panel rendering — message cards with role badges.
//! //!
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a
@@ -19,7 +20,7 @@ use ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> { fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let mut current_spans = Vec::new(); let mut current_spans = Vec::new();
@@ -76,10 +77,11 @@ fn format_timestamp(ts: i64) -> String {
let secs = ts / 1000; let secs = ts / 1000;
let mins = (secs / 60) % 60; let mins = (secs / 60) % 60;
let hrs = (secs / 3600) % 24; let hrs = (secs / 3600) % 24;
format!("{:02}:{:02}", hrs, mins) format!("{hrs:02}:{mins:02}")
} }
/// Render the scrollable chat transcript panel with message card styling. /// Render the scrollable chat transcript panel with message card styling.
#[allow(clippy::too_many_lines)]
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let messages = &state.transcript_cache.messages; let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset; let scroll_offset = state.scroll.offset;
@@ -95,7 +97,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
}; };
// ── Render messages as cards ───────────────────────────────────────── // ── Render messages as cards ─────────────────────────────────────────
for (_msg_idx, msg) in messages.iter().enumerate() { for msg in messages {
let accent = role_accent_color(&msg.role); let accent = role_accent_color(&msg.role);
let badge = role_badge(&msg.role); let badge = role_badge(&msg.role);
let label = role_label(&msg.role); let label = role_label(&msg.role);
@@ -119,12 +121,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
), ),
// Role name // Role name
Span::styled( Span::styled(
format!(" {}", label), format!(" {label}"),
Style::default().fg(accent).add_modifier(Modifier::BOLD), Style::default().fg(accent).add_modifier(Modifier::BOLD),
), ),
// Timestamp // Timestamp
Span::styled( Span::styled(
if ts_str.is_empty() { String::new() } else { format!(" {}", ts_str) }, if ts_str.is_empty() { String::new() } else { format!(" {ts_str}") },
Style::default().fg(Theme::TEXT_DIM), Style::default().fg(Theme::TEXT_DIM),
), ),
]); ]);
@@ -176,7 +178,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!(" {} ", spinner), format!(" {spinner} "),
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
+11 -25
View File
@@ -14,12 +14,13 @@ use super::theme::Theme;
/// Render a markdown string into styled terminal spans, word-wrapped to `width`. /// Render a markdown string into styled terminal spans, word-wrapped to `width`.
/// ///
/// Flow: pulldown_cmark parses `text` into an event stream → each /// Flow: `pulldown_cmark` parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s → /// Start/End/Text/Code/Break event is translated into styled `Span`s →
/// if `width > 0`, a second pass wraps long lines. /// if `width > 0`, a second pass wraps long lines.
/// ///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` /// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget. /// turns it back into `Line`s for the Paragraph widget.
#[allow(clippy::too_many_lines)]
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> { pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let mut spans = Vec::new(); let mut spans = Vec::new();
let parser = pulldown_cmark::Parser::new(text); let parser = pulldown_cmark::Parser::new(text);
@@ -61,9 +62,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
pulldown_cmark::Tag::Paragraph => { pulldown_cmark::Tag::Paragraph => {
first_in_paragraph = true; first_in_paragraph = true;
} }
pulldown_cmark::Tag::Emphasis => {}
pulldown_cmark::Tag::Strong => {}
pulldown_cmark::Tag::List(_) => {}
pulldown_cmark::Tag::Item => { pulldown_cmark::Tag::Item => {
// List item bullet // List item bullet
spans.push(Span::styled( spans.push(Span::styled(
@@ -79,7 +77,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
// We push the URL as a tooltip-like suffix // We push the URL as a tooltip-like suffix
// After the link text ends, we'll add the URL // After the link text ends, we'll add the URL
spans.push(Span::styled( spans.push(Span::styled(
format!("]({})", dest_url), format!("]({dest_url})"),
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
)); ));
} }
@@ -111,14 +109,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
first_in_paragraph = true; first_in_paragraph = true;
spans.push(Span::raw("\n\n")); spans.push(Span::raw("\n\n"));
} }
pulldown_cmark::TagEnd::Emphasis => {} pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
pulldown_cmark::TagEnd::Strong => {}
pulldown_cmark::TagEnd::List(_) => {}
pulldown_cmark::TagEnd::Item => {
spans.push(Span::raw("\n"));
}
pulldown_cmark::TagEnd::Link => {}
pulldown_cmark::TagEnd::BlockQuote(_) => {
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
_ => {} _ => {}
@@ -128,7 +119,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let s = text.to_string(); let s = text.to_string();
if in_code_block { if in_code_block {
spans.push(Span::styled( spans.push(Span::styled(
format!(" {}", s), format!(" {s}"),
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
)); ));
} else if in_heading { } else if in_heading {
@@ -138,14 +129,9 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
3 => Theme::ACCENT_PURPLE, 3 => Theme::ACCENT_PURPLE,
_ => Theme::TEXT, _ => Theme::TEXT,
}; };
let prefix = match heading_level { let prefix = " ";
1 => " ",
2 => " ",
3 => " ",
_ => " ",
};
spans.push(Span::styled( spans.push(Span::styled(
format!("{}{}", prefix, s), format!("{prefix}{s}"),
Style::default().fg(color).add_modifier(Modifier::BOLD), Style::default().fg(color).add_modifier(Modifier::BOLD),
)); ));
} else { } else {
@@ -160,7 +146,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
pulldown_cmark::Event::Code(text) => { pulldown_cmark::Event::Code(text) => {
// Inline code with background // Inline code with background
spans.push(Span::styled( spans.push(Span::styled(
format!(" {} ", text), format!(" {text} "),
Style::default() Style::default()
.fg(Theme::ACCENT_TEAL) .fg(Theme::ACCENT_TEAL)
.bg(Theme::CODE_BAR) .bg(Theme::CODE_BAR)
@@ -195,10 +181,10 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
spans_out.push(Span::styled(text_str.to_string(), style)); spans_out.push(Span::styled(text_str.to_string(), style));
if !text_str.contains('\n') { if text_str.contains('\n') {
line_len += remaining;
} else {
line_len = text_str.split('\n').next_back().unwrap_or("").len(); line_len = text_str.split('\n').next_back().unwrap_or("").len();
} else {
line_len += remaining;
} }
} }
spans = spans_out; spans = spans_out;
+39 -39
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Top-level TUI render pipeline: layouts the terminal into chat / input //! Top-level TUI render pipeline: layouts the terminal into chat / input
//! / status regions, dispatches overlay rendering with glassmorphism-style //! / status regions, dispatches overlay rendering with glassmorphism-style
//! centered panels, and floats toast notifications over the top-right corner. //! centered panels, and floats toast notifications over the top-right corner.
@@ -124,6 +125,7 @@ fn render_main_panel(
/// - A top accent border strip (colored per variant) /// - A top accent border strip (colored per variant)
/// - A title line with icon /// - A title line with icon
/// - Content area with proper spacing /// - Content area with proper spacing
#[allow(clippy::too_many_lines)]
fn render_overlay( fn render_overlay(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
@@ -172,12 +174,12 @@ fn render_overlay(
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Max tokens: {}", format!(" Max tokens: {}",
state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())), state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Temperature: {}", format!(" Temperature: {}",
state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())), state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
@@ -261,11 +263,11 @@ fn render_overlay(
input_text.as_str() input_text.as_str()
} }
}; };
let masked = if !input_text.is_empty() { let masked = if input_text.is_empty() {
let suffix = if input_text.len() > 8 { "****" } else { "" };
format!("{}{}", display, suffix)
} else {
display.to_string() display.to_string()
} else {
let suffix = if input_text.len() > 8 { "****" } else { "" };
format!("{display}{suffix}")
}; };
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -329,9 +331,9 @@ fn render_overlay(
let selected = i == current_idx; let selected = i == current_idx;
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
if selected { if selected {
format!("{} (active)", l) format!("{l} (active)")
} else { } else {
format!(" {}", l) format!(" {l}")
}, },
if selected { if selected {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
@@ -386,7 +388,7 @@ fn render_overlay(
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Messages: {}", msg_count), format!(" Messages: {msg_count}"),
Style::default().fg(Theme::INFO), Style::default().fg(Theme::INFO),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
@@ -427,7 +429,7 @@ fn render_overlay(
}; };
let preview: String = msg.content.chars().take(70).collect(); let preview: String = msg.content.chars().take(70).collect();
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!(" [{}] {}", role_str, preview), format!(" [{role_str}] {preview}"),
Style::default().fg( Style::default().fg(
if matches!(msg.role, crate::dto::chat::message::Role::User) { if matches!(msg.role, crate::dto::chat::message::Role::User) {
Theme::INFO Theme::INFO
@@ -484,7 +486,7 @@ fn render_overlay(
let (label, style) = match item { let (label, style) = match item {
crate::app::mode::learning::LearningItem::Pending { name, .. } => { crate::app::mode::learning::LearningItem::Pending { name, .. } => {
( (
format!("{}[Pending] {}", prefix, name), format!("{prefix}[Pending] {name}"),
if is_selected { if is_selected {
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM) Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
@@ -496,7 +498,7 @@ fn render_overlay(
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => { crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
let status = if lifecycle == "stale" { "Stale" } else { "Active" }; let status = if lifecycle == "stale" { "Stale" } else { "Active" };
( (
format!("{}[{}] {}", prefix, status, name), format!("{prefix}[{status}] {name}"),
if is_selected { if is_selected {
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM) Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
@@ -539,7 +541,7 @@ fn render_overlay(
" Name:", Style::default().fg(Theme::TEXT_DIM), " Name:", Style::default().fg(Theme::TEXT_DIM),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", name), format!(" {name}"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -548,11 +550,11 @@ fn render_overlay(
Style::default().fg(Theme::WARNING), Style::default().fg(Theme::WARNING),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Scope: {}", scope), format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Confidence: {}", confidence), format!(" Confidence: {confidence}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -561,7 +563,7 @@ fn render_overlay(
))); )));
for line in content.lines() { for line in content.lines() {
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", line), format!(" {line}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
} }
@@ -578,7 +580,7 @@ fn render_overlay(
" Name:", Style::default().fg(Theme::TEXT_DIM), " Name:", Style::default().fg(Theme::TEXT_DIM),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", name), format!(" {name}"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -588,15 +590,15 @@ fn render_overlay(
Theme::SUCCESS Theme::SUCCESS
}; };
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Status: {}", lifecycle), format!(" Status: {lifecycle}"),
Style::default().fg(status_color), Style::default().fg(status_color),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Scope: {}", scope), format!(" Scope: {scope}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" Description: {}", description), format!(" Description: {description}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::raw("")));
@@ -605,7 +607,7 @@ fn render_overlay(
))); )));
for line in content.lines() { for line in content.lines() {
right_lines.push(Line::from(Span::styled( right_lines.push(Line::from(Span::styled(
format!(" {}", line), format!(" {line}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
))); )));
} }
@@ -635,7 +637,7 @@ fn render_overlay(
.border_style(Style::default().fg(Theme::INFO)); .border_style(Style::default().fg(Theme::INFO));
let runtime = state.session_runtime.as_ref(); let runtime = state.session_runtime.as_ref();
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime
.map(|r| { .map_or((0, 0, 0, 0, 0), |r| {
( (
r.usage.tokens_in, r.usage.tokens_in,
r.usage.tokens_out, r.usage.tokens_out,
@@ -643,18 +645,16 @@ fn render_overlay(
r.usage.review_tokens, r.usage.review_tokens,
r.session_start, r.session_start,
) )
}) });
.unwrap_or((0, 0, 0, 0, 0));
let (edit_count, lesson_count, review_count, consec_empty) = runtime let (edit_count, lesson_count, review_count, consec_empty) = runtime
.map(|r| { .map_or((0, 0, 0, 0), |r| {
( (
r.edit_count, r.edit_count,
r.lesson_count, r.lesson_count,
r.review_count, r.review_count,
r.consecutive_empty_reviews, r.consecutive_empty_reviews,
) )
}) });
.unwrap_or((0, 0, 0, 0));
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
let hours = elapsed_ms / 3_600_000; let hours = elapsed_ms / 3_600_000;
let minutes = (elapsed_ms % 3_600_000) / 60_000; let minutes = (elapsed_ms % 3_600_000) / 60_000;
@@ -669,19 +669,19 @@ fn render_overlay(
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Main agent: {} tokens", main_tokens), format!(" Main agent: {main_tokens} tokens"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Self-learning: {} tokens", self_learning_total), format!(" Self-learning: {self_learning_total} tokens"),
Style::default().fg(Theme::TEXT_MUTED), Style::default().fg(Theme::TEXT_MUTED),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Total: {} tokens", total_tokens), format!(" Total: {total_tokens} tokens"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" API calls: {}", api_calls), format!(" API calls: {api_calls}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
@@ -690,21 +690,21 @@ fn render_overlay(
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Edits: {}", edit_count), format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Reviews: {}", review_count), format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Lessons: {}", lesson_count), format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED), Style::default().fg(Theme::TEXT_MUTED),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Empty reviews: {}", format!(" Empty reviews: {}",
if consec_empty > 3 { if consec_empty > 3 {
format!("{}", consec_empty) format!("{consec_empty}")
} else { } else {
consec_empty.to_string() consec_empty.to_string()
}, },
@@ -713,7 +713,7 @@ fn render_overlay(
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled( Line::from(Span::styled(
format!(" Session: {}h {}m {}s", hours, minutes, seconds), format!(" Session: {hours}h {minutes}m {seconds}s"),
Style::default().fg(Theme::TEXT_DIM), Style::default().fg(Theme::TEXT_DIM),
)), )),
]; ];
@@ -757,7 +757,7 @@ fn render_overlay(
let is_selected = i == state.misc.selected_index; let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " }; let prefix = if is_selected { "" } else { " " };
let model_str = cfg.default_model.as_deref().unwrap_or("(any)"); let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
let label = format!("{}{} ({})", prefix, name, model_str); let label = format!("{prefix}{name} ({model_str})");
let style = if is_current { let style = if is_current {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
} else if is_selected { } else if is_selected {
@@ -842,7 +842,7 @@ fn render_input_bar(
} else { } else {
Style::default().fg(Theme::TEXT) Style::default().fg(Theme::TEXT)
}; };
let label = format!("{}{}", prefix, candidate); let label = format!("{prefix}{candidate}");
lines.push(Line::from(Span::styled(label, style))); lines.push(Line::from(Span::styled(label, style)));
} }
let dropdown = Paragraph::new(lines).block(dropdown_block); let dropdown = Paragraph::new(lines).block(dropdown_block);
@@ -901,7 +901,7 @@ fn render_input_bar(
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
/// Render active toasts as a floating stack at top-right of the terminal. /// Render active toasts as a floating stack at top-right of the terminal.
/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once. /// Each toast auto-expires after its `lifetime_ms`. Max 4 visible at once.
/// ///
/// Toasts are stacked vertically with a 1-line gap. Each has a colored /// Toasts are stacked vertically with a 1-line gap. Each has a colored
/// left border and a subtle background. /// left border and a subtle background.
+8 -7
View File
@@ -1,3 +1,4 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Status bar rendering for the TUI — modern segmented bar design. //! Status bar rendering for the TUI — modern segmented bar design.
//! //!
//! Flow: `draw_status_bar` reads live connection/turn state off //! Flow: `draw_status_bar` reads live connection/turn state off
@@ -20,14 +21,15 @@ use super::theme::Theme;
/// Layout (left-to-right, space-filling): /// Layout (left-to-right, space-filling):
/// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI) /// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI)
/// CENTER: spinner + optional contextual info /// CENTER: spinner + optional contextual info
/// RIGHT: provider · model · ↑tokens_in ↓tokens_out /// RIGHT: provider · model · ↑`tokens_in``tokens_out`
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
use ratatui::layout::{Constraint, Direction, Layout};
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""]; let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
// ── Agent status badge ──────────────────────────────────────────────── // ── Agent status badge ────────────────────────────────────────────────
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() { let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
(format!(" {} PROG ", f), Theme::MODE_YOLO, Theme::BG) (format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
} else if state.misc.api_connected { } else if state.misc.api_connected {
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG) (" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
} else { } else {
@@ -61,7 +63,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
let total_chars: usize = rt.messages.iter() let total_chars: usize = rt.messages.iter()
.filter_map(|m| m.content.as_deref()) .filter_map(|m| m.content.as_deref())
.map(|c| c.len()) .map(str::len)
.sum(); .sum();
let current_tokens = total_chars / 4; let current_tokens = total_chars / 4;
@@ -69,8 +71,8 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {
parts.push(format!("{}{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out)); parts.push(format!("{}{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out));
} }
let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string()); let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string());
parts.push(format!("{}/{}", current_tokens, max_str)); parts.push(format!("{current_tokens}/{max_str}"));
parts.push(state.settings.provider.clone()); parts.push(state.settings.provider.clone());
parts.push(state.settings.model.clone()); parts.push(state.settings.model.clone());
@@ -79,7 +81,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
let max_tokens = state.app_config.model_roles.values() let max_tokens = state.app_config.model_roles.values()
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model) .find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
.and_then(|role| role.context_window); .and_then(|role| role.context_window);
let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string()); let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string());
format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model) format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model)
}; };
@@ -92,7 +94,6 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
)); ));
// Render the bar using two columns // Render the bar using two columns
use ratatui::layout::{Constraint, Direction, Layout};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
+6 -5
View File
@@ -55,6 +55,7 @@ fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -
} }
/// Render the workflow status panel. /// Render the workflow status panel.
#[allow(clippy::too_many_lines)]
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
@@ -179,7 +180,7 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
// Agent card header // Agent card header
card_lines.push(Line::from(vec![ card_lines.push(Line::from(vec![
Span::styled( Span::styled(
format!(" {} ", icon), format!(" {icon} "),
Style::default().fg(color).add_modifier(Modifier::BOLD), Style::default().fg(color).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
@@ -187,7 +188,7 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!(" [{}]", label), format!(" [{label}]"),
Style::default().fg(color), Style::default().fg(color),
), ),
Span::styled( Span::styled(
@@ -240,18 +241,18 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
])); ]));
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", tool_count), Style::default().fg(Theme::SUCCESS)), Span::styled(format!(" {tool_count}"), Style::default().fg(Theme::SUCCESS)),
])); ]));
if pending > 0 { if pending > 0 {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", pending), Style::default().fg(Theme::WARNING)), Span::styled(format!(" {pending}"), Style::default().fg(Theme::WARNING)),
])); ]));
} }
if bash_count > 0 { if bash_count > 0 {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", bash_count), Style::default().fg(Theme::WARNING)), Span::styled(format!(" {bash_count}"), Style::default().fg(Theme::WARNING)),
])); ]));
} }
} else { } else {