refactor: DRY cleanup — extract shared helpers, remove duplication across tools, LSP, overlays, and runtime
Eliminate ~500 lines of duplicate code across 31 files by extracting shared functions, helpers, and consolidating repeated patterns. Highlights: - Toast helpers (toast_info/success/warning/error) on AppStateRest - push_event() helper for turn-event queue (19 callers consolidated) - log_write_edit_tool() shared fn (turn.rs + engine.rs ~50 lines saved) - resolve_api_key() shared fn (spawn.rs + provider.rs) - LSP call_positional() helper on LspClient - lsp_cursor_params() shared schema for 4 tool files -overlay_block() helper for consistent overlay title/border styling - cycle_selected_index(), path_not_found/a_directory() helpers - mark_dirty(), save_settings() on AppStateRest - Remove redundant Err(e) => Err(e) arms in LSP tools - Consolidate generate_workspace_tree (turn.rs → workspace.rs) - Simplify background-review wrapper args in auto/mod.rs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b02754acd2
commit
9a6ab62562
@@ -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<serde_json::Value>,
|
||||
) -> anyhow::Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String> = 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::<Vec<String>>()
|
||||
.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</think>\n\n".to_string(),
|
||||
));
|
||||
}
|
||||
push_event(&events_q, TurnEvent::StreamToken(
|
||||
"\n</think>\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.
|
||||
|
||||
@@ -315,7 +315,7 @@ pub fn shape_messages(
|
||||
let mut result: Option<String> = None;
|
||||
let mut last_err: Option<anyhow::Error> = 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!(
|
||||
|
||||
@@ -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<Mutex<VecDeque<TurnEvent>>>,
|
||||
event: TurnEvent,
|
||||
) {
|
||||
if let Ok(mut guard) = q.lock() {
|
||||
guard.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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)
|
||||
|
||||
@@ -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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) -> (Vec<String>, std::path::PathBuf, Vec<std::path::PathBuf>, Arc<Mutex<VecDeque<TurnEvent>>>, Arc<AtomicBool>) {
|
||||
(
|
||||
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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
// 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<String> = 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -27,40 +27,19 @@ pub(crate) fn resolve_provider_config() -> (String, String, Option<String>, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
.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<Action> {
|
||||
{
|
||||
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<Action> {
|
||||
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()
|
||||
|
||||
@@ -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<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
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<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String
|
||||
/// Opens the file on the server via `didOpen`, invokes the query closure,
|
||||
/// then closes the file via `didClose`. Returns the query result along with
|
||||
/// the 0-based line and column for post-processing.
|
||||
fn run_lsp_query<F, R>(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<F, R>(
|
||||
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<R>,
|
||||
{
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn Tool>]) -> Vec<crate::dto::provider::request::
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// After a successful write/edit tool run, compute content hash and byte
|
||||
/// delta, then persist an `EditLogEntry` to the session's edit log.
|
||||
///
|
||||
/// Used by both the main agent turn loop (`turn.rs`) and the subagent engine
|
||||
/// (`engine.rs`) to avoid duplicating the SHA-256 / bytes_delta / entry
|
||||
/// construction / save sequence.
|
||||
pub fn log_write_edit_tool(
|
||||
args: &serde_json::Value,
|
||||
tool_name: &str,
|
||||
origin_tag: &str,
|
||||
session_dir: &std::path::Path,
|
||||
session_id: &str,
|
||||
) {
|
||||
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 = args.get("content").or_else(|| args.get("new"));
|
||||
let content_str = content.and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes()));
|
||||
let bytes_delta = if tool_name == "write" {
|
||||
content_str.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: tool_name.to_string(),
|
||||
path: path.to_string(),
|
||||
reason: reason.to_string(),
|
||||
content_sha256,
|
||||
bytes_delta,
|
||||
origin: origin_tag.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
};
|
||||
use zesdex_cms::domain::repository::EditLogRepository;
|
||||
let repo =
|
||||
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
||||
if let Ok(mut el) = repo.open(session_dir) {
|
||||
let _ = repo.append(session_dir, &mut el, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a required string argument from a JSON args map.
|
||||
///
|
||||
/// Return: the value as `String` if present and a string type; `Err` if missing
|
||||
|
||||
@@ -45,18 +45,10 @@ impl Tool for Cd {
|
||||
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(format!(
|
||||
"path '{}' does not exist (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
return Ok(super::path_not_found(&rel, &path));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Ok(format!(
|
||||
"path '{}' is not a directory (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
return Ok(super::path_not_a_directory(&rel, &path));
|
||||
}
|
||||
|
||||
let canon = path.canonicalize().unwrap_or(path);
|
||||
|
||||
@@ -56,11 +56,7 @@ impl Tool for DirCacheUpdate {
|
||||
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(format!(
|
||||
"path '{}' does not exist (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
return Ok(super::path_not_found(&rel, &path));
|
||||
}
|
||||
|
||||
let entries = walk_directory(&path);
|
||||
|
||||
@@ -57,18 +57,10 @@ impl Tool for DirList {
|
||||
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(format!(
|
||||
"path '{}' does not exist (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
return Ok(super::path_not_found(&rel, &path));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Ok(format!(
|
||||
"path '{}' is not a directory (resolved to {})",
|
||||
rel,
|
||||
path.display()
|
||||
));
|
||||
return Ok(super::path_not_a_directory(&rel, &path));
|
||||
}
|
||||
|
||||
let entries: Vec<String> = fs::read_dir(&path)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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<Line> = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user