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
This commit is contained in:
Hunter Bown
2026-02-18 08:17:15 -06:00
parent 6fedee021e
commit 605591d064
25 changed files with 1685 additions and 83 deletions
+14 -1
View File
@@ -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
+286 -20
View File
@@ -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<Value> {
}]
}));
}
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<Value> {
}
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<MessageResponse> {
@@ -1059,10 +1139,86 @@ fn parse_responses_message(payload: &Value) -> Result<MessageResponse> {
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<MessageResponse> {
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<Value> {
@@ -1444,11 +1634,23 @@ fn parse_chat_message(payload: &Value) -> Result<MessageResponse> {
.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<MessageResponse> {
.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<StreamEvent> {
}
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
+6 -1
View File
@@ -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));
}
_ => {}
}
+37
View File
@@ -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::<usize>()
}
@@ -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"),
+463 -6
View File
@@ -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<ToolCaller>,
input_buffer: String,
}
@@ -305,11 +306,13 @@ struct ToolExecutionPlan {
id: String,
name: String,
input: serde_json::Value,
caller: Option<ToolCaller>,
interactive: bool,
approval_required: bool,
approval_description: String,
supports_parallel: bool,
read_only: bool,
blocked_error: Option<ToolError>,
}
#[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]",
"</deepseek: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<Tool>) {
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<String> {
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<String>,
) -> Vec<Tool> {
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<Vec<String>, 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<String> {
let terms: Vec<String> = 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<String>,
) -> Result<ToolResult, ToolError> {
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::<Vec<_>>();
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<ToolResult, ToolError> {
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<String> = 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<ContentBlockKind> = None;
let mut current_tool_index: Option<usize> = 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<ToolError> = 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,
}],
});
+78
View File
@@ -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"));
}
+1
View File
@@ -55,6 +55,7 @@ impl TurnContext {
usage: Usage {
input_tokens: 0,
output_tokens: 0,
server_tool_use: None,
},
}
}
+32 -2
View File
@@ -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,
});
}
+70
View File
@@ -77,11 +77,33 @@ pub enum ContentBlock {
id: String,
name: String,
input: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
caller: Option<ToolCaller>,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
content_blocks: Option<Vec<serde_json::Value>>,
},
#[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<String>,
}
/// 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<String>,
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_callers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub defer_loading: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_examples: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
}
/// 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<String>,
}
/// 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<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_search_requests: Option<u32>,
}
/// 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<String>,
pub stop_sequence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container: Option<ContainerInfo>,
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<ServerToolUsage>,
}
/// 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<ToolCaller>,
},
#[serde(rename = "server_tool_use")]
ServerToolUse {
id: String,
name: String,
input: serde_json::Value,
},
}
+1
View File
@@ -282,6 +282,7 @@ async fn process_deepseek_turn(
id,
name,
input: parsed,
caller: None,
});
}
+1
View File
@@ -1290,6 +1290,7 @@ mod tests {
usage: Usage {
input_tokens: 2,
output_tokens: 1,
server_tool_use: None,
},
status: TurnOutcomeStatus::Completed,
error: None,
+5
View File
@@ -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,
+34
View File
@@ -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",
}
}
+5
View File
@@ -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()
+5 -1
View File
@@ -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,
});
}
+226
View File
@@ -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<u64>,
}
impl StatusToast {
#[must_use]
pub fn new(text: impl Into<String>, level: StatusToastLevel, ttl_ms: Option<u64>) -> 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<String>,
/// Recent status toasts (ephemeral, newest at back).
pub status_toasts: VecDeque<StatusToast>,
/// Sticky status toast used for important warnings/errors.
pub sticky_status: Option<StatusToast>,
/// Last status text already promoted from `status_message` into toast state.
pub last_status_message_seen: Option<String>,
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<String>,
level: StatusToastLevel,
ttl_ms: Option<u64>,
) {
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<String>,
level: StatusToastLevel,
ttl_ms: Option<u64>,
) {
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<u64>, 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<StatusToast> {
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;
+4
View File
@@ -23,6 +23,10 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
"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(""),
];
+44 -2
View File
@@ -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<ratatui::text::Line<'static>> {
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
@@ -60,6 +99,9 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
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)),
+5 -1
View File
@@ -25,7 +25,11 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
)));
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() {
+237 -27
View File
@@ -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<String> {
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<String> {
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 => "> ",
+73
View File
@@ -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
));
}
+3
View File
@@ -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 ===",
+42 -20
View File
@@ -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<String> {
pub(crate) fn slash_completion_hints(input: &str, limit: usize) -> Vec<String> {
if !input.starts_with('/') || input.contains(char::is_whitespace) {
return Vec::new();
}
+3
View File
@@ -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 { .. } => {}
}
}
}
+10 -2
View File
@@ -405,7 +405,10 @@ fn extract_paths_from_message(message: &Message) -> Vec<String> {
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,
}],
};