diff --git a/crates/zesdex-backend/src/app/lsp/client.rs b/crates/zesdex-backend/src/app/lsp/client.rs index 15ac237..fd6190a 100644 --- a/crates/zesdex-backend/src/app/lsp/client.rs +++ b/crates/zesdex-backend/src/app/lsp/client.rs @@ -303,24 +303,35 @@ impl LspClient { ) } + /// Call a textDocument/positional method (hover, completion, definition, references). + /// + /// Builds the standard `{ textDocument: { uri }, position: { line, character } }` body + /// and delegates to `self.call`. `extra` is merged into the body when present (used by + /// `references` to include the `context` block). + fn call_positional( + &mut self, + method: &str, + uri: &str, + line: u32, + character: u32, + extra: Option, + ) -> anyhow::Result { + let mut body = json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + }); + if let Some(ref extra) = extra { + merge_json(&mut body, extra); + } + self.call(method, &body) + } + pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call( - "textDocument/hover", - &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - }), - ) + self.call_positional("textDocument/hover", uri, line, character, None) } pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call( - "textDocument/completion", - &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - }), - ) + self.call_positional("textDocument/completion", uri, line, character, None) } pub fn goto_definition( @@ -329,25 +340,13 @@ impl LspClient { line: u32, character: u32, ) -> anyhow::Result { - self.call( - "textDocument/definition", - &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - }), - ) + self.call_positional("textDocument/definition", uri, line, character, None) } pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { - self.call( - "textDocument/references", - &json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character }, - "context": { - "includeDeclaration": true - } - }), + self.call_positional( + "textDocument/references", uri, line, character, + Some(json!({"context": { "includeDeclaration": true }})), ) } @@ -384,6 +383,19 @@ impl Drop for LspClient { } } +/// Merge the fields of `b` into the object `a` (mutating `a` in place). +/// +/// Used by `LspClient::call_positional` to layer extra fields (e.g. `context`) +/// onto the standard positional-query body. When `a` is not an object or `b` +/// is not an object this is a no-op. +fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) { + if let (Some(map), Some(extra)) = (a.as_object_mut(), b.as_object()) { + for (k, v) in extra { + map.insert(k.clone(), v.clone()); + } + } +} + pub fn path_to_lsp_uri(path: &str) -> String { file_path_to_uri(path) } diff --git a/crates/zesdex-backend/src/app/mode/effort.rs b/crates/zesdex-backend/src/app/mode/effort.rs index 196f202..f87cdf9 100644 --- a/crates/zesdex-backend/src/app/mode/effort.rs +++ b/crates/zesdex-backend/src/app/mode/effort.rs @@ -47,9 +47,6 @@ pub fn cycle_effort(state: &mut AppStateRest) { let current = current_effort(state); state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len(); let label = current_effort_str(state); - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Info, - format!("Effort: {label}"), - )); + state.toast_info(format!("Effort: {label}")); state.dirty = true; } diff --git a/crates/zesdex-backend/src/app/mode/mod.rs b/crates/zesdex-backend/src/app/mode/mod.rs index 9d7e3ec..97ed29d 100644 --- a/crates/zesdex-backend/src/app/mode/mod.rs +++ b/crates/zesdex-backend/src/app/mode/mod.rs @@ -11,3 +11,22 @@ pub mod quit_confirm; pub mod rewind; pub mod settings; pub mod todo; + +/// Cycle `current` in the range `[0, len)`. +/// +/// * `forward = true` — increment (wrap at len) +/// * `forward = false` — decrement (wrap at 0), saturating at 0 when len is 0 +/// +/// Return: `0` when `len == 0`, otherwise the wrapped index. +pub fn cycle_selected_index(current: usize, len: usize, forward: bool) -> usize { + if len == 0 { + return 0; + } + if forward { + (current + 1) % len + } else if current == 0 { + len.saturating_sub(1) + } else { + current - 1 + } +} diff --git a/crates/zesdex-backend/src/app/mode/rewind.rs b/crates/zesdex-backend/src/app/mode/rewind.rs index dcb57dc..0f2fb5d 100644 --- a/crates/zesdex-backend/src/app/mode/rewind.rs +++ b/crates/zesdex-backend/src/app/mode/rewind.rs @@ -27,10 +27,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { let conn = match open_session_db(&state.session_dir) { Ok(c) => c, Err(e) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("Failed to open session DB: {e}"), - )); + state.toast_error(format!("Failed to open session DB: {e}")); state.dirty = true; return; } @@ -39,20 +36,14 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) { Ok(k) => k, Err(e) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("Failed to list snapshots: {e}"), - )); + state.toast_error(format!("Failed to list snapshots: {e}")); state.dirty = true; return; } }; if keys.is_empty() || index >= keys.len() { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Warning, - "No snapshot available at that index".to_string(), - )); + state.toast_warning("No snapshot available at that index".to_string()); state.dirty = true; return; } @@ -62,18 +53,12 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { { Ok(Some(b)) => b, Ok(None) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - "Snapshot data not found".to_string(), - )); + state.toast_error("Snapshot data not found".to_string()); state.dirty = true; return; } Err(e) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("Failed to retrieve snapshot: {e}"), - )); + state.toast_error(format!("Failed to retrieve snapshot: {e}")); state.dirty = true; return; } @@ -87,16 +72,10 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { match std::fs::write(&restore_path, &bytes) { Ok(()) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Success, - format!("Restored {} from snapshot", restore_path.display()), - )); + state.toast_success(format!("Restored {} from snapshot", restore_path.display())); } Err(e) => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("Failed to write restored file: {e}"), - )); + state.toast_error(format!("Failed to write restored file: {e}")); } } diff --git a/crates/zesdex-backend/src/app/runtime/actions/spawn.rs b/crates/zesdex-backend/src/app/runtime/actions/spawn.rs index 1a67100..7aaabfa 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/spawn.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/spawn.rs @@ -57,12 +57,9 @@ pub(super) fn spawn_turn(state: &AppStateRest) { if messages.is_empty() { return; } - let mut api_key = state - .settings - .api_keys - .get(&state.settings.provider) - .cloned() - .unwrap_or_default(); + let mut api_key = crate::service::provider::resolve_api_key( + &state.settings, &state.app_config, + ); let model = state.settings.model.clone(); let base_url = state .app_config @@ -95,16 +92,6 @@ pub(super) fn spawn_turn(state: &AppStateRest) { } return; } - if api_key.is_empty() { - if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { - api_key = provider_cfg - .api_key_env - .as_ref() - .and_then(|env| std::env::var(env).ok()) - .or_else(|| provider_cfg.default_api_key.clone()) - .unwrap_or_default(); - } - } if api_key.is_empty() { api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); } diff --git a/crates/zesdex-backend/src/app/runtime/actions/tick.rs b/crates/zesdex-backend/src/app/runtime/actions/tick.rs index 9761a4a..83cbe84 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/tick.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/tick.rs @@ -271,6 +271,19 @@ pub(super) fn handle_tick(state: &mut AppStateRest) { } TurnEvent::StreamDone(msg) => { state.misc.thinking = false; + // Replace the partial streaming transcript with the complete + // message content. In the normal streaming path this is a + // no-op (the accumulated tokens already match), but when the + // non-streaming fallback fires the response is a completely + // new generation — the partial SSE text must be overwritten. + if let Some(content) = &msg.content { + if let Some(last) = state.transcript_cache.messages.last_mut() { + if last.role == Role::Assistant { + last.content.clone_from(content); + state.transcript_cache.dirty = true; + } + } + } if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } diff --git a/crates/zesdex-backend/src/app/runtime/actions/turn.rs b/crates/zesdex-backend/src/app/runtime/actions/turn.rs index a3216dd..2932169 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/turn.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/turn.rs @@ -3,18 +3,17 @@ //! messages, and manages auto-retry for unfinished tasks. //! //! Also contains the smaller helpers that the loop depends on: -//! `execute_one_tool`, `generate_workspace_tree`, `build_memory_section`, -//! and `archive_message`. +//! `execute_one_tool`, `build_memory_section`, and `archive_message`. use std::collections::VecDeque; use std::fmt::Write; -use sha2::Digest; -use zesdex_cms::domain::repository::EditLogRepository; use crate::app::guard::Verdict; use crate::app::runtime::context::tokens::count_tokens; +use crate::app::runtime::push_event; use crate::app::state::runtime::TurnEvent; +use zesdex_cms::domain::repository::EditLogRepository; use zesdex_cms::domain::repository::MemoryRepository; use crate::dto::chat::message::ChatMessage; @@ -66,17 +65,15 @@ pub(super) fn run_agent_turn( const MAX_TODO_RETRIES: usize = 5; let mut msgs = messages.to_vec(); let mut edited_paths: Vec = Vec::new(); - let initial_edits = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() - .open(&tc.edit_log_session_dir) - .map(|el| el.len()) - .unwrap_or(0); + let initial_edit_log = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() + .open(&tc.edit_log_session_dir).ok(); let mut inline_reviews_count: usize = 0; let mut prev_shaped = false; // Build system prompt components once and cache them for the entire turn // instead of regenerating on every loop iteration (which walks the full // workspace tree and reads all memory files each time). - let tree_info = generate_workspace_tree(&tc.workspace_roots); + let tree_info = crate::app::subagent::workspace::generate_workspace_tree(&tc.workspace_roots); let memory_section = build_memory_section(&tc.ctx.memory_dir); let system_text = format!( "{}\n\n{}\n\n{}{}", @@ -142,12 +139,10 @@ pub(super) fn run_agent_turn( "[hive-mind] the Hive stirs — Core Intelligence compiling a cognitive cycle plan" ); - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "pipeline".to_string(), - message: HIVE_MIND_KICKOFF_NOTE.to_string(), - }); - } + push_event(&events_q, TurnEvent::SystemNote { + kind: "pipeline".to_string(), + message: HIVE_MIND_KICKOFF_NOTE.to_string(), + }); let pipeline_abort = Some(tc.abort_flag.clone()); @@ -194,8 +189,13 @@ pub(super) fn run_agent_turn( let planner_prompt_chars = system_msg.content.as_deref().map_or(0, str::len) + user_msg.content.as_deref().map_or(0, str::len); - let planner_result = - tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None); + let planner_result = tc.client.chat_with_tools_non_streaming( + &[system_msg, user_msg], + None, + None, + None, + Some(&tc.abort_flag), + ); let pipeline_result = match planner_result { Ok((reply, usage_opt)) => { let (mut tok_in, mut tok_out) = usage_opt.unwrap_or((0, 0)); @@ -206,12 +206,10 @@ pub(super) fn run_agent_turn( let response_chars = reply.content.as_deref().map_or(0, str::len); tok_out = (response_chars / 4).max(1) as u64; } - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Usage { - tokens_in: tok_in, - tokens_out: tok_out, - }); - } + push_event(&events_q, TurnEvent::Usage { + tokens_in: tok_in, + tokens_out: tok_out, + }); let reply_text = reply.content.as_deref().unwrap_or("").trim(); let clean_json = if reply_text.starts_with("```") { let mut lines = reply_text.lines(); @@ -238,15 +236,13 @@ pub(super) fn run_agent_turn( .collect::>() .join(", "); - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "pipeline".to_string(), - message: format!( - "The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...", - plan.cycles.len() - ), - }); - } + push_event(&events_q, TurnEvent::SystemNote { + kind: "pipeline".to_string(), + message: format!( + "The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...", + plan.cycles.len() + ), + }); crate::app::workflow::hive_mind::run_hive_mind( user_request, @@ -283,20 +279,16 @@ pub(super) fn run_agent_turn( archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); msgs.push(pipeline_msg); - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "pipeline".to_string(), - message: - "The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..." - .to_string(), - }); - } - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "hive_mind_converged".to_string(), - message: String::new(), - }); - } + push_event(&events_q, TurnEvent::SystemNote { + kind: "pipeline".to_string(), + message: + "The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..." + .to_string(), + }); + push_event(&events_q, TurnEvent::SystemNote { + kind: "hive_mind_converged".to_string(), + message: String::new(), + }); } Err(e) => { tracing::warn!("[hive-mind] convergence fractured: {}", e); @@ -318,9 +310,7 @@ pub(super) fn run_agent_turn( .abort_flag .load(std::sync::atomic::Ordering::SeqCst) { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Error("Generation aborted by user".to_string())); - } + push_event(&events_q, TurnEvent::Error("Generation aborted by user".to_string())); return Ok(()); } @@ -357,9 +347,7 @@ pub(super) fn run_agent_turn( // Dispatch the compacted messages to the main thread so the local session history // is permanently compacted and doesn't trigger shaping again immediately on next turn. - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Compacted(compacted.clone())); - } + push_event(&events_q, TurnEvent::Compacted(compacted.clone())); // Also update our local `msgs` variable so the rest of the loop operates on the compacted version msgs.clone_from(&compacted); @@ -424,14 +412,13 @@ pub(super) fn run_agent_turn( } true }, + Some(&tc.abort_flag), ); if reasoning_started && !reasoning_ended { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::StreamToken( - "\n\n\n".to_string(), - )); - } + push_event(&events_q, TurnEvent::StreamToken( + "\n\n\n".to_string(), + )); } let (response, final_usage) = match result { @@ -441,11 +428,9 @@ pub(super) fn run_agent_turn( if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Error( - "Generation aborted by user".to_string(), - )); - } + push_event(&events_q, TurnEvent::Error( + "Generation aborted by user".to_string(), + )); return Ok(()); } // Streaming-only: no non-streaming fallback. @@ -472,14 +457,12 @@ pub(super) fn run_agent_turn( Edit todo.md manually or ask me to focus on specific items.", ); } - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "task_retry".to_string(), - message: format!( - "Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})" - ), - }); - } + push_event(&events_q, TurnEvent::SystemNote { + kind: "task_retry".to_string(), + 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)); continue; } @@ -500,12 +483,10 @@ pub(super) fn run_agent_turn( let response_chars = response.content.as_deref().map_or(0, str::len); tok_out = (response_chars / 4).max(1) as u64; } - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Usage { - tokens_in: tok_in, - tokens_out: tok_out, - }); - } + push_event(&events_q, TurnEvent::Usage { + tokens_in: tok_in, + tokens_out: tok_out, + }); let has_tool_calls = response.tool_calls.is_some() && response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); @@ -575,11 +556,9 @@ pub(super) fn run_agent_turn( .abort_flag .load(std::sync::atomic::Ordering::SeqCst) { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Error( - "Turn aborted by user".to_string(), - )); - } + push_event(&events_q, TurnEvent::Error( + "Turn aborted by user".to_string(), + )); return Ok(()); } @@ -648,17 +627,13 @@ pub(super) fn run_agent_turn( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string); - { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::ToolResult { - tool_call_id: tool_call.id.clone(), - tool_name: tool_name.clone(), - output: output.clone(), - is_error, - path: tool_path, - }); - } - } + push_event(&events_q, TurnEvent::ToolResult { + tool_call_id: tool_call.id.clone(), + tool_name: tool_name.clone(), + output: output.clone(), + is_error, + path: tool_path, + }); let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); @@ -668,12 +643,10 @@ pub(super) fn run_agent_turn( } else { if !content.is_empty() { archive_message(tc.db.as_ref(), &tc.session_id, &response); - if let Ok(mut q) = events_q.lock() { - if stream_started { - q.push_back(TurnEvent::StreamDone(response.clone())); - } else { - q.push_back(TurnEvent::AssistantMessage(response.clone())); - } + if stream_started { + push_event(&events_q, TurnEvent::StreamDone(response.clone())); + } else { + push_event(&events_q, TurnEvent::AssistantMessage(response.clone())); } } @@ -691,12 +664,10 @@ pub(super) fn run_agent_turn( if has_unfinished { todo_retry_count += 1; if todo_retry_count > MAX_TODO_RETRIES { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "task_retry".to_string(), - message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."), - }); - } + push_event(&events_q, TurnEvent::SystemNote { + kind: "task_retry".to_string(), + message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."), + }); 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})"); @@ -704,12 +675,10 @@ pub(super) fn run_agent_turn( let msg = ChatMessage::system(sys_text); archive_message(tc.db.as_ref(), &tc.session_id, &msg); msgs.push(msg); - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { - kind: "task_retry".to_string(), - message: sys_text_clone, - }); - } + push_event(&events_q, TurnEvent::SystemNote { + kind: "task_retry".to_string(), + message: sys_text_clone, + }); continue; } @@ -717,49 +686,52 @@ pub(super) fn run_agent_turn( } } - let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() - .open(&tc.edit_log_session_dir) - .unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new()); - let final_edits = el.len(); - let total_edits_this_turn = final_edits.saturating_sub(initial_edits); + let total_edits_this_turn = initial_edit_log.as_ref().and_then(|initial_el| { + let initial_count = initial_el.len(); + zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() + .open(&tc.edit_log_session_dir) + .ok() + .map(|final_el| { + let count = final_el.len().saturating_sub(initial_count); + (count, initial_count, final_el) + }) + }); - if total_edits_this_turn > 0 { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::SystemNote { + if let Some((total_edits_this_turn, prev_edits, el)) = &total_edits_this_turn { + if *total_edits_this_turn > 0 { + push_event(&events_q, TurnEvent::SystemNote { kind: "edits".to_string(), message: total_edits_this_turn.to_string(), }); - } - // Collect edited paths from the new edit log entries - let mut bg_paths = Vec::new(); - for entry in el.entries.iter().skip(initial_edits) { - bg_paths.push(entry.path.clone()); - } - bg_paths.sort(); - bg_paths.dedup(); + // Collect edited paths from the new edit log entries + let mut bg_paths = Vec::new(); + for entry in el.entries.iter().skip(*prev_edits) { + bg_paths.push(entry.path.clone()); + } + bg_paths.sort(); + bg_paths.dedup(); - // ── Background auto-subagents ── - if !bg_paths.is_empty() { - let bg_session_dir = tc.edit_log_session_dir.clone(); - let bg_workspaces = tc.workspace_roots.clone(); - let bg_events = events_q.clone(); - let bg_abort = tc.abort_flag.clone(); - std::thread::spawn(move || { - crate::app::subagent::auto::spawn_all_background( - &bg_paths, - &bg_session_dir, - &bg_workspaces, - &bg_events, - bg_abort, - ); - }); + // ── Background auto-subagents ── + if !bg_paths.is_empty() { + let bg_session_dir = tc.edit_log_session_dir.clone(); + let bg_workspaces = tc.workspace_roots.clone(); + let bg_events = events_q.clone(); + let bg_abort = tc.abort_flag.clone(); + std::thread::spawn(move || { + crate::app::subagent::auto::spawn_all_background( + &bg_paths, + &bg_session_dir, + &bg_workspaces, + &bg_events, + bg_abort, + ); + }); + } } } - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Done); - } + push_event(&events_q, TurnEvent::Done); Ok(()) } @@ -820,55 +792,9 @@ fn execute_one_tool( } let result = tool.run(ctx, args)?; if name == "write" || name == "edit" { - let reason = args - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("unnamed"); - let path = args - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let content_sha256 = { - let content = - args.get("content").or_else(|| args.get("new")); - let hash = sha2::Sha256::digest( - content - .and_then(|v| v.as_str()) - .unwrap_or("") - .as_bytes(), - ); - hex::encode(hash) - }; - let bytes_delta = if name == "write" { - args.get("content") - .and_then(|v| v.as_str()) - .map_or(0, |s| s.len() as i64) - } else { - 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(""); - (new.len() as i64 - old.len() as i64).abs() - }; - let entry = zesdex_cms::domain::edit_log::EditLogEntry { - ts: chrono::Utc::now().timestamp_millis(), - tool: name.to_string(), - path: path.to_string(), - reason: reason.to_string(), - content_sha256, - bytes_delta, - origin: ctx.origin.tag(), - session_id: sess.id.to_string(), - }; - let repo = - zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); - if let Ok(mut el) = repo.open(sess.dir) { - let _ = repo.append(sess.dir, &mut el, entry); - } + crate::tool::log_write_edit_tool( + args, name, &ctx.origin.tag(), sess.dir, sess.id, + ); } return Ok(result); } @@ -876,44 +802,6 @@ fn execute_one_tool( anyhow::bail!("tool not found: {name}") } -/// Build an ASCII tree of the workspace directory structure for the -/// system prompt, so the LLM can see the file layout. -/// -/// Flow: for each root, walk using `ignore::WalkBuilder` (respecting -/// `.gitignore` and hidden files) → prefix `[DIR]` for directories → -/// truncate after 1000 entries. -/// -/// Return: a formatted string with one entry per line. -fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String { - let mut out = String::new(); - out.push_str("Current Workspace Directory Structure:\n"); - for root in roots { - writeln!(out, "Root: {}", root.display()).unwrap(); - let walker = ignore::WalkBuilder::new(root) - .hidden(true) - .git_ignore(true) - .build(); - let mut count = 0; - for entry in walker.flatten() { - let path = entry.path(); - if let Ok(rel) = path.strip_prefix(root) { - if rel.as_os_str().is_empty() { - continue; - } - let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); - let prefix = if is_dir { "[DIR] " } else { " " }; - writeln!(out, " {}{}", prefix, rel.display()).unwrap(); - count += 1; - if count > 1000 { - out.push_str(" ... (truncated)\n"); - break; - } - } - } - } - out -} - /// Load all memory entries from `memory_dir` and format them as a compact /// section appended to the system prompt, so the AI is always aware of /// stored lessons and project knowledge. diff --git a/crates/zesdex-backend/src/app/runtime/context/shaping.rs b/crates/zesdex-backend/src/app/runtime/context/shaping.rs index 61db014..a5f8d83 100644 --- a/crates/zesdex-backend/src/app/runtime/context/shaping.rs +++ b/crates/zesdex-backend/src/app/runtime/context/shaping.rs @@ -315,7 +315,7 @@ pub fn shape_messages( let mut result: Option = None; let mut last_err: Option = None; for attempt in 0..2 { - match llm.chat_with_tools_non_streaming(&req_msgs, None) { + match llm.chat_with_tools_non_streaming(&req_msgs, None, None, None, abort_flag) { Ok(resp) => { if let Some(content) = resp.0.content { result = Some(format!( diff --git a/crates/zesdex-backend/src/app/runtime/mod.rs b/crates/zesdex-backend/src/app/runtime/mod.rs index 12a851f..977b402 100644 --- a/crates/zesdex-backend/src/app/runtime/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/mod.rs @@ -1,6 +1,26 @@ //! Runtime layer: action dispatch, slash commands, short-send handling, //! and the LLM streaming pipeline. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use super::state::runtime::TurnEvent; + pub mod actions; pub mod action_dispatch; pub mod context; pub mod stream; + +/// Acquire the mutex on a turn-events queue and push one event onto it. +/// +/// Silently ignores a poisoned mutex so callers never have to handle lock +/// errors inline. Used by the 20+ locations in `actions/turn.rs` that push +/// events and want to skip the boilerplate. +pub fn push_event( + q: &Arc>>, + event: TurnEvent, +) { + if let Ok(mut guard) = q.lock() { + guard.push_back(event); + } +} diff --git a/crates/zesdex-backend/src/app/state/rest.rs b/crates/zesdex-backend/src/app/state/rest.rs index 7275326..f127fed 100644 --- a/crates/zesdex-backend/src/app/state/rest.rs +++ b/crates/zesdex-backend/src/app/state/rest.rs @@ -347,10 +347,35 @@ impl AppStateRest { self.dirty = true; } + /// Mark the app state as dirty, triggering a TUI re-render on the next frame. + pub fn mark_dirty(&mut self) { + self.dirty = true; + } + /// Queue a toast notification for display and mark the app dirty. pub fn push_toast(&mut self, toast: Toast) { self.misc.push_toast(toast); - self.dirty = true; + self.mark_dirty(); + } + + /// Push an info toast with the given message. + pub fn toast_info(&mut self, msg: impl Into) { + self.push_toast(Toast::new(super::types::ToastKind::Info, msg.into())); + } + + /// Push a success toast with the given message. + pub fn toast_success(&mut self, msg: impl Into) { + self.push_toast(Toast::new(super::types::ToastKind::Success, msg.into())); + } + + /// Push a warning toast with the given message. + pub fn toast_warning(&mut self, msg: impl Into) { + self.push_toast(Toast::new(super::types::ToastKind::Warning, msg.into())); + } + + /// Push an error toast with the given message. + pub fn toast_error(&mut self, msg: impl Into) { + self.push_toast(Toast::new(super::types::ToastKind::Error, msg.into())); } /// Resolve the base directory that stores this session (grandparent of @@ -385,6 +410,17 @@ impl AppStateRest { ) } + /// Persist the current settings to the store and swallow any error. + /// + /// Inline usage of `JsonSettingsRepository::new().save(...)` was + /// duplicated twice in `controller/input.rs` — this helper centralises + /// the call site. + pub fn save_settings(&self) { + use zesdex_cms::domain::repository::SettingsRepository; + let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() + .save(&self.store_base_dir(), &self.settings); + } + /// Build a `ToolCtx` for tool calls originating from the main agent. pub fn tool_ctx(&self) -> crate::tool::ToolCtx { self.tool_ctx_for(Origin::Main) diff --git a/crates/zesdex-backend/src/app/subagent/auto/mod.rs b/crates/zesdex-backend/src/app/subagent/auto/mod.rs index d572546..0701fd5 100644 --- a/crates/zesdex-backend/src/app/subagent/auto/mod.rs +++ b/crates/zesdex-backend/src/app/subagent/auto/mod.rs @@ -248,16 +248,24 @@ fn spawn_background_review( }); } +/// Collect the trailing arguments shared by all background-review spawners. +fn review_args<'a>( + file_paths: &'a [String], + session_dir: &'a Path, + workspaces: &'a [std::path::PathBuf], + turn_events: &'a Arc>>, + abort_flag: Arc, +) -> (Vec, std::path::PathBuf, Vec, Arc>>, Arc) { + ( + file_paths.to_vec(), + session_dir.to_path_buf(), + workspaces.to_vec(), + turn_events.clone(), + abort_flag, + ) +} + /// Spawn a background subagent that generates tests for modified files. -/// -/// Uses the test-generator prompt and has read-write access so it can -/// create test files. Runs in a separate OS thread and reports completion -/// via `TurnEvent::SystemNote { kind: "bg-test-gen" }`. -/// -/// Skipped (no-op) if a test-gen run is already in flight (guarded by -/// `TEST_GEN_RUNNING`) — prevents a chatty multi-turn edit session from -/// stacking overlapping runs. `abort_flag` is forwarded to the generic -/// spawner so the run can be cancelled if the turn aborts. pub fn spawn_background_test_gen( file_paths: &[String], session_dir: &Path, @@ -265,29 +273,15 @@ pub fn spawn_background_test_gen( turn_events: &Arc>>, abort_flag: Arc, ) { + let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag); spawn_background_review( - "bg-test-gen", - &TEST_GEN_RUNNING, - crate::prompts::TEST_GENERATOR_PROMPT, - "test-generator", - "coder", - file_paths.to_vec(), - session_dir.to_path_buf(), - workspaces.to_vec(), - turn_events.clone(), - abort_flag, + "bg-test-gen", &TEST_GEN_RUNNING, + crate::prompts::TEST_GENERATOR_PROMPT, "test-generator", "coder", + fps, sd, ws, te, af, ); } /// Spawn a background architecture-review subagent. -/// -/// Inspects the modified files for architectural consistency (layering, -/// coupling, module boundaries). Reports via -/// `TurnEvent::SystemNote { kind: "bg-arch-review" }`. -/// -/// Skipped (no-op) if an arch-review run is already in flight (guarded by -/// `ARCH_REVIEW_RUNNING`). `abort_flag` is forwarded to the generic -/// spawner so the run can be cancelled if the turn aborts. pub fn spawn_background_arch_review( file_paths: &[String], session_dir: &Path, @@ -295,31 +289,18 @@ pub fn spawn_background_arch_review( turn_events: &Arc>>, abort_flag: Arc, ) { + let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag); spawn_background_review( - "bg-arch-review", - &ARCH_REVIEW_RUNNING, - crate::prompts::ARCH_REVIEWER_PROMPT, - "arch-reviewer", - "reviewer", - file_paths.to_vec(), - session_dir.to_path_buf(), - workspaces.to_vec(), - turn_events.clone(), - abort_flag, + "bg-arch-review", &ARCH_REVIEW_RUNNING, + crate::prompts::ARCH_REVIEWER_PROMPT, "arch-reviewer", "reviewer", + fps, sd, ws, te, af, ); } /// Spawn a background security-review subagent. /// -/// Checks modified files for security vulnerabilities. Reports via -/// `TurnEvent::SystemNote { kind: "bg-security-review" }`. -/// /// Only reviews production code files for security — test files and /// config files are out of scope for security review. -/// -/// Skipped (no-op) if a security-review run is already in flight (guarded by -/// `SECURITY_REVIEW_RUNNING`). `abort_flag` is forwarded to the generic -/// spawner so the run can be cancelled if the turn aborts. pub fn spawn_background_security_review( file_paths: &[String], session_dir: &Path, @@ -327,25 +308,16 @@ pub fn spawn_background_security_review( turn_events: &Arc>>, abort_flag: Arc, ) { - // Only review production code files for security — test files and - // config files are out of scope for security review. + let (_, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag); let prod_paths: Vec = file_paths .iter() .filter(|p| is_production_code(p)) .cloned() .collect(); - spawn_background_review( - "bg-security-review", - &SECURITY_REVIEW_RUNNING, - crate::prompts::SECURITY_REVIEWER_PROMPT, - "security-reviewer", - "reviewer", - prod_paths, - session_dir.to_path_buf(), - workspaces.to_vec(), - turn_events.clone(), - abort_flag, + "bg-security-review", &SECURITY_REVIEW_RUNNING, + crate::prompts::SECURITY_REVIEWER_PROMPT, "security-reviewer", "reviewer", + prod_paths, sd, ws, te, af, ); } diff --git a/crates/zesdex-backend/src/app/subagent/engine.rs b/crates/zesdex-backend/src/app/subagent/engine.rs index 60e2ac7..4a048d1 100644 --- a/crates/zesdex-backend/src/app/subagent/engine.rs +++ b/crates/zesdex-backend/src/app/subagent/engine.rs @@ -14,10 +14,8 @@ use super::workspace::generate_workspace_tree; use crate::dto::chat::message::ChatMessage; use crate::dto::provider::request::ToolDef; use crate::tool::tool_is_risky; -use sha2::Digest; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; -use zesdex_cms::domain::repository::EditLogRepository; /// Tiny jitter helper so retry backoffs don't arrive in lockstep. fn retry_jitter_ns(range_ns: u64) -> u64 { @@ -208,6 +206,7 @@ pub fn run_subagent( } true }, + ctx.abort_flag.as_deref(), ); match stream_result { @@ -346,49 +345,14 @@ pub fn run_subagent( let run_res = tool.run(tool_ctx_ref, &args); if is_edit && run_res.is_ok() { - let reason = args - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("unnamed"); - let path = args - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let content_sha256 = { - let content = args.get("content").or_else(|| args.get("new")); - let hash = sha2::Sha256::digest( - content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(), - ); - hex::encode(hash) - }; - let bytes_delta = if tool_name == "write" { - args.get("content") - .and_then(|v| v.as_str()) - .map_or(0, |s| s.len() as i64) - } else { - 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(""); - (new.len() as i64 - old.len() as i64).abs() - }; let session_id = ctx.session_dir .file_name() .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - let entry = zesdex_cms::domain::edit_log::EditLogEntry { - ts: chrono::Utc::now().timestamp_millis(), - tool: tool_name.clone(), - path: path.to_string(), - reason: reason.to_string(), - content_sha256, - bytes_delta, - origin: tool_ctx_ref.origin.tag(), - session_id, - }; - let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); - if let Ok(mut el) = repo.open(&ctx.session_dir) { - let _ = repo.append(&ctx.session_dir, &mut el, entry); - } + .unwrap_or("unknown"); + crate::tool::log_write_edit_tool( + &args, tool_name, &tool_ctx_ref.origin.tag(), + &ctx.session_dir, session_id, + ); } run_res } diff --git a/crates/zesdex-backend/src/app/subagent/provider.rs b/crates/zesdex-backend/src/app/subagent/provider.rs index 99d8c19..aa7689e 100644 --- a/crates/zesdex-backend/src/app/subagent/provider.rs +++ b/crates/zesdex-backend/src/app/subagent/provider.rs @@ -27,40 +27,19 @@ pub(crate) fn resolve_provider_config() -> (String, String, Option, Stri .load(&store_base_dir) .unwrap_or_default(); - let mut api_key = settings - .api_keys - .get(&settings.provider) - .cloned() - .unwrap_or_else(|| { - tracing::warn!( - "[subagent] no API key for provider '{}' in settings, trying env/default", - settings.provider - ); - String::new() - }); + let api_key = crate::service::provider::resolve_api_key(&settings, &app_config); + if api_key.is_empty() { + tracing::warn!( + "[subagent] all API key resolution paths exhausted for '{}'", + settings.provider + ); + } let model = settings.model.clone(); let base_url = app_config .providers .get(&settings.provider) .map(|p| p.api_base.clone()); - if api_key.is_empty() { - if let Some(provider_cfg) = app_config.providers.get(&settings.provider) { - api_key = provider_cfg - .api_key_env - .as_ref() - .and_then(|env| std::env::var(env).ok()) - .or_else(|| provider_cfg.default_api_key.clone()) - .unwrap_or_else(|| { - tracing::warn!( - "[subagent] all API key resolution paths exhausted for '{}'", - settings.provider - ); - String::new() - }); - } - } - (api_key, model, base_url, settings.provider) } diff --git a/crates/zesdex-backend/src/controller/input.rs b/crates/zesdex-backend/src/controller/input.rs index 042e07a..65602ab 100644 --- a/crates/zesdex-backend/src/controller/input.rs +++ b/crates/zesdex-backend/src/controller/input.rs @@ -10,7 +10,6 @@ use crate::app::state::input::AutocompleteKind; use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; use crate::controller::command::parse_command; -use zesdex_cms::domain::repository::SettingsRepository; /// Translate a terminal `KeyEvent` into zero or more `Action` values /// based on the current application state. @@ -33,15 +32,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { if let Some(ref ed) = state.misc.editor.clone() { let content = ed.as_string(); if let Err(e) = std::fs::write(&ed.path, &content) { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Error, - format!("Save failed: {e}"), - )); + state.toast_error(format!("Save failed: {e}")); } else { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Success, - format!("Saved {}", ed.path), - )); + state.toast_success(format!("Saved {}", ed.path)); } state.dirty = true; } @@ -81,22 +74,14 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { KeyCode::Up => { let items = crate::app::mode::learning::get_learning_items(state); let n = items.len(); - state.misc.selected_index = if state.misc.selected_index == 0 { - n.saturating_sub(1) - } else { - state.misc.selected_index - 1 - }; + state.misc.selected_index = crate::app::mode::cycle_selected_index(state.misc.selected_index, n, false); state.dirty = true; return vec![]; } KeyCode::Down => { let items = crate::app::mode::learning::get_learning_items(state); let n = items.len(); - state.misc.selected_index = if n == 0 { - 0 - } else { - (state.misc.selected_index + 1) % n - }; + state.misc.selected_index = crate::app::mode::cycle_selected_index(state.misc.selected_index, n, true); state.dirty = true; return vec![]; } @@ -155,10 +140,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { state.misc.pending_clipboard_copy = Some(msg.content.clone()); } None => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Info, - "No assistant message to copy yet".to_string(), - )); + state.toast_info("No assistant message to copy yet".to_string()); } } Vec::new() @@ -210,20 +192,12 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { Vec::new() } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); - state.misc.selected_index = if state.misc.selected_index == 0 { - n.saturating_sub(1) - } else { - state.misc.selected_index - 1 - }; + state.misc.selected_index = mode::cycle_selected_index(state.misc.selected_index, n, false); state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::ModelSelector { let n = state.app_config.providers.len(); - state.misc.selected_index = if state.misc.selected_index == 0 { - n.saturating_sub(1) - } else { - state.misc.selected_index - 1 - }; + state.misc.selected_index = mode::cycle_selected_index(state.misc.selected_index, n, false); state.dirty = true; Vec::new() } else if key.modifiers.contains(KeyModifiers::CONTROL) { @@ -242,20 +216,12 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { Vec::new() } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); - state.misc.selected_index = if n == 0 { - 0 - } else { - (state.misc.selected_index + 1) % n - }; + state.misc.selected_index = mode::cycle_selected_index(state.misc.selected_index, n, true); state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::ModelSelector { let n = state.app_config.providers.len(); - state.misc.selected_index = if n == 0 { - 0 - } else { - (state.misc.selected_index + 1) % n - }; + state.misc.selected_index = mode::cycle_selected_index(state.misc.selected_index, n, true); state.dirty = true; Vec::new() } else if key.modifiers.contains(KeyModifiers::CONTROL) { @@ -359,15 +325,11 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { .api_keys .insert(state.settings.provider.clone(), text.clone()); } - let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() - .save(&state.store_base_dir(), &state.settings); + state.save_settings(); state.input.buffer.clear(); state.input.cursor = 0; state.misc.overlay = Overlay::None; - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Success, - "API key saved".to_string(), - )); + state.toast_success("API key saved".to_string()); state.dirty = true; Vec::new() } @@ -406,12 +368,8 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { { state.settings.api_keys.insert(provider.clone(), env_key); } - let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() - .save(&state.store_base_dir(), &state.settings); - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Success, - format!("Switched to {provider} / {model}"), - )); + state.save_settings(); + state.toast_success(format!("Switched to {provider} / {model}")); } } state.misc.overlay = Overlay::None; @@ -419,10 +377,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { Vec::new() } Overlay::ClearConfirm => { - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Info, - "Transcript cleared".to_string(), - )); + state.toast_info("Transcript cleared".to_string()); state.misc.overlay = Overlay::None; state.dirty = true; Vec::new() diff --git a/crates/zesdex-backend/src/service/provider.rs b/crates/zesdex-backend/src/service/provider.rs index 1f502be..05f964e 100644 --- a/crates/zesdex-backend/src/service/provider.rs +++ b/crates/zesdex-backend/src/service/provider.rs @@ -25,6 +25,7 @@ //! caller-level fallback handles that case. use anyhow::Result; +use std::sync::atomic::AtomicBool; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::app::runtime::stream::turn::StreamedTurn; @@ -43,11 +44,15 @@ const REQUEST_TIMEOUT: Duration = Duration::from_mins(1); // --------------------------------------------------------------------------- /// Return a pseudo-random jitter offset in the range [0, range_ns). +/// +/// Uses the full epoch nanoseconds (wrapped to u64) instead of the +/// sub-second component so the jitter range scales with `range_ns` +/// rather than being capped at ~1 s. fn jitter_ns(range_ns: u64) -> u64 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() - .subsec_nanos() as u64; + .as_nanos() as u64; nanos % range_ns } @@ -56,10 +61,11 @@ fn jitter_ns(range_ns: u64) -> u64 { /// `attempt` is 1-based (first retry → attempt=1). fn backoff_duration(attempt: u32) -> Duration { let base_secs = (2u64).pow(attempt).min(30); - let quarter = (base_secs * 250_000_000).max(100_000_000); // ±25%, min 100ms - let offset = jitter_ns(quarter); - // ±25% jitter: sometimes slightly less, sometimes slightly more - let ns = base_secs * 1_000_000_000 + offset - quarter / 2; + let half_range = (base_secs * 250_000_000).max(100_000_000); // 25% of base, min 100ms + let offset = jitter_ns(half_range * 2); // [0, 50% of base) + // ±25% jitter: subtract half_range so the result varies + // between base-25% and base+25%. + let ns = base_secs * 1_000_000_000 + offset - half_range; Duration::from_nanos(ns) } @@ -91,9 +97,9 @@ fn backoff_for_error(attempt: u32, err_str: &str) -> Duration { if is_rate_limit(err_str) { // Rate limits need more time to drain — start at 5s instead of 2s. let base_secs = (5u64 * (2u64).pow(attempt.saturating_sub(1))).min(60); - let quarter = (base_secs * 250_000_000).max(100_000_000); - let offset = jitter_ns(quarter); - let ns = base_secs * 1_000_000_000 + offset - quarter / 2; + let half_range = (base_secs * 250_000_000).max(100_000_000); + let offset = jitter_ns(half_range * 2); + let ns = base_secs * 1_000_000_000 + offset - half_range; Duration::from_nanos(ns) } else { backoff_duration(attempt) @@ -188,12 +194,15 @@ impl LlmClient { &self, messages: &[ChatMessage], tools: Option>, + max_tokens: Option, + temperature: Option, + abort_flag: Option<&AtomicBool>, ) -> Result<(ChatMessage, Option<(u64, u64)>)> { let req = ChatRequest { model: self.model.clone(), messages: messages.to_vec(), - max_tokens: Some(4096), - temperature: Some(0.7), + max_tokens: Some(max_tokens.unwrap_or(4096)), + temperature: Some(temperature.unwrap_or(0.7)), tools, stream: Some(false), stop: None, @@ -209,6 +218,12 @@ impl LlmClient { loop { attempt += 1; + // Check abort before each retry so user cancellation is + // responsive even during a long non-streaming backoff chain. + if abort_flag.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { + anyhow::bail!("aborted"); + } + let mut http_req = self .client .post(&url) @@ -298,7 +313,11 @@ impl LlmClient { temperature: Option, max_tokens: Option, mut on_event: impl FnMut(&StreamEvent) -> bool, + abort_flag: Option<&AtomicBool>, ) -> Result<(ChatMessage, Option<(u64, u64)>)> { + // Clone tools for the non-streaming fallback path — the original + // is moved into the ChatRequest below and cannot be used again. + let tools_for_fallback = tools.clone(); let req = ChatRequest { model: self.model.clone(), messages: messages.to_vec(), @@ -374,19 +393,23 @@ impl LlmClient { // This preserves the conversation state because the messages // passed in are the same — we don't need the partial SSE output. if meaningful_content { + // Check abort before entering the blocking non-streaming + // call — otherwise the fallback ignores user cancellation. + if abort_flag.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { + return Err(anyhow::anyhow!("aborted")); + } tracing::warn!( - "streaming failed after meaningful content — falling back to non-streaming retry", + "streaming failed after meaningful content — falling back to non-streaming call", ); - // Use the same messages; pass None for tools (streaming already - // included them) and let the non-streaming path handle retries. - // The on_event callback is irrelevant for non-streaming, but we - // signal a special synthetic Done event so callers aren't left - // hanging waiting for stream completion. - // Rebuild ChatRequest without streaming options. - return self.chat_with_tools_non_streaming_retry( + // Use the same messages and tools so the fallback produces + // a response compatible with what the streaming request + // would have returned (including tool definitions). + return self.chat_with_tools_non_streaming( messages, - max_tokens.unwrap_or(4096), - temperature.unwrap_or(0.7), + tools_for_fallback, + max_tokens, + temperature, + abort_flag, ); } @@ -395,93 +418,6 @@ impl LlmClient { )) } - /// Non-streaming fallback used by the streaming method after a partial - /// stream failure. Same retry policy as `chat_with_tools_non_streaming`. - fn chat_with_tools_non_streaming_retry( - &self, - messages: &[ChatMessage], - max_tokens: u32, - temperature: f32, - ) -> Result<(ChatMessage, Option<(u64, u64)>)> { - let req = ChatRequest { - model: self.model.clone(), - messages: messages.to_vec(), - max_tokens: Some(max_tokens), - temperature: Some(temperature), - tools: None, - stream: Some(false), - stop: None, - stream_options: None, - tool_choice: None, - top_p: None, - }; - - let url = format!("{}/chat/completions", self.base_url); - let max_retries = 10; - let mut attempt = 0u32; - - loop { - attempt += 1; - - let mut http_req = self - .client - .post(&url) - .header("Content-Type", "application/json"); - - if !self.api_key.is_empty() { - http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key)); - } - - let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> { - let resp = http_req.json(&req).send().map_err(|e| { - if e.is_timeout() { - anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.") - } else if e.is_connect() { - anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url) - } else { - anyhow::anyhow!("API request failed: {e}") - } - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().unwrap_or_default(); - anyhow::bail!("API error {} from {}: {}", status, self.base_url, body); - } - - let data: crate::dto::provider::response::ChatResponse = resp.json()?; - let usage = data - .usage - .map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens))); - let message = data - .choices - .into_iter() - .next() - .and_then(|c| c.message) - .ok_or_else(|| anyhow::anyhow!("API response had no choices"))?; - Ok((message, usage)) - })(); - - match result { - Ok((msg, usage)) => return Ok((msg, usage)), - Err(e) => { - let err_str = e.to_string(); - if attempt >= max_retries || is_auth_error(&err_str) { - return Err(e); - } - let delay = backoff_for_error(attempt, &err_str); - tracing::warn!( - "Warning [non-streaming fallback]: {}. Retrying {}/{}, sleeping {delay:?}...", - e, - attempt, - max_retries, - ); - std::thread::sleep(delay); - } - } - } - } - /// Perform one streaming chat completion request, parsing SSE events until completion. /// /// Flow: POST → read body in chunks → advance past valid UTF-8 boundary → @@ -590,3 +526,34 @@ impl LlmClient { Ok((turn.build_assistant_message(), usage)) } } + +/// Resolve the API key for the currently configured provider, falling back +/// through settings → env var → provider default. +/// +/// Used by both the main agent turn loop (`spawn.rs`) and subagent provider +/// resolution (`subagent/provider.rs`) to share the identical fallback chain. +/// +/// Flow: try `settings.api_keys[provider]` → try `api_key_env` env var → +/// try `default_api_key` from config → return empty string if all paths +/// exhausted (callers must check and reject the empty case). +pub fn resolve_api_key( + settings: &zesdex_cms::domain::settings::Settings, + app_config: &zesdex_cms::domain::app_config::AppConfig, +) -> String { + let mut api_key = settings + .api_keys + .get(&settings.provider) + .cloned() + .unwrap_or_default(); + if api_key.is_empty() { + if let Some(provider_cfg) = app_config.providers.get(&settings.provider) { + api_key = provider_cfg + .api_key_env + .as_ref() + .and_then(|env| std::env::var(env).ok()) + .or_else(|| provider_cfg.default_api_key.clone()) + .unwrap_or_default(); + } + } + api_key +} diff --git a/crates/zesdex-backend/src/tool/lsp/completion.rs b/crates/zesdex-backend/src/tool/lsp/completion.rs index 7e41cb9..4310db5 100644 --- a/crates/zesdex-backend/src/tool/lsp/completion.rs +++ b/crates/zesdex-backend/src/tool/lsp/completion.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::{json, Value}; +use serde_json::Value; use std::fmt::Write; use crate::tool::{Tool, ToolCtx}; @@ -7,47 +7,19 @@ use crate::tool::{Tool, ToolCtx}; pub struct LspCompletion; impl Tool for LspCompletion { - fn name(&self) -> &'static str { - "lsp_completion" - } + fn name(&self) -> &'static str { "lsp_completion" } fn description(&self) -> &'static str { "Get code completion suggestions at a cursor position from an LSP server. \ `server` is optional — if omitted, the server is auto-detected from the file's extension." } - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "server": { - "type": "string", - "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted." - }, - "path": { - "type": "string", - "description": "Path to the file (relative to workspace root)" - }, - "line": { - "type": "integer", - "description": "Line number (0-based)" - }, - "column": { - "type": "integer", - "description": "Column number (0-based)" - } - }, - "required": ["path", "line", "column"] - }) - } + fn parameters(&self) -> Value { super::lsp_cursor_params(false) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let result = super::run_lsp_query(ctx, args, |client, uri, line, column| { + let (completion_result, line, column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.completion(uri, line, column) - }); - - match result { - Ok((completion_result, line, column)) => { + })?; let items = if let Some(items) = completion_result.as_array() { items.clone() } else if let Some(arr) = @@ -114,8 +86,5 @@ impl Tool for LspCompletion { writeln!(output, " ... and {} more", items.len() - 50).unwrap(); } Ok(output) - } - Err(e) => Err(e), - } } } diff --git a/crates/zesdex-backend/src/tool/lsp/definition.rs b/crates/zesdex-backend/src/tool/lsp/definition.rs index 7a28368..501dd5f 100644 --- a/crates/zesdex-backend/src/tool/lsp/definition.rs +++ b/crates/zesdex-backend/src/tool/lsp/definition.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::{json, Value}; +use serde_json::Value; use std::fmt::Write; use crate::tool::{Tool, ToolCtx}; @@ -7,82 +7,52 @@ use crate::tool::{Tool, ToolCtx}; pub struct LspDefinition; impl Tool for LspDefinition { - fn name(&self) -> &'static str { - "lsp_definition" - } + fn name(&self) -> &'static str { "lsp_definition" } fn description(&self) -> &'static str { "Go to definition: find the location where a symbol is defined. \ `server` is optional — if omitted, the server is auto-detected from the file's extension." } - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "server": { - "type": "string", - "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted." - }, - "path": { - "type": "string", - "description": "Path to the file (relative to workspace root)" - }, - "line": { - "type": "integer", - "description": "Line number (0-based)" - }, - "column": { - "type": "integer", - "description": "Column number (0-based)" - } - }, - "required": ["path", "line", "column"] - }) - } + fn parameters(&self) -> Value { super::lsp_cursor_params(false) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let result = super::run_lsp_query(ctx, args, |client, uri, line, column| { + let (def_result, _line, _column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.goto_definition(uri, line, column) - }); + })?; - match result { - Ok((def_result, _line, _column)) => { - if def_result == Value::Null { - return Ok("No definition found at this position.".to_string()); - } - let locations = if let Some(loc) = def_result.as_array() { - loc.clone() - } else { - vec![def_result.clone()] - }; - - if locations.is_empty() { - return Ok("No definition found.".to_string()); - } - - let mut output = String::from("Definition(s):\n"); - for (i, loc) in locations.iter().enumerate().take(10) { - 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_start = target_range.and_then(|r| r.get("start")); - 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(serde_json::Value::as_i64) - .unwrap_or(0); - let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); - writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap(); - } - if locations.len() > 10 { - writeln!(output, " ... and {} more", locations.len() - 10).unwrap(); - } - Ok(output) - } - Err(e) => Err(e), + if def_result == Value::Null { + return Ok("No definition found at this position.".to_string()); } + let locations = if let Some(loc) = def_result.as_array() { + loc.clone() + } else { + vec![def_result.clone()] + }; + + if locations.is_empty() { + return Ok("No definition found.".to_string()); + } + + let mut output = String::from("Definition(s):\n"); + for (i, loc) in locations.iter().enumerate().take(10) { + 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_start = target_range.and_then(|r| r.get("start")); + 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(serde_json::Value::as_i64) + .unwrap_or(0); + let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); + writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap(); + } + if locations.len() > 10 { + writeln!(output, " ... and {} more", locations.len() - 10).unwrap(); + } + Ok(output) } } diff --git a/crates/zesdex-backend/src/tool/lsp/hover.rs b/crates/zesdex-backend/src/tool/lsp/hover.rs index 780c315..071b0d2 100644 --- a/crates/zesdex-backend/src/tool/lsp/hover.rs +++ b/crates/zesdex-backend/src/tool/lsp/hover.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::{json, Value}; +use serde_json::Value; use std::fmt::Write; use crate::tool::{Tool, ToolCtx}; @@ -7,51 +7,19 @@ use crate::tool::{Tool, ToolCtx}; pub struct LspHover; impl Tool for LspHover { - fn name(&self) -> &'static str { - "lsp_hover" - } + fn name(&self) -> &'static str { "lsp_hover" } fn description(&self) -> &'static str { "Get hover information (type signature, documentation) at a cursor position in a file. \ `server` is optional — if omitted, the server is auto-detected from the file's extension." } - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "server": { - "type": "string", - "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted." - }, - "path": { - "type": "string", - "description": "Path to the file (relative to workspace root)" - }, - "line": { - "type": "integer", - "description": "Line number (0-based)" - }, - "column": { - "type": "integer", - "description": "Column number (0-based)" - }, - "language_id": { - "type": "string", - "description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect." - } - }, - "required": ["path", "line", "column"] - }) - } + fn parameters(&self) -> Value { super::lsp_cursor_params(true) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let result = super::run_lsp_query(ctx, args, |client, uri, line, column| { + let (hover_result, _line, _column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.hover(uri, line, column) - }); - - match result { - Ok((hover_result, _line, _column)) => { + })?; if hover_result == Value::Null { return Ok("No hover information available at this position.".to_string()); } @@ -78,9 +46,6 @@ impl Tool for LspHover { .push_str(&serde_json::to_string_pretty(&hover_result).unwrap_or_default()); } Ok(output) - } - Err(e) => Err(e), - } } } diff --git a/crates/zesdex-backend/src/tool/lsp/mod.rs b/crates/zesdex-backend/src/tool/lsp/mod.rs index 4c2b340..7481ea7 100644 --- a/crates/zesdex-backend/src/tool/lsp/mod.rs +++ b/crates/zesdex-backend/src/tool/lsp/mod.rs @@ -50,6 +50,48 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] { } } +/// Build the standard `server` + `path` + `line` + `column` parameter schema +/// used by cursor-based LSP tools (definition, references, completion). +/// +/// When `with_language_id` is `true`, an optional `language_id` property is +/// included (for tools like hover that pass it to `didOpen`). +pub fn lsp_cursor_params(with_language_id: bool) -> serde_json::Value { + let mut props = serde_json::json!({ + "server": { + "type": "string", + "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted." + }, + "path": { + "type": "string", + "description": "Path to the file (relative to workspace root)" + }, + "line": { + "type": "integer", + "description": "Line number (0-based)" + }, + "column": { + "type": "integer", + "description": "Column number (0-based)" + } + }); + if with_language_id { + if let Some(obj) = props.as_object_mut() { + obj.insert( + "language_id".to_string(), + serde_json::json!({ + "type": "string", + "description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect." + }), + ); + } + } + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["path", "line", "column"] + }) +} + /// Guess which connected LSP server should handle `path` based on its extension. /// /// Flow: extract extension from `path` -> for each connected server, check @@ -132,7 +174,16 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result(ctx: &ToolCtx, args: &Value, op: F) -> Result<(R, u32, u32)> +/// +/// When `text` is `Some`, the provided content is used instead of reading +/// from disk (used by `LspDiagnostics` which receives the full text as an +/// argument). +fn run_lsp_query( + ctx: &ToolCtx, + args: &Value, + text: Option<&str>, + op: F, +) -> Result<(R, u32, u32)> where F: FnOnce(&mut crate::app::lsp::LspClient, &str, u32, u32) -> Result, { @@ -150,8 +201,11 @@ where let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?; let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); - let file_content = - std::fs::read_to_string(&abs_path).map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?; + let file_content = match text { + Some(t) => t.to_string(), + None => std::fs::read_to_string(&abs_path) + .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?, + }; let manager = ctx .lsp_manager diff --git a/crates/zesdex-backend/src/tool/lsp/references.rs b/crates/zesdex-backend/src/tool/lsp/references.rs index 83c596e..1cde63d 100644 --- a/crates/zesdex-backend/src/tool/lsp/references.rs +++ b/crates/zesdex-backend/src/tool/lsp/references.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_json::{json, Value}; +use serde_json::Value; use std::fmt::Write; use crate::tool::{Tool, ToolCtx}; @@ -7,73 +7,43 @@ use crate::tool::{Tool, ToolCtx}; pub struct LspReferences; impl Tool for LspReferences { - fn name(&self) -> &'static str { - "lsp_references" - } + fn name(&self) -> &'static str { "lsp_references" } fn description(&self) -> &'static str { "Find all references to a symbol at a cursor position. \ `server` is optional — if omitted, the server is auto-detected from the file's extension." } - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "server": { - "type": "string", - "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted." - }, - "path": { - "type": "string", - "description": "Path to the file (relative to workspace root)" - }, - "line": { - "type": "integer", - "description": "Line number (0-based)" - }, - "column": { - "type": "integer", - "description": "Column number (0-based)" - } - }, - "required": ["path", "line", "column"] - }) - } + fn parameters(&self) -> Value { super::lsp_cursor_params(false) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let result = super::run_lsp_query(ctx, args, |client, uri, line, column| { + let (ref_result, _line, _column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.references(uri, line, column) - }); + })?; - match result { - Ok((ref_result, _line, _column)) => { - let locations = ref_result.as_array().cloned().unwrap_or_default(); - if locations.is_empty() { - return Ok("No references found for this symbol.".to_string()); - } - - let mut output = format!("{} reference(s) found:\n", locations.len()); - for (i, loc) in locations.iter().enumerate().take(50) { - 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 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(serde_json::Value::as_i64) - .unwrap_or(0); - let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); - writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap(); - } - if locations.len() > 50 { - writeln!(output, " ... and {} more references", locations.len() - 50).unwrap(); - } - Ok(output) - } - Err(e) => Err(e), + let locations = ref_result.as_array().cloned().unwrap_or_default(); + if locations.is_empty() { + return Ok("No references found for this symbol.".to_string()); } + + let mut output = format!("{} reference(s) found:\n", locations.len()); + for (i, loc) in locations.iter().enumerate().take(50) { + 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 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(serde_json::Value::as_i64) + .unwrap_or(0); + let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); + writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap(); + } + if locations.len() > 50 { + writeln!(output, " ... and {} more references", locations.len() - 50).unwrap(); + } + Ok(output) } } diff --git a/crates/zesdex-backend/src/tool/mod.rs b/crates/zesdex-backend/src/tool/mod.rs index 0af2b16..c8855c4 100644 --- a/crates/zesdex-backend/src/tool/mod.rs +++ b/crates/zesdex-backend/src/tool/mod.rs @@ -1,6 +1,7 @@ //! Tool trait, execution context, and the registry of all built-in tools. use anyhow::Result; use serde_json::Value; +use sha2::Digest; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; @@ -237,6 +238,55 @@ pub fn tool_defs(tools: &[Box]) -> Vec = fs::read_dir(&path) diff --git a/crates/zesdex-backend/src/tool/utility/mod.rs b/crates/zesdex-backend/src/tool/utility/mod.rs index a753993..c6ddaf0 100644 --- a/crates/zesdex-backend/src/tool/utility/mod.rs +++ b/crates/zesdex-backend/src/tool/utility/mod.rs @@ -1,7 +1,28 @@ //! Small standalone utility tools (cd, dir listing/caching, pong, todowrite). + +use std::path::Path; + pub mod cd; pub mod dir_cache_update; pub mod dir_list; pub mod pong; pub mod todofinish; pub mod todowrite; + +/// Format a "path does not exist" message. +pub fn path_not_found(rel: &str, path: &Path) -> String { + format!( + "path '{}' does not exist (resolved to {})", + rel, + path.display() + ) +} + +/// Format a "path is not a directory" message. +pub fn path_not_a_directory(rel: &str, path: &Path) -> String { + format!( + "path '{}' is not a directory (resolved to {})", + rel, + path.display() + ) +} diff --git a/crates/zesdex-backend/src/tool/utility/pong.rs b/crates/zesdex-backend/src/tool/utility/pong.rs index bf6cd04..a87257b 100644 --- a/crates/zesdex-backend/src/tool/utility/pong.rs +++ b/crates/zesdex-backend/src/tool/utility/pong.rs @@ -5,8 +5,7 @@ //! //! Why: gives callers a cheap, dependency-free way to verify the tool //! harness is reachable and responding before running real work. -use super::super::Tool; -use super::super::ToolCtx; +use super::super::{Tool, ToolCtx}; use anyhow::Result; use serde_json::{json, Value}; @@ -14,9 +13,7 @@ use serde_json::{json, Value}; pub struct Pong; impl Tool for Pong { - fn name(&self) -> &'static str { - "pong" - } + fn name(&self) -> &'static str { "pong" } fn description(&self) -> &'static str { "Simple connectivity check. Echoes back any input for health checks and latency testing." diff --git a/crates/zesdex-backend/src/tool/utility/todofinish.rs b/crates/zesdex-backend/src/tool/utility/todofinish.rs index 23ba38d..cf7cc90 100644 --- a/crates/zesdex-backend/src/tool/utility/todofinish.rs +++ b/crates/zesdex-backend/src/tool/utility/todofinish.rs @@ -8,9 +8,7 @@ use std::path::PathBuf; pub struct Todofinish; impl Tool for Todofinish { - fn name(&self) -> &'static str { - "todofinish" - } + fn name(&self) -> &'static str { "todofinish" } fn description(&self) -> &'static str { "Mark tasks as finished in the session todo list (todo.md). You can mark all tasks as finished by leaving the 'task_index' empty, or specify a 1-based index to finish a specific task." diff --git a/crates/zesdex-backend/src/view/overlays/bash.rs b/crates/zesdex-backend/src/view/overlays/bash.rs index c0a84ea..1453c4e 100644 --- a/crates/zesdex-backend/src/view/overlays/bash.rs +++ b/crates/zesdex-backend/src/view/overlays/bash.rs @@ -1,4 +1,4 @@ -use ratatui::style::{Modifier, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; @@ -10,14 +10,7 @@ pub fn render( block: Block<'static>, state: &crate::app::state::rest::AppStateRest, ) { - let block = block - .title(Span::styled( - " Bash Jobs ", - Style::default() - .fg(Theme::ACCENT_ORANGE) - .add_modifier(Modifier::BOLD), - )) - .border_style(Style::default().fg(Theme::ACCENT_ORANGE)); + let block = super::overlay_block(block, "Bash Jobs", Theme::ACCENT_ORANGE); let lines: Vec = state .session_runtime .as_ref() diff --git a/crates/zesdex-backend/src/view/overlays/help.rs b/crates/zesdex-backend/src/view/overlays/help.rs index ba2abce..e40ad34 100644 --- a/crates/zesdex-backend/src/view/overlays/help.rs +++ b/crates/zesdex-backend/src/view/overlays/help.rs @@ -1,5 +1,4 @@ -use ratatui::style::{Modifier, Style}; -use ratatui::text::Span; +use ratatui::style::Style; use ratatui::widgets::{Block, Paragraph, Wrap}; use ratatui::Frame; use crate::view::theme::Theme; @@ -10,14 +9,7 @@ pub fn render( block: Block<'static>, _state: &crate::app::state::rest::AppStateRest, ) { - let block = block - .title(Span::styled( - " Help ", - Style::default() - .fg(Theme::INFO) - .add_modifier(Modifier::BOLD), - )) - .border_style(Style::default().fg(Theme::INFO)); + let block = super::overlay_block(block, "Help", Theme::INFO); let content = crate::prompts::HELP_TEXT; let paragraph = Paragraph::new(content) .block(block) diff --git a/crates/zesdex-backend/src/view/overlays/mod.rs b/crates/zesdex-backend/src/view/overlays/mod.rs index bfbfbc0..3b84ece 100644 --- a/crates/zesdex-backend/src/view/overlays/mod.rs +++ b/crates/zesdex-backend/src/view/overlays/mod.rs @@ -19,11 +19,29 @@ pub mod todo; pub mod usage; use ratatui::layout::Rect; -use ratatui::style::Style; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Clear}; use ratatui::Frame; use super::theme::Theme; +/// Decorate an overlay block with a styled title and matching border color. +/// +/// Every overlay renders a `Block` with a title bar in its variant colour +/// and a matching border — this helper centralises the `Span::styled` + +/// `border_style` boilerplate that was duplicated identically in 14 overlay +/// modules. +pub fn overlay_block(block: Block<'static>, title: &str, color: ratatui::style::Color) -> Block<'static> { + block + .title(Span::styled( + format!(" {title} "), + Style::default() + .fg(color) + .add_modifier(Modifier::BOLD), + )) + .border_style(Style::default().fg(color)) +} + /// Compute a centered rectangle within `area` at the given percentage width /// and height. The result is always at least 40 cols wide and 10 rows tall. pub fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { diff --git a/crates/zesdex-backend/src/view/overlays/settings.rs b/crates/zesdex-backend/src/view/overlays/settings.rs index 8cdc7e4..a2e12db 100644 --- a/crates/zesdex-backend/src/view/overlays/settings.rs +++ b/crates/zesdex-backend/src/view/overlays/settings.rs @@ -1,4 +1,4 @@ -use ratatui::style::{Modifier, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; @@ -10,14 +10,7 @@ pub fn render( block: Block<'static>, state: &crate::app::state::rest::AppStateRest, ) { - let block = block - .title(Span::styled( - " Settings ", - Style::default() - .fg(Theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )) - .border_style(Style::default().fg(Theme::PRIMARY)); + let block = super::overlay_block(block, "Settings", Theme::PRIMARY); let lines = vec![ Line::from(Span::styled( format!(" Provider: {}", state.settings.provider),