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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}],
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ impl TurnContext {
|
||||
usage: Usage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ async fn process_deepseek_turn(
|
||||
id,
|
||||
name,
|
||||
input: parsed,
|
||||
caller: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1290,6 +1290,7 @@ mod tests {
|
||||
usage: Usage {
|
||||
input_tokens: 2,
|
||||
output_tokens: 1,
|
||||
server_tool_use: None,
|
||||
},
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(""),
|
||||
];
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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 => "> ",
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user