feat: add editing and MCP command handling, enhance SSE streaming with usage tracking
- Implemented `Edit` and `McpAdd` commands in the command parser and handler. - Added a new `stream` module to the runtime for handling streaming events. - Enhanced `SseParser` to parse usage information from SSE events. - Introduced `ToolCallAccumulator` for tracking tool calls independently. - Updated `AppStateRest` to include `app_config` and `MiscState` to track `effort_level` and `selected_index`. - Modified `LlmClient` to support streaming responses with usage tracking. - Improved error handling and retry logic in the streaming API calls. - Added tests for new features and improved markdown rendering in the chat view.
This commit is contained in:
@@ -53,6 +53,13 @@ pub enum Action {
|
||||
StartOAuth {
|
||||
provider: String,
|
||||
},
|
||||
OpenEditor {
|
||||
path: String,
|
||||
},
|
||||
McpAdd {
|
||||
name: String,
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
@@ -137,7 +144,51 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.misc.overlay = overlay;
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::OpenEditor { path } => {
|
||||
let resolved = crate::tool::resolve_path(&state.workspace_roots, &path);
|
||||
match resolved {
|
||||
Ok(abs_path) => {
|
||||
let content = std::fs::read_to_string(&abs_path)
|
||||
.unwrap_or_default();
|
||||
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
|
||||
let ed = crate::app::mode::editor::EditorState::open(
|
||||
abs_path.to_string_lossy().to_string(),
|
||||
Some(lines),
|
||||
);
|
||||
state.misc.editor = Some(ed);
|
||||
state.misc.overlay = Overlay::Editor;
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path)));
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e)));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::McpAdd { name, command } => {
|
||||
let extra_args: Vec<String> = command.split_whitespace().map(|s| s.to_string()).collect();
|
||||
let cmd = extra_args.first().cloned().unwrap_or_default();
|
||||
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
|
||||
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
|
||||
Ok(_) => {
|
||||
let tool_count = state.mcp_manager.servers.last()
|
||||
.map(|s| s.tools.len())
|
||||
.unwrap_or(0);
|
||||
state.push_toast(Toast::new(ToastKind::Success,
|
||||
format!("Connected MCP server '{}' ({} tools)", name, tool_count)));
|
||||
state.dirty = true;
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error,
|
||||
format!("MCP connect failed: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::CloseOverlay => {
|
||||
// If the overlay is the Editor, dismiss it properly first
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
crate::app::mode::editor::handle_editor_dismiss(state);
|
||||
}
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -315,6 +366,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
}
|
||||
}
|
||||
TurnEvent::StreamStart => {
|
||||
state.misc.thinking = false;
|
||||
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
|
||||
}
|
||||
TurnEvent::StreamToken(delta) => {
|
||||
if let Some(last) = state.transcript_cache.messages.last_mut() {
|
||||
if last.role == Role::Assistant {
|
||||
last.content.push_str(&delta);
|
||||
state.transcript_cache.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
TurnEvent::StreamDone(msg) => {
|
||||
state.misc.thinking = false;
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(msg);
|
||||
}
|
||||
}
|
||||
TurnEvent::Usage { tokens_in, tokens_out } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in += tokens_in;
|
||||
rt.usage.tokens_out += tokens_out;
|
||||
rt.usage.api_calls += 1;
|
||||
}
|
||||
}
|
||||
TurnEvent::Error(msg) => {
|
||||
let long_toast = Toast {
|
||||
kind: ToastKind::Error,
|
||||
@@ -384,6 +460,12 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
}
|
||||
let api_key = state.settings.api_key.clone().unwrap_or_default();
|
||||
let model = state.settings.model.clone();
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||
.map(|p| p.api_base.clone());
|
||||
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
|
||||
state.misc.effort_level,
|
||||
state.settings.max_tokens,
|
||||
);
|
||||
let mut tools = crate::tool::all_tools();
|
||||
tools.extend(state.mcp_manager.as_tools());
|
||||
let tool_defs = crate::tool::tool_defs(&tools);
|
||||
@@ -404,7 +486,7 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
.ok()
|
||||
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
|
||||
let tc = TurnCtx {
|
||||
client: crate::service::provider::LlmClient::new(api_key, model),
|
||||
client: crate::service::provider::LlmClient::new(api_key, model, base_url),
|
||||
tdefs: tool_defs,
|
||||
tools,
|
||||
ctx,
|
||||
@@ -413,6 +495,8 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
edit_log_session_dir: edit_session_dir,
|
||||
session_id,
|
||||
db,
|
||||
temperature,
|
||||
max_tokens,
|
||||
};
|
||||
let result = run_agent_turn(tc, &messages, &events_q);
|
||||
if let Err(e) = result {
|
||||
@@ -436,6 +520,8 @@ struct TurnCtx {
|
||||
edit_log_session_dir: std::path::PathBuf,
|
||||
session_id: String,
|
||||
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||
temperature: f32,
|
||||
max_tokens: u32,
|
||||
}
|
||||
|
||||
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
||||
@@ -490,9 +576,34 @@ fn run_agent_turn(
|
||||
msgs.clone()
|
||||
};
|
||||
|
||||
let response = tc
|
||||
.client
|
||||
.chat_with_tools(&wire_msgs, Some(tc.tdefs.clone()))?;
|
||||
let mut stream_started = false;
|
||||
let (response, usage) = tc.client.chat_with_tools_streaming(
|
||||
&wire_msgs,
|
||||
Some(tc.tdefs.clone()),
|
||||
Some(tc.temperature),
|
||||
Some(tc.max_tokens),
|
||||
|event| match event {
|
||||
crate::app::runtime::stream::StreamEvent::Token(tok) => {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
if !stream_started {
|
||||
q.push_back(TurnEvent::StreamStart);
|
||||
stream_started = true;
|
||||
}
|
||||
q.push_back(TurnEvent::StreamToken(tok.clone()));
|
||||
}
|
||||
}
|
||||
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Usage {
|
||||
tokens_in: *prompt_tokens,
|
||||
tokens_out: *completion_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)?;
|
||||
let _ = usage; // already emitted as TurnEvent::Usage inside the on_event callback, if present
|
||||
|
||||
let has_tool_calls = response.tool_calls.is_some()
|
||||
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||
@@ -524,9 +635,11 @@ fn run_agent_turn(
|
||||
&tc.tools,
|
||||
&tc.ctx,
|
||||
&tool_name,
|
||||
&tool_call.id,
|
||||
&args,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.session_id,
|
||||
&tc.db,
|
||||
) {
|
||||
Ok(result) => (result, false, is_edit_tool),
|
||||
Err(e) => (e.to_string(), true, false),
|
||||
@@ -566,7 +679,11 @@ fn run_agent_turn(
|
||||
if !content.is_empty() {
|
||||
archive_message(&tc.db, &tc.session_id, &response);
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::AssistantMessage(response));
|
||||
if stream_started {
|
||||
q.push_back(TurnEvent::StreamDone(response));
|
||||
} else {
|
||||
q.push_back(TurnEvent::AssistantMessage(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -589,16 +706,34 @@ fn run_agent_turn(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn execute_one_tool(
|
||||
tools: &[Box<dyn crate::tool::Tool>],
|
||||
ctx: &crate::tool::ToolCtx,
|
||||
name: &str,
|
||||
tool_call_id: &str,
|
||||
args: &serde_json::Value,
|
||||
session_dir: &std::path::Path,
|
||||
session_id: &str,
|
||||
db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||
) -> anyhow::Result<String> {
|
||||
for tool in tools {
|
||||
if tool.name() == name {
|
||||
// Snapshot current file content before write/edit for rewind
|
||||
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
|
||||
if let Some(ref arc) = db {
|
||||
if let Ok(conn) = arc.lock() {
|
||||
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
|
||||
if let Ok(bytes) = std::fs::read(&abs_path) {
|
||||
let _ = crate::model::msglog::store_blob(
|
||||
&conn, session_id, tool_call_id, &bytes, None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = tool.run(ctx, args)?;
|
||||
if name == "write" || name == "edit" {
|
||||
let reason = args
|
||||
|
||||
Reference in New Issue
Block a user