From 605591d06483471f2464d66c5adfca60f74c1b0f Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 18 Feb 2026 08:17:15 -0600 Subject: [PATCH] Feature: parallel tool execution, UI/widget enhancements, onboarding improvements, engine upgrades - Add parallel batch execution for read-only tools - Enhance UI widgets, sidebar focus, and onboarding flows - Upgrade engine with error escalation, tool search, and risk-based replanning - Extend client with server tool usage and container support - Update settings and compaction logic - Improve MCP resource handling and subagent coordination - Update README with new tool count --- README.md | 15 +- src/client.rs | 306 +++++++++++++++-- src/commands/config.rs | 7 +- src/compaction.rs | 37 ++ src/core/engine.rs | 469 +++++++++++++++++++++++++- src/core/engine/tests.rs | 78 +++++ src/core/turn.rs | 1 + src/mcp.rs | 34 +- src/models.rs | 70 ++++ src/modules/text.rs | 1 + src/runtime_api.rs | 1 + src/runtime_threads.rs | 5 + src/settings.rs | 34 ++ src/tools/registry.rs | 5 + src/tools/subagent.rs | 6 +- src/tui/app.rs | 226 +++++++++++++ src/tui/onboarding/api_key.rs | 4 + src/tui/onboarding/mod.rs | 46 ++- src/tui/onboarding/trust_directory.rs | 6 +- src/tui/ui.rs | 264 +++++++++++++-- src/tui/ui/tests.rs | 73 ++++ src/tui/views/mod.rs | 3 + src/tui/widgets/mod.rs | 62 ++-- src/utils.rs | 3 + src/working_set.rs | 12 +- 25 files changed, 1685 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index f49adeb8..0615f71f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Press `Tab` to cycle modes. Normal mode (manual approval for everything) is also ## What It Can Do -The assistant has access to 30+ tools: +The assistant has access to 35+ tools: - **File operations** — read, write, edit, patch, search, and grep across your workspace - **Shell execution** — run commands with timeout, background execution, and interactive I/O @@ -64,6 +64,13 @@ The assistant has access to 30+ tools: - **Sub-agents** — spawn background agents or coordinate agent swarms for parallel work - **Task management** — to-do lists, implementation plans, persistent notes, and a background task queue - **Structured data** — weather, finance, sports scores, time zones, and a calculator +- **Parallel execution** — run multiple tool calls simultaneously for efficiency +- **Project mapping** — explore codebase structure and identify key files +- **Code execution** — run Python code in a sandboxed environment +- **Test runner** — execute `cargo test` and other test suites +- **Diagnostics** — inspect workspace, git, and toolchain status +- **Note-taking** — record persistent notes for future reference +- **Tool discovery** — search for tools by name or natural language - **MCP integration** — connect external tool servers via the [Model Context Protocol](docs/MCP.md) All file tools respect the `--workspace` boundary unless `/trust` is enabled. @@ -92,6 +99,9 @@ deepseek review --staged # Start the runtime API server deepseek serve --http +# List available models +deepseek models + # Verify your environment deepseek doctor ``` @@ -143,6 +153,9 @@ See [`docs/RUNTIME_API.md`](docs/RUNTIME_API.md) for endpoints and usage. | No API key | Set `DEEPSEEK_API_KEY` or run `deepseek` to complete onboarding | | Config not found | Check `~/.deepseek/config.toml` (or set `DEEPSEEK_CONFIG_PATH`) | | Wrong region | Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China) | +| Finance tool unavailable | Use `web.run` to fetch financial data instead | +| Token/cost tracking inaccuracies | Use `/compact` to manage context; treat cost estimates as approximate | +| `web.run` tool name | Tool is named `web.run` (single dot), not `web..run` | | Sandbox errors (macOS) | Run `deepseek doctor` | ## Documentation diff --git a/src/client.rs b/src/client.rs index 3363ef46..9ff58433 100644 --- a/src/client.rs +++ b/src/client.rs @@ -23,7 +23,7 @@ use crate::llm_client::{ use crate::logging; use crate::models::{ ContentBlock, ContentBlockStart, Delta, Message, MessageDelta, MessageRequest, MessageResponse, - StreamEvent, SystemPrompt, Tool, Usage, + ServerToolUsage, StreamEvent, SystemPrompt, Tool, ToolCaller, Usage, }; fn to_api_tool_name(name: &str) -> String { @@ -777,7 +777,12 @@ impl LlmClient for DeepSeekClient { model: model.clone(), stop_reason: None, stop_sequence: None, - usage: Usage { input_tokens: 0, output_tokens: 0 }, + container: None, + usage: Usage { + input_tokens: 0, + output_tokens: 0, + server_tool_use: None, + }, }, }); @@ -959,26 +964,72 @@ fn build_responses_input(messages: &[Message]) -> Vec { }] })); } - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { + id, + name, + input, + caller, + } => { let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string()); - items.push(json!({ + let mut item = json!({ "type": "function_call", "call_id": id, "name": to_api_tool_name(name), "arguments": args, - })); + }); + if let Some(caller) = caller { + item["caller"] = json!({ + "type": caller.caller_type, + "tool_id": caller.tool_id, + }); + } + items.push(item); } ContentBlock::ToolResult { tool_use_id, content, + is_error, + .. } => { - items.push(json!({ + let mut item = json!({ "type": "function_call_output", "call_id": tool_use_id, "output": content, - })); + }); + if let Some(is_error) = is_error { + item["is_error"] = json!(is_error); + } + items.push(item); } ContentBlock::Thinking { .. } => {} + ContentBlock::ServerToolUse { id, name, input } => { + items.push(json!({ + "type": "server_tool_use", + "id": id, + "name": name, + "input": input, + })); + } + ContentBlock::ToolSearchToolResult { + tool_use_id, + content, + } => { + items.push(json!({ + "type": "tool_search_tool_result", + "tool_use_id": tool_use_id, + "content": content, + })); + } + ContentBlock::CodeExecutionToolResult { + tool_use_id, + content, + } => { + items.push(json!({ + "type": "code_execution_tool_result", + "tool_use_id": tool_use_id, + "content": content, + })); + } } } } @@ -987,12 +1038,41 @@ fn build_responses_input(messages: &[Message]) -> Vec { } fn tool_to_responses(tool: &Tool) -> Value { - json!({ - "type": "function", - "name": to_api_tool_name(&tool.name), - "description": tool.description, - "parameters": tool.input_schema, - }) + let tool_type = tool.tool_type.as_deref().unwrap_or("function"); + let mut value = if tool_type == "function" { + json!({ + "type": "function", + "name": to_api_tool_name(&tool.name), + "description": tool.description, + "parameters": tool.input_schema, + }) + } else if tool_type == "code_execution_20250825" { + json!({ + "type": tool_type, + "name": to_api_tool_name(&tool.name), + }) + } else { + json!({ + "type": tool_type, + "name": to_api_tool_name(&tool.name), + "description": tool.description, + "input_schema": tool.input_schema, + }) + }; + + if let Some(allowed_callers) = &tool.allowed_callers { + value["allowed_callers"] = json!(allowed_callers); + } + if let Some(defer_loading) = tool.defer_loading { + value["defer_loading"] = json!(defer_loading); + } + if let Some(input_examples) = &tool.input_examples { + value["input_examples"] = json!(input_examples); + } + if let Some(strict) = tool.strict { + value["strict"] = json!(strict); + } + value } fn parse_responses_message(payload: &Value) -> Result { @@ -1059,10 +1139,86 @@ fn parse_responses_message(payload: &Value) -> Result { Some(other) => other.clone(), None => Value::Null, }; + let caller = item.get("caller").and_then(|v| { + v.get("type") + .and_then(Value::as_str) + .map(|caller_type| ToolCaller { + caller_type: caller_type.to_string(), + tool_id: v + .get("tool_id") + .and_then(Value::as_str) + .map(std::string::ToString::to_string), + }) + }); content.push(ContentBlock::ToolUse { id: call_id, name: from_api_tool_name(&name), input, + caller, + }); + } + "function_call_output" => { + let tool_use_id = item + .get("call_id") + .or_else(|| item.get("tool_use_id")) + .and_then(Value::as_str) + .unwrap_or("tool_call") + .to_string(); + let content_text = item + .get("output") + .or_else(|| item.get("content")) + .map(|v| { + if let Some(s) = v.as_str() { + s.to_string() + } else { + v.to_string() + } + }) + .unwrap_or_default(); + let is_error = item.get("is_error").and_then(Value::as_bool); + content.push(ContentBlock::ToolResult { + tool_use_id, + content: content_text, + is_error, + content_blocks: None, + }); + } + "server_tool_use" => { + let id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or("server_tool") + .to_string(); + let name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or("server_tool") + .to_string(); + let input = item.get("input").cloned().unwrap_or(Value::Null); + content.push(ContentBlock::ServerToolUse { id, name, input }); + } + "tool_search_tool_result" => { + let tool_use_id = item + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or("tool_search") + .to_string(); + let content_value = item.get("content").cloned().unwrap_or(Value::Null); + content.push(ContentBlock::ToolSearchToolResult { + tool_use_id, + content: content_value, + }); + } + "code_execution_tool_result" => { + let tool_use_id = item + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or("code_execution") + .to_string(); + let content_value = item.get("content").cloned().unwrap_or(Value::Null); + content.push(ContentBlock::CodeExecutionToolResult { + tool_use_id, + content: content_value, }); } "reasoning" => { @@ -1103,6 +1259,10 @@ fn parse_responses_message(payload: &Value) -> Result { model, stop_reason: None, stop_sequence: None, + container: payload + .get("container") + .cloned() + .and_then(|v| serde_json::from_value(v).ok()), usage, }) } @@ -1139,21 +1299,35 @@ fn build_chat_messages( match block { ContentBlock::Text { text, .. } => text_parts.push(text.clone()), ContentBlock::Thinking { thinking } => thinking_parts.push(thinking.clone()), - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { + id, + name, + input, + caller, + .. + } => { let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string()); - tool_calls.push(json!({ + let mut call = json!({ "id": id, "type": "function", "function": { "name": to_api_tool_name(name), "arguments": args, } - })); + }); + if let Some(caller) = caller { + call["caller"] = json!({ + "type": caller.caller_type, + "tool_id": caller.tool_id, + }); + } + tool_calls.push(call); tool_call_ids.push(id.clone()); } ContentBlock::ToolResult { tool_use_id, content, + .. } => { tool_results.push(( tool_use_id.clone(), @@ -1164,6 +1338,9 @@ fn build_chat_messages( }), )); } + ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } @@ -1336,14 +1513,27 @@ fn build_chat_messages( } fn tool_to_chat(tool: &Tool) -> Value { - json!({ + let mut value = json!({ "type": "function", "function": { "name": to_api_tool_name(&tool.name), "description": tool.description, "parameters": tool.input_schema, } - }) + }); + if let Some(allowed_callers) = &tool.allowed_callers { + value["allowed_callers"] = json!(allowed_callers); + } + if let Some(defer_loading) = tool.defer_loading { + value["defer_loading"] = json!(defer_loading); + } + if let Some(input_examples) = &tool.input_examples { + value["input_examples"] = json!(input_examples); + } + if let Some(strict) = tool.strict { + value["strict"] = json!(strict); + } + value } fn map_tool_choice_for_chat(choice: &Value) -> Option { @@ -1444,11 +1634,23 @@ fn parse_chat_message(payload: &Value) -> Result { .and_then(Value::as_str) .map(|raw| serde_json::from_str(raw).unwrap_or(Value::String(raw.to_string()))) .unwrap_or(Value::Null); + let caller = call.get("caller").and_then(|v| { + v.get("type") + .and_then(Value::as_str) + .map(|caller_type| ToolCaller { + caller_type: caller_type.to_string(), + tool_id: v + .get("tool_id") + .and_then(Value::as_str) + .map(std::string::ToString::to_string), + }) + }); content_blocks.push(ContentBlock::ToolUse { id, name: from_api_tool_name(&name), input: arguments, + caller, }); } } @@ -1466,6 +1668,7 @@ fn parse_chat_message(payload: &Value) -> Result { .and_then(Value::as_str) .map(str::to_string), stop_sequence: None, + container: None, usage, }) } @@ -1483,9 +1686,25 @@ fn parse_usage(usage: Option<&Value>) -> Usage { .and_then(Value::as_u64) .unwrap_or(0); + let server_tool_use = usage.and_then(|u| u.get("server_tool_use")).map(|server| { + let code_execution_requests = server + .get("code_execution_requests") + .and_then(Value::as_u64) + .map(|v| v as u32); + let tool_search_requests = server + .get("tool_search_requests") + .and_then(Value::as_u64) + .map(|v| v as u32); + ServerToolUsage { + code_execution_requests, + tool_search_requests, + } + }); + Usage { input_tokens: input_tokens as u32, output_tokens: output_tokens as u32, + server_tool_use, } } @@ -1535,18 +1754,34 @@ fn build_stream_events(response: &MessageResponse) -> Vec { } events.push(StreamEvent::ContentBlockStop { index }); } - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { events.push(StreamEvent::ContentBlockStart { index, content_block: ContentBlockStart::ToolUse { id: id.clone(), name: name.clone(), input: input.clone(), + caller: None, + }, + }); + events.push(StreamEvent::ContentBlockStop { index }); + } + ContentBlock::ToolResult { .. } => {} + ContentBlock::ServerToolUse { id, name, input } => { + events.push(StreamEvent::ContentBlockStart { + index, + content_block: ContentBlockStart::ServerToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), }, }); events.push(StreamEvent::ContentBlockStop { index }); } - ContentBlock::ToolResult { .. } => {} + ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } index = index.saturating_add(1); } @@ -1687,6 +1922,17 @@ fn parse_sse_chunk( .and_then(Value::as_str) .unwrap_or("") .to_string(); + let caller = tc.get("caller").and_then(|v| { + v.get("type") + .and_then(Value::as_str) + .map(|caller_type| ToolCaller { + caller_type: caller_type.to_string(), + tool_id: v + .get("tool_id") + .and_then(Value::as_str) + .map(std::string::ToString::to_string), + }) + }); entry.insert(true); events.push(StreamEvent::ContentBlockStart { @@ -1695,6 +1941,7 @@ fn parse_sse_chunk( id, name: from_api_tool_name(&name), input: json!({}), + caller, }, }); } @@ -1886,6 +2133,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool-1".to_string(), content: "ok".to_string(), + is_error: None, + content_blocks: None, }], }]; @@ -1905,6 +2154,7 @@ mod tests { id: "tool-1".to_string(), name: "list_dir".to_string(), input: json!({}), + caller: None, }], }, Message { @@ -1912,6 +2162,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool-1".to_string(), content: "ok".to_string(), + is_error: None, + content_blocks: None, }], }, ]; @@ -1937,6 +2189,7 @@ mod tests { id: "tool-1".to_string(), name: "web.run".to_string(), input: json!({}), + caller: None, }], }, Message { @@ -1944,6 +2197,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool-1".to_string(), content: "ok".to_string(), + is_error: None, + content_blocks: None, }], }, ]; @@ -1978,6 +2233,7 @@ mod tests { id: "tool-orphan".to_string(), name: "read_file".to_string(), input: json!({"path": "src/main.rs"}), + caller: None, }], }, // No tool result follows — it was removed by compaction. @@ -2017,6 +2273,7 @@ mod tests { id: "tool-ok".to_string(), name: "list_dir".to_string(), input: json!({}), + caller: None, }], }, Message { @@ -2024,6 +2281,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool-ok".to_string(), content: "files".to_string(), + is_error: None, + content_blocks: None, }], }, ]; @@ -2054,16 +2313,19 @@ mod tests { id: "t1".to_string(), name: "read_file".to_string(), input: json!({"path": "a.rs"}), + caller: None, }, ContentBlock::ToolUse { id: "t2".to_string(), name: "read_file".to_string(), input: json!({"path": "b.rs"}), + caller: None, }, ContentBlock::ToolUse { id: "t3".to_string(), name: "shell".to_string(), input: json!({"cmd": "ls"}), + caller: None, }, ], }, @@ -2072,6 +2334,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "t1".to_string(), content: "content a".to_string(), + is_error: None, + content_blocks: None, }], }, Message { @@ -2079,6 +2343,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "t2".to_string(), content: "content b".to_string(), + is_error: None, + content_blocks: None, }], }, // No result for t3 diff --git a/src/commands/config.rs b/src/commands/config.rs index 54cc264b..c0cd898e 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -4,7 +4,7 @@ use super::CommandResult; use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name, clear_api_key}; use crate::palette; use crate::settings::Settings; -use crate::tui::app::{App, AppAction, AppMode, OnboardingState}; +use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus}; use crate::tui::approval::ApprovalMode; /// Display current configuration @@ -22,6 +22,7 @@ pub fn show_config(app: &mut App) -> CommandResult { Trust mode: {}\n\ Auto-compact: {}\n\ Sidebar width: {}%\n\ + Sidebar focus: {}\n\ Total tokens: {}\n\ Project doc: {}", app.mode.label(), @@ -33,6 +34,7 @@ pub fn show_config(app: &mut App) -> CommandResult { if app.trust_mode { "yes" } else { "no" }, if app.auto_compact { "yes" } else { "no" }, app.sidebar_width_percent, + app.sidebar_focus.as_setting(), app.total_tokens, if has_project_doc { "loaded" @@ -179,6 +181,9 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { app.sidebar_width_percent = settings.sidebar_width_percent; app.mark_history_updated(); } + "sidebar_focus" | "focus" => { + app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus)); + } _ => {} } diff --git a/src/compaction.rs b/src/compaction.rs index 01497f75..77287403 100644 --- a/src/compaction.rs +++ b/src/compaction.rs @@ -199,6 +199,9 @@ fn message_text(msg: &Message) -> String { ContentBlock::ToolResult { content, .. } => { let _ = writeln!(text, "{content}"); } + ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } text @@ -212,6 +215,9 @@ fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Ve ContentBlock::ToolResult { content, .. } => extract_paths_from_text(content, workspace), ContentBlock::ToolUse { input, .. } => extract_paths_from_tool_input(input, workspace), ContentBlock::Thinking { .. } => Vec::new(), + ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => Vec::new(), }; paths.extend(candidates); } @@ -462,6 +468,9 @@ fn estimate_tokens_for_message(message: &Message) -> usize { .map(|s| s.len() / 4) .unwrap_or(100), ContentBlock::ToolResult { content, .. } => content.len() / 4, + ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => 0, }) .sum::() } @@ -770,6 +779,9 @@ async fn create_summary( ContentBlock::Thinking { .. } => { // Skip thinking blocks in summary } + ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } } @@ -1164,6 +1176,7 @@ mod tests { id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({"path": "src/main.rs"}), + caller: None, }], }, Message { @@ -1171,6 +1184,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool-1".to_string(), content: "ok src/main.rs".to_string(), + is_error: None, + content_blocks: None, }], }, ]; @@ -1247,6 +1262,7 @@ mod tests { id: "orphan-call".to_string(), name: "read_file".to_string(), input: json!({"path": "src/main.rs"}), + caller: None, }], }, msg("assistant", "recent"), @@ -1275,6 +1291,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "orphan-result".to_string(), content: "ok".to_string(), + is_error: None, + content_blocks: None, }], }, msg("assistant", "recent"), @@ -1302,6 +1320,7 @@ mod tests { id: "tool-ok".to_string(), name: "list_dir".to_string(), input: json!({}), + caller: None, }], }, Message { @@ -1309,6 +1328,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool-ok".to_string(), content: "files here".to_string(), + is_error: None, + content_blocks: None, }], }, msg("assistant", "done"), @@ -1336,11 +1357,13 @@ mod tests { id: "t1".to_string(), name: "read_file".to_string(), input: json!({"path": "a.rs"}), + caller: None, }, ContentBlock::ToolUse { id: "t2".to_string(), name: "read_file".to_string(), input: json!({"path": "b.rs"}), + caller: None, }, ], }, @@ -1349,6 +1372,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "t1".to_string(), content: "content of a.rs".to_string(), + is_error: None, + content_blocks: None, }], }, Message { @@ -1356,6 +1381,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "t2".to_string(), content: "content of b.rs".to_string(), + is_error: None, + content_blocks: None, }], }, msg("assistant", "done"), @@ -1397,11 +1424,13 @@ mod tests { id: "good".to_string(), name: "read_file".to_string(), input: json!({}), + caller: None, }, ContentBlock::ToolUse { id: "orphan".to_string(), name: "shell".to_string(), input: json!({}), + caller: None, }, ], }, @@ -1410,6 +1439,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "good".to_string(), content: "ok".to_string(), + is_error: None, + content_blocks: None, }], }, // Note: NO result for "orphan" exists anywhere @@ -1443,6 +1474,7 @@ mod tests { id: format!("t{i}"), name: "read_file".to_string(), input: json!({}), + caller: None, }], }); messages.push(Message { @@ -1450,6 +1482,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: format!("t{i}"), content: format!("result {i}"), + is_error: None, + content_blocks: None, }], }); } @@ -1551,6 +1585,7 @@ mod tests { id: "patch-1".to_string(), name: "apply_patch".to_string(), input: json!({"patch": "diff content"}), + caller: None, }], }, Message { @@ -1558,6 +1593,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "patch-1".to_string(), content: "Patch applied successfully".to_string(), + is_error: None, + content_blocks: None, }], }, msg("assistant", "more chat"), diff --git a/src/core/engine.rs b/src/core/engine.rs index 6a60fac9..012f14a1 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -30,12 +30,12 @@ use crate::llm_client::LlmClient; use crate::mcp::McpPool; use crate::models::{ ContentBlock, ContentBlockStart, DEFAULT_CONTEXT_WINDOW_TOKENS, Delta, Message, MessageRequest, - StreamEvent, SystemPrompt, Tool, Usage, context_window_for_model, + StreamEvent, SystemPrompt, Tool, ToolCaller, Usage, context_window_for_model, }; use crate::prompts; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; -use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult}; +use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult, required_str}; use crate::tools::subagent::{ SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, }; @@ -287,6 +287,7 @@ struct ToolUseState { id: String, name: String, input: serde_json::Value, + caller: Option, input_buffer: String, } @@ -305,11 +306,13 @@ struct ToolExecutionPlan { id: String, name: String, input: serde_json::Value, + caller: Option, interactive: bool, approval_required: bool, approval_description: String, supports_parallel: bool, read_only: bool, + blocked_error: Option, } #[derive(Debug, serde::Serialize)] @@ -366,6 +369,12 @@ const TOOL_CALL_START_MARKERS: [&str; 5] = [ const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; +const CODE_EXECUTION_TOOL_NAME: &str = "code_execution"; +const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825"; +const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; +const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119"; +const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25"; +const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119"; const TOOL_CALL_END_MARKERS: [&str; 5] = [ "[/TOOL_CALL]", "", @@ -520,6 +529,262 @@ fn parse_parallel_tool_calls( Ok(calls) } +fn is_tool_search_tool(name: &str) -> bool { + matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME) +} + +fn should_default_defer_tool(name: &str) -> bool { + !matches!( + name, + "read_file" + | "list_dir" + | "grep_files" + | "file_search" + | "diagnostics" + | MULTI_TOOL_PARALLEL_NAME + | "update_plan" + | "todo_write" + | REQUEST_USER_INPUT_NAME + ) +} + +fn ensure_advanced_tooling(catalog: &mut Vec) { + if !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME) { + catalog.push(Tool { + tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()), + name: CODE_EXECUTION_TOOL_NAME.to_string(), + description: "Execute Python code in a local sandboxed runtime and return stdout/stderr/return_code as JSON.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "code": { "type": "string", "description": "Python source code to execute." } + }, + "required": ["code"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } + + if !catalog.iter().any(|t| t.name == TOOL_SEARCH_REGEX_NAME) { + catalog.push(Tool { + tool_type: Some(TOOL_SEARCH_REGEX_TYPE.to_string()), + name: TOOL_SEARCH_REGEX_NAME.to_string(), + description: "Search deferred tool definitions using a regex query and return matching tool references.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." } + }, + "required": ["query"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } + + if !catalog.iter().any(|t| t.name == TOOL_SEARCH_BM25_NAME) { + catalog.push(Tool { + tool_type: Some(TOOL_SEARCH_BM25_TYPE.to_string()), + name: TOOL_SEARCH_BM25_NAME.to_string(), + description: "Search deferred tool definitions using natural-language matching and return matching tool references.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Natural language query for tool discovery." } + }, + "required": ["query"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } +} + +fn initial_active_tools(catalog: &[Tool]) -> std::collections::HashSet { + let mut active = std::collections::HashSet::new(); + for tool in catalog { + if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) { + active.insert(tool.name.clone()); + } + } + if active.is_empty() && !catalog.is_empty() { + if let Some(first) = catalog.first() { + active.insert(first.name.clone()); + } + } + active +} + +fn active_tool_list_from_catalog( + catalog: &[Tool], + active: &std::collections::HashSet, +) -> Vec { + catalog + .iter() + .filter(|tool| active.contains(&tool.name)) + .cloned() + .collect() +} + +fn tool_search_haystack(tool: &Tool) -> String { + format!( + "{}\n{}\n{}", + tool.name.to_lowercase(), + tool.description.to_lowercase(), + tool.input_schema.to_string().to_lowercase() + ) +} + +fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result, ToolError> { + let regex = regex::Regex::new(query) + .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; + + let mut matches = Vec::new(); + for tool in catalog { + if is_tool_search_tool(&tool.name) { + continue; + } + let hay = tool_search_haystack(tool); + if regex.is_match(&hay) { + matches.push(tool.name.clone()); + } + if matches.len() >= 5 { + break; + } + } + Ok(matches) +} + +fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { + let terms: Vec = query + .split_whitespace() + .map(|term| term.trim().to_lowercase()) + .filter(|term| !term.is_empty()) + .collect(); + if terms.is_empty() { + return Vec::new(); + } + + let mut scored: Vec<(i64, String)> = Vec::new(); + for tool in catalog { + if is_tool_search_tool(&tool.name) { + continue; + } + let hay = tool_search_haystack(tool); + let mut score = 0i64; + for term in &terms { + if hay.contains(term) { + score += 1; + } + if tool.name.to_lowercase().contains(term) { + score += 2; + } + } + if score > 0 { + scored.push((score, tool.name.clone())); + } + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + scored.into_iter().take(5).map(|(_, name)| name).collect() +} + +fn execute_tool_search( + tool_name: &str, + input: &serde_json::Value, + catalog: &[Tool], + active_tools: &mut std::collections::HashSet, +) -> Result { + let query = required_str(input, "query")?; + let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME { + discover_tools_with_regex(catalog, query)? + } else { + discover_tools_with_bm25_like(catalog, query) + }; + + for name in &discovered { + active_tools.insert(name.clone()); + } + + let references = discovered + .iter() + .map(|name| json!({"type": "tool_reference", "tool_name": name})) + .collect::>(); + + let payload = json!({ + "type": "tool_search_tool_search_result", + "tool_references": references, + }); + + Ok(ToolResult { + content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), + success: true, + metadata: Some(json!({ + "tool_references": discovered, + })), + }) +} + +async fn execute_code_execution_tool( + input: &serde_json::Value, + workspace: &std::path::Path, +) -> Result { + let code = required_str(input, "code")?; + let mut cmd = tokio::process::Command::new("python3"); + cmd.arg("-c"); + cmd.arg(code); + cmd.current_dir(workspace); + + let output = tokio::time::timeout(Duration::from_secs(120), cmd.output()) + .await + .map_err(|_| ToolError::Timeout { seconds: 120 }) + .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let return_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + let payload = json!({ + "type": "code_execution_result", + "stdout": stdout, + "stderr": stderr, + "return_code": return_code, + "content": [], + }); + + Ok(ToolResult { + content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), + success, + metadata: Some(payload), + }) +} + +fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str { + caller.map_or("direct", |c| c.caller_type.as_str()) +} + +fn caller_allowed_for_tool(caller: Option<&ToolCaller>, tool_def: Option<&Tool>) -> bool { + let requested = caller_type_for_tool_use(caller); + if let Some(def) = tool_def + && let Some(allowed) = &def.allowed_callers + { + if allowed.is_empty() { + return requested == "direct"; + } + return allowed.iter().any(|item| item == requested); + } + requested == "direct" +} + fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool { !plans.is_empty() && plans.iter().all(|plan| { @@ -1128,6 +1393,22 @@ impl Engine { }; let tools = tool_registry.as_ref().map(|registry| { let mut tools = registry.to_api_tools(); + for tool in &mut tools { + tool.defer_loading = Some(should_default_defer_tool(&tool.name)); + } + let mut mcp_tools = mcp_tools; + for tool in &mut mcp_tools { + let name = tool.name.as_str(); + let keep_loaded = matches!( + name, + "list_mcp_resources" + | "list_mcp_resource_templates" + | "mcp_read_resource" + | "read_mcp_resource" + | "mcp_get_prompt" + ); + tool.defer_loading = Some(!keep_loaded); + } tools.extend(mcp_tools); tools }); @@ -1685,6 +1966,11 @@ impl Engine { let mut consecutive_tool_error_steps = 0u32; let mut turn_error: Option = None; let mut context_recovery_attempts = 0u8; + let mut tool_catalog = tools.unwrap_or_default(); + if !tool_catalog.is_empty() { + ensure_advanced_tooling(&mut tool_catalog); + } + let mut active_tool_names = initial_active_tools(&tool_catalog); loop { if self.cancel_token.is_cancelled() { @@ -1854,13 +2140,21 @@ impl Engine { } // Build the request + let active_tools = if tool_catalog.is_empty() { + None + } else { + Some(active_tool_list_from_catalog( + &tool_catalog, + &active_tool_names, + )) + }; let request = MessageRequest { model: self.session.model.clone(), messages: self.session.messages.clone(), max_tokens: TURN_MAX_OUTPUT_TOKENS, system: self.session.system_prompt.clone(), - tools: tools.clone(), - tool_choice: if tools.is_some() { + tools: active_tools.clone(), + tool_choice: if active_tools.is_some() { Some(json!({ "type": "auto" })) } else { None @@ -1910,6 +2204,7 @@ impl Engine { let mut usage = Usage { input_tokens: 0, output_tokens: 0, + server_tool_use: None, }; let mut current_block_kind: Option = None; let mut current_tool_index: Option = None; @@ -2037,7 +2332,12 @@ impl Engine { }) .await; } - ContentBlockStart::ToolUse { id, name, input } => { + ContentBlockStart::ToolUse { + id, + name, + input, + caller, + } => { crate::logging::info(format!( "Tool '{}' block start. Initial input: {:?}", name, input @@ -2056,6 +2356,30 @@ impl Engine { id, name, input, + caller, + input_buffer: String::new(), + }); + } + ContentBlockStart::ServerToolUse { id, name, input } => { + crate::logging::info(format!( + "Server tool '{}' block start. Initial input: {:?}", + name, input + )); + current_block_kind = Some(ContentBlockKind::ToolUse); + current_tool_index = Some(tool_uses.len()); + let _ = self + .tx_event + .send(Event::ToolCallStarted { + id: id.clone(), + name: name.clone(), + input: json!({}), + }) + .await; + tool_uses.push(ToolUseState { + id, + name, + input, + caller: None, input_buffer: String::new(), }); } @@ -2201,6 +2525,7 @@ impl Engine { id: call.id, name: call.name, input: call.args, + caller: None, input_buffer: String::new(), }); } @@ -2217,6 +2542,7 @@ impl Engine { id: tool.id.clone(), name: tool.name.clone(), input: tool.input.clone(), + caller: tool.caller.clone(), }); } @@ -2287,6 +2613,7 @@ impl Engine { let tool_id = tool.id.clone(); let tool_name = tool.name.clone(); let tool_input = tool.input.clone(); + let tool_caller = tool.caller.clone(); crate::logging::info(format!( "Planning tool '{}' with input: {:?}", tool_name, tool_input @@ -2303,6 +2630,24 @@ impl Engine { let mut approval_description = "Tool execution requires approval".to_string(); let mut supports_parallel = false; let mut read_only = false; + let mut blocked_error: Option = None; + let tool_def = tool_catalog.iter().find(|def| def.name == tool_name); + + if let Some(def) = tool_def + && def.defer_loading.unwrap_or(false) + && !active_tool_names.contains(&tool_name) + { + blocked_error = Some(ToolError::not_available(format!( + "Tool '{tool_name}' is deferred and not yet loaded. Use {TOOL_SEARCH_BM25_NAME} or {TOOL_SEARCH_REGEX_NAME} first." + ))); + } + + if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) { + blocked_error = Some(ToolError::permission_denied(format!( + "Tool '{tool_name}' does not allow caller '{}'", + caller_type_for_tool_use(tool_caller.as_ref()) + ))); + } if McpPool::is_mcp_tool(&tool_name) { read_only = mcp_tool_is_read_only(&tool_name); @@ -2316,6 +2661,17 @@ impl Engine { approval_description = spec.description().to_string(); supports_parallel = spec.supports_parallel(); read_only = spec.is_read_only(); + } else if tool_name == CODE_EXECUTION_TOOL_NAME { + approval_required = true; + approval_description = + "Run model-provided Python code in local execution sandbox".to_string(); + supports_parallel = false; + read_only = false; + } else if is_tool_search_tool(&tool_name) { + approval_required = false; + approval_description = "Search tool catalog".to_string(); + supports_parallel = false; + read_only = true; } plans.push(ToolExecutionPlan { @@ -2323,11 +2679,13 @@ impl Engine { id: tool_id, name: tool_name, input: tool_input, + caller: tool_caller, interactive, approval_required, approval_description, supports_parallel, read_only, + blocked_error, }); } @@ -2355,6 +2713,17 @@ impl Engine { if parallel_allowed { let mut tool_tasks = FuturesUnordered::new(); for plan in plans { + if let Some(err) = plan.blocked_error.clone() { + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: plan.id, + name: plan.name, + input: plan.input, + started_at: Instant::now(), + result: Err(err), + }); + continue; + } let registry = tool_registry; let lock = tool_exec_lock.clone(); let mcp_pool = mcp_pool.clone(); @@ -2403,6 +2772,28 @@ impl Engine { let tool_id = plan.id.clone(); let tool_name = plan.name.clone(); let tool_input = plan.input.clone(); + let tool_caller = plan.caller.clone(); + + if let Some(err) = plan.blocked_error.clone() { + let result = Err(err); + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id.clone(), + name: tool_name.clone(), + result: result.clone(), + }) + .await; + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: tool_id, + name: tool_name, + input: tool_input, + started_at: Instant::now(), + result, + }); + continue; + } if tool_name == MULTI_TOOL_PARALLEL_NAME { let started_at = Instant::now(); @@ -2434,6 +2825,60 @@ impl Engine { continue; } + if tool_name == CODE_EXECUTION_TOOL_NAME { + let started_at = Instant::now(); + let result = + execute_code_execution_tool(&tool_input, &self.session.workspace).await; + + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id.clone(), + name: tool_name.clone(), + result: result.clone(), + }) + .await; + + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: tool_id, + name: tool_name, + input: tool_input, + started_at, + result, + }); + continue; + } + + if is_tool_search_tool(&tool_name) { + let started_at = Instant::now(); + let result = execute_tool_search( + &tool_name, + &tool_input, + &tool_catalog, + &mut active_tool_names, + ); + + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id.clone(), + name: tool_name.clone(), + result: result.clone(), + }) + .await; + + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: tool_id, + name: tool_name, + input: tool_input, + started_at, + result, + }); + continue; + } + if tool_name == REQUEST_USER_INPUT_NAME { let started_at = Instant::now(); let result = match UserInputRequest::from_value(&tool_input) { @@ -2492,6 +2937,7 @@ impl Engine { "tool_id": tool_id.clone(), "tool_name": tool_name.clone(), "decision": "approved", + "caller": caller_type_for_tool_use(tool_caller.as_ref()), })); (None, None) } @@ -2501,6 +2947,7 @@ impl Engine { "tool_id": tool_id.clone(), "tool_name": tool_name.clone(), "decision": "denied", + "caller": caller_type_for_tool_use(tool_caller.as_ref()), })); ( Some(Err(ToolError::permission_denied(format!( @@ -2516,6 +2963,7 @@ impl Engine { "tool_name": tool_name.clone(), "decision": "retry_with_policy", "policy": format!("{policy:?}"), + "caller": caller_type_for_tool_use(tool_caller.as_ref()), })); let elevated_context = tool_registry.map(|r| { r.context().clone().with_elevated_sandbox_policy(policy) @@ -2599,6 +3047,8 @@ impl Engine { content: vec![ContentBlock::ToolResult { tool_use_id: outcome.id, content: output_for_context, + is_error: None, + content_blocks: None, }], }); } @@ -2624,6 +3074,8 @@ impl Engine { content: vec![ContentBlock::ToolResult { tool_use_id: outcome.id, content: format!("Error: {error}"), + is_error: Some(true), + content_blocks: None, }], }); } @@ -2888,7 +3340,10 @@ impl Engine { } } } - ContentBlock::Thinking { .. } => {} + ContentBlock::Thinking { .. } + | ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } } @@ -3136,6 +3591,8 @@ impl Engine { content: vec![ContentBlock::ToolResult { tool_use_id: candidate.id.clone(), content: verification_note.clone(), + is_error: None, + content_blocks: None, }], }); diff --git a/src/core/engine/tests.rs b/src/core/engine/tests.rs index 668138c8..e0f9612c 100644 --- a/src/core/engine/tests.rs +++ b/src/core/engine/tests.rs @@ -24,11 +24,13 @@ fn make_plan( id: "tool-1".to_string(), name: "grep_files".to_string(), input: json!({"pattern": "test"}), + caller: None, interactive, approval_required, approval_description: "desc".to_string(), supports_parallel, read_only, + blocked_error: None, } } @@ -311,3 +313,79 @@ async fn controller_disabled_keeps_behavior_unchanged() { assert_eq!(before, after); assert_eq!(before_len, after_len); } + +#[test] +fn caller_policy_defaults_to_direct() { + let tool = Tool { + tool_type: None, + name: "read_file".to_string(), + description: "Read".to_string(), + input_schema: json!({"type":"object"}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }; + let direct = ToolCaller { + caller_type: "direct".to_string(), + tool_id: None, + }; + let code = ToolCaller { + caller_type: "code_execution_20250825".to_string(), + tool_id: Some("srvtoolu_1".to_string()), + }; + assert!(caller_allowed_for_tool(Some(&direct), Some(&tool))); + assert!(!caller_allowed_for_tool(Some(&code), Some(&tool))); + assert!(caller_allowed_for_tool(None, Some(&tool))); +} + +#[test] +fn tool_search_activates_discovered_deferred_tools() { + let mut catalog = vec![ + Tool { + tool_type: None, + name: "read_file".to_string(), + description: "Read files".to_string(), + input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(true), + input_examples: None, + strict: None, + cache_control: None, + }, + Tool { + tool_type: None, + name: "grep_files".to_string(), + description: "Search files".to_string(), + input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(true), + input_examples: None, + strict: None, + cache_control: None, + }, + ]; + ensure_advanced_tooling(&mut catalog); + let mut active = initial_active_tools(&catalog); + let result = execute_tool_search( + TOOL_SEARCH_BM25_NAME, + &json!({"query":"read file"}), + &catalog, + &mut active, + ) + .expect("search succeeds"); + assert!(result.success); + assert!(active.contains("read_file")); +} + +#[tokio::test] +async fn code_execution_runs_python_and_returns_result_payload() { + let tmp = tempdir().expect("tempdir"); + let result = + execute_code_execution_tool(&json!({"code":"print('hello from code exec')"}), tmp.path()) + .await + .expect("code execution should run"); + assert!(result.content.contains("hello from code exec")); + assert!(result.content.contains("return_code")); +} diff --git a/src/core/turn.rs b/src/core/turn.rs index cbf5a1a7..6fdea8a4 100644 --- a/src/core/turn.rs +++ b/src/core/turn.rs @@ -55,6 +55,7 @@ impl TurnContext { usage: Usage { input_tokens: 0, output_tokens: 0, + server_tool_use: None, }, } } diff --git a/src/mcp.rs b/src/mcp.rs index 09e1bec7..52598395 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1077,15 +1077,21 @@ impl McpPool { // Add regular tools for (name, tool) in self.all_tools() { api_tools.push(crate::models::Tool { + tool_type: None, name, description: tool.description.clone().unwrap_or_default(), input_schema: tool.input_schema.clone(), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, cache_control: None, }); } if !self.config.servers.is_empty() { api_tools.push(crate::models::Tool { + tool_type: None, name: "list_mcp_resources".to_string(), description: "List available MCP resources across servers (optionally filtered by server).".to_string(), input_schema: serde_json::json!({ @@ -1094,9 +1100,14 @@ impl McpPool { "server": { "type": "string", "description": "Optional MCP server name to filter by" } } }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, cache_control: None, }); api_tools.push(crate::models::Tool { + tool_type: None, name: "list_mcp_resource_templates".to_string(), description: "List available MCP resource templates across servers (optionally filtered by server).".to_string(), input_schema: serde_json::json!({ @@ -1105,6 +1116,10 @@ impl McpPool { "server": { "type": "string", "description": "Optional MCP server name to filter by" } } }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, cache_control: None, }); } @@ -1113,6 +1128,7 @@ impl McpPool { let resources = self.all_resources(); if !resources.is_empty() { api_tools.push(crate::models::Tool { + tool_type: None, name: "mcp_read_resource".to_string(), description: "Read a resource from an MCP server using its URI".to_string(), input_schema: serde_json::json!({ @@ -1123,9 +1139,14 @@ impl McpPool { }, "required": ["server", "uri"] }), - cache_control: None, - }); + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); api_tools.push(crate::models::Tool { + tool_type: None, name: "read_mcp_resource".to_string(), description: "Alias for mcp_read_resource.".to_string(), input_schema: serde_json::json!({ @@ -1136,6 +1157,10 @@ impl McpPool { }, "required": ["server", "uri"] }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, cache_control: None, }); } @@ -1144,6 +1169,7 @@ impl McpPool { let prompts = self.all_prompts(); if !prompts.is_empty() { api_tools.push(crate::models::Tool { + tool_type: None, name: "mcp_get_prompt".to_string(), description: "Get a prompt from an MCP server".to_string(), input_schema: serde_json::json!({ @@ -1159,6 +1185,10 @@ impl McpPool { }, "required": ["server", "name"] }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, cache_control: None, }); } diff --git a/src/models.rs b/src/models.rs index a6c1843c..5787c2fd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -77,11 +77,33 @@ pub enum ContentBlock { id: String, name: String, input: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + caller: Option, }, #[serde(rename = "tool_result")] ToolResult { tool_use_id: String, content: String, + #[serde(skip_serializing_if = "Option::is_none")] + is_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_blocks: Option>, + }, + #[serde(rename = "server_tool_use")] + ServerToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + #[serde(rename = "tool_search_tool_result")] + ToolSearchToolResult { + tool_use_id: String, + content: serde_json::Value, + }, + #[serde(rename = "code_execution_tool_result")] + CodeExecutionToolResult { + tool_use_id: String, + content: serde_json::Value, }, } @@ -92,16 +114,52 @@ pub struct CacheControl { pub cache_type: String, } +/// Metadata describing who invoked a tool call. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ToolCaller { + #[serde(rename = "type")] + pub caller_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_id: Option, +} + /// Tool definition exposed to the model. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Tool { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub tool_type: Option, pub name: String, pub description: String, pub input_schema: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_callers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub defer_loading: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_examples: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub strict: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub cache_control: Option, } +/// Container metadata for code-execution style server tools. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContainerInfo { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +/// Server-side tool usage counters. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ServerToolUsage { + #[serde(skip_serializing_if = "Option::is_none")] + pub code_execution_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_search_requests: Option, +} + /// Response payload for a message request. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MessageResponse { @@ -112,6 +170,8 @@ pub struct MessageResponse { pub model: String, pub stop_reason: Option, pub stop_sequence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, pub usage: Usage, } @@ -120,6 +180,8 @@ pub struct MessageResponse { pub struct Usage { pub input_tokens: u32, pub output_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_tool_use: Option, } /// Map known models to their approximate context window sizes. @@ -216,6 +278,14 @@ pub enum ContentBlockStart { id: String, name: String, input: serde_json::Value, // usually empty or partial + #[serde(skip_serializing_if = "Option::is_none")] + caller: Option, + }, + #[serde(rename = "server_tool_use")] + ServerToolUse { + id: String, + name: String, + input: serde_json::Value, }, } diff --git a/src/modules/text.rs b/src/modules/text.rs index 2b733fd1..af767a85 100644 --- a/src/modules/text.rs +++ b/src/modules/text.rs @@ -282,6 +282,7 @@ async fn process_deepseek_turn( id, name, input: parsed, + caller: None, }); } diff --git a/src/runtime_api.rs b/src/runtime_api.rs index e9aee111..0f11ec5f 100644 --- a/src/runtime_api.rs +++ b/src/runtime_api.rs @@ -1290,6 +1290,7 @@ mod tests { usage: Usage { input_tokens: 2, output_tokens: 1, + server_tool_use: None, }, status: TurnOutcomeStatus::Completed, error: None, diff --git a/src/runtime_threads.rs b/src/runtime_threads.rs index e26bf3ed..ff18b84d 100644 --- a/src/runtime_threads.rs +++ b/src/runtime_threads.rs @@ -2060,6 +2060,7 @@ mod tests { usage: Usage { input_tokens: 10, output_tokens: 12, + server_tool_use: None, }, status: TurnOutcomeStatus::Completed, error: None, @@ -2148,6 +2149,7 @@ mod tests { usage: Usage { input_tokens: 5, output_tokens: 5, + server_tool_use: None, }, status: TurnOutcomeStatus::Completed, error: None, @@ -2357,6 +2359,7 @@ mod tests { usage: Usage { input_tokens: 8, output_tokens: 9, + server_tool_use: None, }, status: TurnOutcomeStatus::Completed, error: None, @@ -2462,6 +2465,7 @@ mod tests { usage: Usage { input_tokens: 3, output_tokens: 3, + server_tool_use: None, }, status: TurnOutcomeStatus::Completed, error: None, @@ -2489,6 +2493,7 @@ mod tests { usage: Usage { input_tokens: 1, output_tokens: 1, + server_tool_use: None, }, status: TurnOutcomeStatus::Completed, error: None, diff --git a/src/settings.rs b/src/settings.rs index f14a86ab..7863820c 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -25,6 +25,8 @@ pub struct Settings { pub default_mode: String, /// Sidebar width as percentage of terminal width pub sidebar_width_percent: u16, + /// Sidebar focus mode: auto, plan, todos, tasks, agents + pub sidebar_focus: String, /// Maximum number of input history entries to save pub max_input_history: usize, /// Default model to use @@ -40,6 +42,7 @@ impl Default for Settings { show_tool_details: true, default_mode: "agent".to_string(), sidebar_width_percent: 28, + sidebar_focus: "auto".to_string(), max_input_history: 100, default_model: None, } @@ -67,6 +70,7 @@ impl Settings { let mut settings: Settings = toml::from_str(&content) .with_context(|| format!("Failed to parse settings from {}", path.display()))?; settings.default_mode = normalize_mode(&settings.default_mode).to_string(); + settings.sidebar_focus = normalize_sidebar_focus(&settings.sidebar_focus).to_string(); settings.default_model = settings .default_model .as_deref() @@ -136,6 +140,21 @@ impl Settings { } self.sidebar_width_percent = width; } + "sidebar_focus" | "focus" => { + let normalized = match value.trim().to_ascii_lowercase().as_str() { + "auto" => "auto", + "plan" => "plan", + "todos" => "todos", + "tasks" => "tasks", + "agents" | "subagents" | "sub-agents" => "agents", + _ => { + anyhow::bail!( + "Failed to update setting: invalid sidebar focus '{value}'. Expected: auto, plan, todos, tasks, agents." + ) + } + }; + self.sidebar_focus = normalized.to_string(); + } "max_history" | "history" => { let max: usize = value.parse().map_err(|_| { anyhow::anyhow!( @@ -173,6 +192,7 @@ impl Settings { " sidebar_width: {}%", self.sidebar_width_percent )); + lines.push(format!(" sidebar_focus: {}", self.sidebar_focus)); lines.push(format!(" max_history: {}", self.max_input_history)); lines.push(format!( " default_model: {}", @@ -195,6 +215,10 @@ impl Settings { ("show_tool_details", "Show detailed tool output: on/off"), ("default_mode", "Default mode: agent, plan, yolo"), ("sidebar_width", "Sidebar width percentage: 10-50"), + ( + "sidebar_focus", + "Sidebar focus: auto, plan, todos, tasks, agents", + ), ("max_history", "Max input history entries"), ( "default_model", @@ -221,3 +245,13 @@ fn normalize_mode(value: &str) -> &str { _ => value, } } + +fn normalize_sidebar_focus(value: &str) -> &str { + match value.trim().to_ascii_lowercase().as_str() { + "plan" => "plan", + "todos" => "todos", + "tasks" => "tasks", + "agents" | "subagents" | "sub-agents" => "agents", + _ => "auto", + } +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 68514574..bd983a62 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -143,9 +143,14 @@ impl ToolRegistry { self.tools .values() .map(|tool| Tool { + tool_type: None, name: tool.name().to_string(), description: tool.description().to_string(), input_schema: tool.input_schema(), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, cache_control: None, }) .collect() diff --git a/src/tools/subagent.rs b/src/tools/subagent.rs index bcf7577e..55054de2 100644 --- a/src/tools/subagent.rs +++ b/src/tools/subagent.rs @@ -1093,7 +1093,9 @@ async fn run_subagent( final_result = Some(text.clone()); } } - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { tool_uses.push((id.clone(), name.clone(), input.clone())); } _ => {} @@ -1133,6 +1135,8 @@ async fn run_subagent( tool_results.push(ContentBlock::ToolResult { tool_use_id: tool_id, content: result, + is_error: None, + content_blocks: None, }); } diff --git a/src/tui/app.rs b/src/tui/app.rs index 6b1b52ef..d65270cf 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -67,6 +67,74 @@ pub enum AppMode { Plan, } +/// Sidebar content focus mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarFocus { + Auto, + Plan, + Todos, + Tasks, + Agents, +} + +impl SidebarFocus { + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "plan" => Self::Plan, + "todos" => Self::Todos, + "tasks" => Self::Tasks, + "agents" | "subagents" | "sub-agents" => Self::Agents, + _ => Self::Auto, + } + } + + #[must_use] + pub fn as_setting(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Plan => "plan", + Self::Todos => "todos", + Self::Tasks => "tasks", + Self::Agents => "agents", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusToastLevel { + Info, + Success, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct StatusToast { + pub text: String, + pub level: StatusToastLevel, + pub created_at: Instant, + pub ttl_ms: Option, +} + +impl StatusToast { + #[must_use] + pub fn new(text: impl Into, level: StatusToastLevel, ttl_ms: Option) -> Self { + Self { + text: text.into(), + level, + created_at: Instant::now(), + ttl_ms, + } + } + + #[must_use] + pub fn is_expired(&self, now: Instant) -> bool { + self.ttl_ms + .is_some_and(|ttl| now.duration_since(self.created_at).as_millis() >= u128::from(ttl)) + } +} + fn char_count(text: &str) -> usize { text.chars().count() } @@ -183,7 +251,14 @@ pub struct App { pub is_loading: bool, /// Degraded connectivity mode; new user inputs are queued for later retry. pub offline_mode: bool, + /// Legacy status text sink retained for compatibility with existing call sites. pub status_message: Option, + /// Recent status toasts (ephemeral, newest at back). + pub status_toasts: VecDeque, + /// Sticky status toast used for important warnings/errors. + pub sticky_status: Option, + /// Last status text already promoted from `status_message` into toast state. + pub last_status_message_seen: Option, pub model: String, pub workspace: PathBuf, pub skills_dir: PathBuf, @@ -196,6 +271,11 @@ pub struct App { pub show_thinking: bool, pub show_tool_details: bool, pub sidebar_width_percent: u16, + pub sidebar_focus: SidebarFocus, + /// Slash menu selection index in composer. + pub slash_menu_selected: usize, + /// Temporary hide flag for slash menu until next input edit. + pub slash_menu_hidden: bool, #[allow(dead_code)] pub compact_threshold: usize, pub max_input_history: usize, @@ -374,6 +454,7 @@ impl App { let show_thinking = settings.show_thinking; let show_tool_details = settings.show_tool_details; let sidebar_width_percent = settings.sidebar_width_percent; + let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus); let max_input_history = settings.max_input_history; let ui_theme = palette::ui_theme(&settings.theme); let model = settings.default_model.clone().unwrap_or_else(|| model); @@ -442,6 +523,9 @@ impl App { is_loading: false, offline_mode: false, status_message: None, + status_toasts: VecDeque::new(), + sticky_status: None, + last_status_message_seen: None, model, workspace, skills_dir, @@ -453,6 +537,9 @@ impl App { show_thinking, show_tool_details, sidebar_width_percent, + sidebar_focus, + slash_menu_selected: 0, + slash_menu_hidden: false, compact_threshold, max_input_history, total_tokens: 0, @@ -610,6 +697,137 @@ impl App { self.needs_redraw = true; } + pub fn push_status_toast( + &mut self, + text: impl Into, + level: StatusToastLevel, + ttl_ms: Option, + ) { + let toast = StatusToast::new(text, level, ttl_ms); + self.status_toasts.push_back(toast); + while self.status_toasts.len() > 24 { + self.status_toasts.pop_front(); + } + self.needs_redraw = true; + } + + pub fn set_sticky_status( + &mut self, + text: impl Into, + level: StatusToastLevel, + ttl_ms: Option, + ) { + self.sticky_status = Some(StatusToast::new(text, level, ttl_ms)); + self.needs_redraw = true; + } + + pub fn clear_sticky_status(&mut self) { + self.sticky_status = None; + } + + pub fn set_sidebar_focus(&mut self, focus: SidebarFocus) { + self.sidebar_focus = focus; + self.needs_redraw = true; + } + + pub fn close_slash_menu(&mut self) { + self.slash_menu_hidden = true; + self.needs_redraw = true; + } + + fn classify_status_text(text: &str) -> (StatusToastLevel, Option, bool) { + let lower = text.to_ascii_lowercase(); + let has = |needle: &str| lower.contains(needle); + + if has("offline mode") || has("context critical") { + return (StatusToastLevel::Warning, None, true); + } + if has("error") + || has("failed") + || has("denied") + || has("timeout") + || has("aborted") + || has("critical") + { + return (StatusToastLevel::Error, Some(15_000), true); + } + if has("saved") + || has("loaded") + || has("queued") + || has("found") + || has("enabled") + || has("completed") + { + return (StatusToastLevel::Success, Some(5_000), false); + } + if has("cancelled") || has("warning") { + return (StatusToastLevel::Warning, Some(5_000), false); + } + (StatusToastLevel::Info, Some(4_000), false) + } + + pub fn sync_status_message_to_toasts(&mut self) { + let current = self.status_message.clone(); + if self.last_status_message_seen == current { + return; + } + self.last_status_message_seen = current.clone(); + + let Some(message) = current else { + return; + }; + if message.trim().is_empty() { + return; + } + + let (level, ttl_ms, sticky) = Self::classify_status_text(&message); + if sticky { + self.set_sticky_status(message, level, ttl_ms); + } else { + if matches!(level, StatusToastLevel::Success) + && self + .sticky_status + .as_ref() + .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error)) + { + self.clear_sticky_status(); + } + self.push_status_toast(message, level, ttl_ms); + } + } + + pub fn active_status_toast(&mut self) -> Option { + self.sync_status_message_to_toasts(); + let now = Instant::now(); + let mut removed = false; + + while self + .status_toasts + .front() + .is_some_and(|toast| toast.is_expired(now)) + { + self.status_toasts.pop_front(); + removed = true; + } + + if self + .sticky_status + .as_ref() + .is_some_and(|toast| toast.is_expired(now)) + { + self.sticky_status = None; + removed = true; + } + + if removed { + self.needs_redraw = true; + } + + self.sticky_status + .clone() + .or_else(|| self.status_toasts.back().cloned()) + } + pub fn transcript_render_options(&self) -> TranscriptRenderOptions { TranscriptRenderOptions { show_thinking: self.show_thinking, @@ -658,6 +876,7 @@ impl App { let byte_index = byte_index_at_char(&self.input, cursor); self.input.insert_str(byte_index, text); self.cursor_position = cursor + char_count(text); + self.slash_menu_hidden = false; self.needs_redraw = true; } @@ -761,6 +980,7 @@ impl App { let byte_index = byte_index_at_char(&self.input, cursor); self.input.insert(byte_index, c); self.cursor_position = cursor + 1; + self.slash_menu_hidden = false; self.needs_redraw = true; } @@ -772,6 +992,7 @@ impl App { let removed = remove_char_at(&mut self.input, target); if removed { self.cursor_position = target; + self.slash_menu_hidden = false; self.needs_redraw = true; } } @@ -785,6 +1006,7 @@ impl App { if !removed { self.cursor_position = char_count(&self.input); } + self.slash_menu_hidden = false; self.needs_redraw = true; } @@ -813,6 +1035,8 @@ impl App { pub fn clear_input(&mut self) { self.input.clear(); self.cursor_position = 0; + self.slash_menu_selected = 0; + self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); self.needs_redraw = true; } @@ -889,6 +1113,7 @@ impl App { self.history_index = Some(new_index); self.input = self.input_history[new_index].clone(); self.cursor_position = char_count(&self.input); + self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); } @@ -903,6 +1128,7 @@ impl App { self.history_index = Some(i + 1); self.input = self.input_history[i + 1].clone(); self.cursor_position = char_count(&self.input); + self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); } else { self.history_index = None; diff --git a/src/tui/onboarding/api_key.rs b/src/tui/onboarding/api_key.rs index df89b49f..8ac5cfe7 100644 --- a/src/tui/onboarding/api_key.rs +++ b/src/tui/onboarding/api_key.rs @@ -23,6 +23,10 @@ pub fn lines(app: &App) -> Vec> { "Get your key at https://platform.deepseek.com", Style::default().fg(palette::DEEPSEEK_SKY), )), + Line::from(Span::styled( + "Paste the full key exactly as issued (no spaces/newlines).", + Style::default().fg(palette::TEXT_MUTED), + )), Line::from(""), ]; diff --git a/src/tui/onboarding/mod.rs b/src/tui/onboarding/mod.rs index 8f55247b..99fa5ecb 100644 --- a/src/tui/onboarding/mod.rs +++ b/src/tui/onboarding/mod.rs @@ -9,7 +9,8 @@ use std::path::{Path, PathBuf}; use ratatui::{ Frame, layout::Rect, - style::Style, + style::{Modifier, Style}, + text::{Line, Span}, widgets::{Block, Paragraph, Wrap}, }; @@ -38,11 +39,49 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; if !lines.is_empty() { - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let (step, total) = onboarding_step(app); + let mut decorated = vec![ + Line::from(Span::styled( + format!("Step {step}/{total}"), + Style::default() + .fg(palette::TEXT_MUTED) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + ]; + decorated.extend(lines); + let paragraph = Paragraph::new(decorated).wrap(Wrap { trim: false }); f.render_widget(paragraph, content_area); } } +fn onboarding_step(app: &App) -> (usize, usize) { + let needs_trust = !app.trust_mode && needs_trust(&app.workspace); + let mut total = 2; // Welcome + Tips + if app.onboarding_needs_api_key { + total += 1; + } + if needs_trust { + total += 1; + } + + let step = match app.onboarding { + OnboardingState::Welcome => 1, + OnboardingState::ApiKey => 2, + OnboardingState::TrustDirectory => { + if app.onboarding_needs_api_key { + 3 + } else { + 2 + } + } + OnboardingState::Tips => total, + OnboardingState::None => total, + }; + + (step, total) +} + pub fn tips_lines() -> Vec> { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; @@ -60,6 +99,9 @@ pub fn tips_lines() -> Vec> { Line::from(Span::raw(" - l opens the pager for the last message")), Line::from(Span::raw(" - Ctrl+C cancels or exits")), Line::from(Span::raw(" - /help lists all commands")), + Line::from(Span::raw( + " - Start with /config or /model for a quick check", + )), Line::from(""), Line::from(vec![ Span::styled("Press ", Style::default().fg(palette::TEXT_MUTED)), diff --git a/src/tui/onboarding/trust_directory.rs b/src/tui/onboarding/trust_directory.rs index 744b942d..123e59a1 100644 --- a/src/tui/onboarding/trust_directory.rs +++ b/src/tui/onboarding/trust_directory.rs @@ -25,7 +25,11 @@ pub fn lines(app: &App) -> Vec> { ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "This enables broader file access for tools and agents.", + "Y = allow tools/agents to read outside this workspace when needed.", + Style::default().fg(palette::TEXT_MUTED), + ))); + lines.push(Line::from(Span::styled( + "N = keep access scoped to this workspace only (safer default).", Style::default().fg(palette::TEXT_MUTED), ))); if let Some(message) = app.status_message.as_deref() { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c7cd3008..bdb21350 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -64,8 +64,8 @@ use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text}; use crate::tui::user_input::UserInputView; use super::app::{ - App, AppAction, AppMode, OnboardingState, QueuedMessage, TaskPanelEntry, ToolDetailRecord, - TuiOptions, + App, AppAction, AppMode, OnboardingState, QueuedMessage, SidebarFocus, StatusToastLevel, + TaskPanelEntry, ToolDetailRecord, TuiOptions, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, @@ -77,7 +77,9 @@ use super::history::{ summarize_tool_args, summarize_tool_output, }; use super::views::{HelpView, ModalKind, ViewEvent}; -use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable}; +use super::widgets::{ + ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable, slash_completion_hints, +}; // === Constants === @@ -375,7 +377,12 @@ async fn run_event_loop( }); } for (id, name, input) in app.pending_tool_uses.drain(..) { - blocks.push(ContentBlock::ToolUse { id, name, input }); + blocks.push(ContentBlock::ToolUse { + id, + name, + input, + caller: None, + }); } // DeepSeek rejects assistant messages that contain only reasoning blocks. @@ -460,6 +467,8 @@ async fn run_event_loop( content: vec![ContentBlock::ToolResult { tool_use_id: id.clone(), content: tool_content, + is_error: None, + content_blocks: None, }], }); handle_tool_call_complete(app, &id, &name, &result); @@ -808,6 +817,7 @@ async fn run_event_loop( let now = Instant::now(); app.flush_paste_burst_if_due(now); + app.sync_status_message_to_toasts(); if app.needs_redraw { terminal.draw(|f| render(f, app))?; // app is &mut @@ -892,6 +902,10 @@ async fn run_event_loop( } OnboardingState::ApiKey => { let key = app.api_key_input.trim().to_string(); + if let Some(warning) = validate_api_key_for_onboarding(&key) { + app.status_message = Some(warning); + continue; + } match app.submit_api_key() { Ok(_) => { // Recreate the engine so it picks up the newly saved key @@ -1016,6 +1030,12 @@ async fn run_event_loop( continue; } + let slash_menu_entries = visible_slash_menu_entries(app, 6); + let slash_menu_open = !slash_menu_entries.is_empty(); + if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() { + app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1); + } + // Global keybindings match key.code { KeyCode::Enter if app.input.is_empty() && app.transcript_selection.is_active() => { @@ -1033,6 +1053,31 @@ async fn run_event_loop( continue; } } + KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Plan); + app.status_message = Some("Sidebar focus: plan".to_string()); + continue; + } + KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Todos); + app.status_message = Some("Sidebar focus: todos".to_string()); + continue; + } + KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Tasks); + app.status_message = Some("Sidebar focus: tasks".to_string()); + continue; + } + KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Agents); + app.status_message = Some("Sidebar focus: agents".to_string()); + continue; + } + KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Auto); + app.status_message = Some("Sidebar focus: auto".to_string()); + continue; + } KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.view_stack.push(SessionPickerView::new()); continue; @@ -1064,7 +1109,9 @@ async fn run_event_loop( } } KeyCode::Esc => { - if app.is_loading { + if slash_menu_open { + app.close_slash_menu(); + } else if app.is_loading { engine_handle.cancel(); app.is_loading = false; app.status_message = Some("Request cancelled".to_string()); @@ -1077,9 +1124,18 @@ async fn run_event_loop( KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_up(3); } + KeyCode::Up if key.modifiers.is_empty() && slash_menu_open => { + if app.slash_menu_selected > 0 { + app.slash_menu_selected = app.slash_menu_selected.saturating_sub(1); + } + } KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_down(3); } + KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => { + app.slash_menu_selected = (app.slash_menu_selected + 1) + .min(slash_menu_entries.len().saturating_sub(1)); + } KeyCode::PageUp => { let page = app.last_transcript_visible.max(1); app.scroll_up(page); @@ -1089,11 +1145,45 @@ async fn run_event_loop( app.scroll_down(page); } KeyCode::Tab => { + if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true) + { + continue; + } if try_autocomplete_slash_command(app) { continue; } app.cycle_mode(); } + KeyCode::Char('g') + if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => + { + if let Some(anchor) = + TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), 0) + { + app.transcript_scroll = anchor; + } + } + KeyCode::Char('G') + if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + && app.input.is_empty() + && !slash_menu_open => + { + app.scroll_to_bottom(); + } + KeyCode::Char('[') + if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => + { + if !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) { + app.status_message = Some("No previous tool output".to_string()); + } + } + KeyCode::Char(']') + if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => + { + if !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) { + app.status_message = Some("No next tool output".to_string()); + } + } // Input handling KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.insert_char('\n'); @@ -1465,6 +1555,36 @@ fn in_command_context(app: &App) -> bool { app.input.starts_with('/') } +fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec { + if app.slash_menu_hidden { + return Vec::new(); + } + slash_completion_hints(&app.input, limit) +} + +fn apply_slash_menu_selection(app: &mut App, entries: &[String], append_space: bool) -> bool { + if entries.is_empty() { + return false; + } + + let selected_idx = app.slash_menu_selected.min(entries.len().saturating_sub(1)); + let mut command = entries[selected_idx].clone(); + + if append_space && !command.ends_with(' ') && !command.contains(char::is_whitespace) { + if let Some(info) = commands::get_command_info(command.trim_start_matches('/')) + && (info.usage.contains('<') || info.usage.contains('[')) + { + command.push(' '); + } + } + + app.input = command; + app.cursor_position = app.input.chars().count(); + app.slash_menu_hidden = false; + app.status_message = Some(format!("Command selected: {}", app.input.trim_end())); + true +} + fn try_autocomplete_slash_command(app: &mut App) -> bool { if !app.input.starts_with('/') || app.input.contains(char::is_whitespace) { return false; @@ -1482,6 +1602,7 @@ fn try_autocomplete_slash_command(app: &mut App) -> bool { if !shared.is_empty() && shared.len() > prefix.len() { app.input = format!("/{shared}"); app.cursor_position = app.input.chars().count(); + app.slash_menu_hidden = false; app.status_message = Some(format!("Autocomplete: /{shared}")); return true; } @@ -1490,6 +1611,7 @@ fn try_autocomplete_slash_command(app: &mut App) -> bool { let completed = format!("/{} ", matches[0].name); app.input = completed.clone(); app.cursor_position = completed.chars().count(); + app.slash_menu_hidden = false; app.status_message = Some(format!("Command completed: {}", completed.trim_end())); return true; } @@ -1636,6 +1758,25 @@ fn sanitize_stream_chunk(chunk: &str) -> String { .collect() } +fn validate_api_key_for_onboarding(api_key: &str) -> Option { + let trimmed = api_key.trim(); + if trimmed.is_empty() { + return Some("API key cannot be empty.".to_string()); + } + if trimmed.contains(char::is_whitespace) { + return Some("API key appears malformed (contains whitespace).".to_string()); + } + if trimmed.len() < 16 { + return Some("API key appears too short. Please paste the full key.".to_string()); + } + if !trimmed.contains('-') { + return Some( + "API key format looks unusual. Check that the full key was copied.".to_string(), + ); + } + None +} + fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { let skill_instruction = app.active_skill.take(); QueuedMessage::new(input, skill_instruction) @@ -1927,7 +2068,7 @@ fn render(f: &mut Frame, app: &mut App) { let mut chat_area = chunks[1]; let mut sidebar_area = None; - if chunks[1].width >= 90 { + if chunks[1].width >= 100 { let preferred_sidebar = (u32::from(chunks[1].width) * u32::from(app.sidebar_width_percent.clamp(10, 50)) / 100) as u16; @@ -1983,20 +2124,28 @@ fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { return; } - let sections = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Min(6), - ]) - .split(area); + match app.sidebar_focus { + SidebarFocus::Auto => { + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Min(6), + ]) + .split(area); - render_sidebar_plan(f, sections[0], app); - render_sidebar_todos(f, sections[1], app); - render_sidebar_tasks(f, sections[2], app); - render_sidebar_subagents(f, sections[3], app); + render_sidebar_plan(f, sections[0], app); + render_sidebar_todos(f, sections[1], app); + render_sidebar_tasks(f, sections[2], app); + render_sidebar_subagents(f, sections[3], app); + } + SidebarFocus::Plan => render_sidebar_plan(f, area, app), + SidebarFocus::Todos => render_sidebar_todos(f, area, app), + SidebarFocus::Tasks => render_sidebar_tasks(f, area, app), + SidebarFocus::Agents => render_sidebar_subagents(f, area, app), + } } fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { @@ -2626,7 +2775,16 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin f.render_widget(paragraph, area); } -fn render_footer(f: &mut Frame, area: Rect, app: &App) { +fn status_color(level: StatusToastLevel) -> ratatui::style::Color { + match level { + StatusToastLevel::Info => palette::DEEPSEEK_SKY, + StatusToastLevel::Success => palette::STATUS_SUCCESS, + StatusToastLevel::Warning => palette::STATUS_WARNING, + StatusToastLevel::Error => palette::STATUS_ERROR, + } +} + +fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { let width = area.width; let available_width = width as usize; @@ -2690,16 +2848,17 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum(); - // 3. Left side content (Status toast or standard footer) - let left_spans = if let Some(msg) = app.status_message.as_ref() { + // 3. Left side content (status toast or standard footer) + let active_status = app.active_status_toast(); + let left_spans = if let Some(toast) = active_status { let max_left = available_width .saturating_sub(right_width) .saturating_sub(1) .max(1); - let truncated = truncate_line_to_width(msg, max_left); + let truncated = truncate_line_to_width(&toast.text, max_left); vec![Span::styled( truncated, - Style::default().fg(palette::DEEPSEEK_SKY), + Style::default().fg(status_color(toast.level)), )] } else { // Compact footer: session + token cost + help hint @@ -2740,12 +2899,12 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { all_spans.extend(right_spans); } else { // Fallback for narrow screens - let simple_left = if let Some(msg) = app.status_message.as_ref() { + let simple_left = if let Some(toast) = app.active_status_toast() { let max_left = available_width.saturating_sub(10).saturating_sub(1).max(1); - let truncated = truncate_line_to_width(msg, max_left); + let truncated = truncate_line_to_width(&toast.text, max_left); vec![Span::styled( truncated, - Style::default().fg(palette::DEEPSEEK_SKY), + Style::default().fg(status_color(toast.level)), )] } else { vec![Span::styled( @@ -2834,6 +2993,57 @@ fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option Some(percent.min(100)) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchDirection { + Forward, + Backward, +} + +fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool { + let line_meta = app.transcript_cache.line_meta(); + if line_meta.is_empty() { + return false; + } + + let top = app + .last_transcript_top + .min(line_meta.len().saturating_sub(1)); + let current_cell = line_meta + .get(top) + .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line) + .map(|(cell_index, _)| cell_index); + + let mut scan_indices = Vec::new(); + match direction { + SearchDirection::Forward => { + scan_indices.extend((top.saturating_add(1))..line_meta.len()); + } + SearchDirection::Backward => { + scan_indices.extend((0..top).rev()); + } + } + + for idx in scan_indices { + let Some((cell_index, _)) = line_meta[idx].cell_line() else { + continue; + }; + if current_cell.is_some_and(|current| current == cell_index) { + continue; + } + if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) { + continue; + } + if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) { + app.transcript_scroll = anchor; + app.pending_scroll_delta = 0; + app.needs_redraw = true; + return true; + } + } + + false +} + fn prompt_for_mode(mode: AppMode) -> &'static str { match mode { AppMode::Normal => "> ", diff --git a/src/tui/ui/tests.rs b/src/tui/ui/tests.rs index 3425ae19..a46997ab 100644 --- a/src/tui/ui/tests.rs +++ b/src/tui/ui/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::Config; +use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus}; use std::path::PathBuf; #[test] @@ -279,3 +280,75 @@ fn test_esc_priority_order_loading_then_input_then_mode() { assert_eq!(app.mode, AppMode::Yolo); // Should change to Normal mode } + +#[test] +fn visible_slash_menu_entries_respects_hide_flag() { + let mut app = create_test_app(); + app.input = "/mo".to_string(); + app.slash_menu_hidden = false; + + let entries = visible_slash_menu_entries(&app, 6); + assert!(!entries.is_empty()); + + app.slash_menu_hidden = true; + let hidden_entries = visible_slash_menu_entries(&app, 6); + assert!(hidden_entries.is_empty()); +} + +#[test] +fn apply_slash_menu_selection_appends_space_for_arg_commands() { + let mut app = create_test_app(); + let entries = vec!["/set".to_string(), "/settings".to_string()]; + app.slash_menu_selected = 0; + assert!(apply_slash_menu_selection(&mut app, &entries, true)); + assert_eq!(app.input, "/set "); +} + +#[test] +fn jump_to_adjacent_tool_cell_finds_next_and_previous() { + let mut app = create_test_app(); + app.history = vec![ + HistoryCell::User { + content: "hello".to_string(), + }, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "file_search".to_string(), + status: ToolStatus::Success, + input_summary: Some("query: foo".to_string()), + output: Some("done".to_string()), + })), + HistoryCell::Assistant { + content: "ok".to_string(), + streaming: false, + }, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "run_command".to_string(), + status: ToolStatus::Success, + input_summary: Some("ls".to_string()), + output: Some("...".to_string()), + })), + ]; + app.mark_history_updated(); + app.transcript_cache.ensure( + &app.history, + 100, + app.history_version, + app.transcript_render_options(), + ); + + app.last_transcript_top = 0; + assert!(jump_to_adjacent_tool_cell( + &mut app, + SearchDirection::Forward + )); + assert!(matches!( + app.transcript_scroll, + TranscriptScroll::Scrolled { .. } + )); + + app.last_transcript_top = app.transcript_cache.total_lines().saturating_sub(1); + assert!(jump_to_adjacent_tool_cell( + &mut app, + SearchDirection::Backward + )); +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 8af43be6..2f6b1bc4 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -232,6 +232,8 @@ impl ModalView for HelpView { Line::from(" Alt+Up / Alt+Down - Scroll transcript"), Line::from(" PageUp / PageDown - Scroll transcript by page"), Line::from(" Home / End - Jump to top / bottom of transcript"), + Line::from(" g / G - Jump to top / bottom (when input empty)"), + Line::from(" [ / ] - Jump between tool output blocks"), Line::from(""), Line::from(vec![Span::styled( "=== Input Editing ===", @@ -267,6 +269,7 @@ impl ModalView for HelpView { )]), Line::from(" Tab - Complete /command or cycle modes"), Line::from(" Ctrl+X - Toggle between Agent and Normal modes"), + Line::from(" Alt+1..4 / Alt+0 - Focus sidebar section / auto layout"), Line::from(""), Line::from(vec![Span::styled( "=== Sessions ===", diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index efa46cea..3077734a 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -111,12 +111,16 @@ impl<'a> ComposerWidget<'a> { impl Renderable for ComposerWidget<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { - let command_hints = slash_completion_hints(&self.app.input, 5); + let slash_menu_entries = if self.app.slash_menu_hidden { + Vec::new() + } else { + slash_completion_hints(&self.app.input, 6) + }; let prompt_width = self.prompt.width(); let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX); let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1)); - let hint_lines = usize::from(!command_hints.is_empty()); - let max_height = usize::from(area.height).saturating_sub(hint_lines).max(1); + let menu_lines = slash_menu_entries.len(); + let max_height = usize::from(area.height).saturating_sub(menu_lines).max(1); let continuation = " ".repeat(prompt_width); let (visible_lines, _cursor_row, _cursor_col) = layout_input( @@ -157,18 +161,28 @@ impl Renderable for ComposerWidget<'_> { } } - if !command_hints.is_empty() { - lines.push(Line::from(vec![ - Span::styled(" ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled( - "Tab complete: ", - Style::default().fg(palette::TEXT_MUTED).italic(), - ), - Span::styled( - command_hints.join(" "), - Style::default().fg(palette::DEEPSEEK_SKY), - ), - ])); + if !slash_menu_entries.is_empty() { + let selected = self + .app + .slash_menu_selected + .min(slash_menu_entries.len().saturating_sub(1)); + for (idx, entry) in slash_menu_entries.iter().enumerate() { + let is_selected = idx == selected; + let style = if is_selected { + Style::default() + .fg(palette::DEEPSEEK_SKY) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let marker = if is_selected { "▸" } else { " " }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(marker, style), + Span::styled(" ", style), + Span::styled(entry.clone(), style), + ])); + } } let paragraph = Paragraph::new(lines).style(background); @@ -176,22 +190,30 @@ impl Renderable for ComposerWidget<'_> { } fn desired_height(&self, width: u16) -> u16 { - let hint_lines = usize::from(!slash_completion_hints(&self.app.input, 5).is_empty()); + let menu_lines = if self.app.slash_menu_hidden { + 0 + } else { + slash_completion_hints(&self.app.input, 6).len() + }; composer_height( &self.app.input, width, self.max_height, self.prompt, - hint_lines, + menu_lines, ) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let hint_lines = usize::from(!slash_completion_hints(&self.app.input, 5).is_empty()); + let menu_lines = if self.app.slash_menu_hidden { + 0 + } else { + slash_completion_hints(&self.app.input, 6).len() + }; let prompt_width = self.prompt.width(); let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX); let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1)); - let max_height = usize::from(area.height).saturating_sub(hint_lines).max(1); + let max_height = usize::from(area.height).saturating_sub(menu_lines).max(1); let (_visible_lines, cursor_row, cursor_col) = layout_input( &self.app.input, @@ -591,7 +613,7 @@ fn composer_height( line_count.clamp(1, max_height).try_into().unwrap_or(1) } -fn slash_completion_hints(input: &str, limit: usize) -> Vec { +pub(crate) fn slash_completion_hints(input: &str, limit: usize) -> Vec { if !input.starts_with('/') || input.contains(char::is_whitespace) { return Vec::new(); } diff --git a/src/utils.rs b/src/utils.rs index cc184765..37cd305f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -209,6 +209,9 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize { ContentBlock::Thinking { thinking } => total += thinking.len(), ContentBlock::ToolUse { input, .. } => total += input.to_string().len(), ContentBlock::ToolResult { content, .. } => total += content.len(), + ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } } diff --git a/src/working_set.rs b/src/working_set.rs index 1040cccc..974223ff 100644 --- a/src/working_set.rs +++ b/src/working_set.rs @@ -405,7 +405,10 @@ fn extract_paths_from_message(message: &Message) -> Vec { ContentBlock::ToolResult { content, .. } => { paths.extend(extract_paths_from_text(content)); } - ContentBlock::Thinking { .. } => {} + ContentBlock::Thinking { .. } + | ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } paths @@ -567,7 +570,10 @@ fn message_mentions_any_path(message: &Message, needles: &[String], max_scan_cha return true; } } - ContentBlock::Thinking { .. } => {} + ContentBlock::Thinking { .. } + | ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => {} } } false @@ -747,6 +753,8 @@ mod tests { content: vec![ContentBlock::ToolResult { tool_use_id: "tool_1".to_string(), content: "Changed src/compaction.rs".to_string(), + is_error: None, + content_blocks: None, }], };