diff --git a/src-tauri/src/chat_channel/session_bridge.rs b/src-tauri/src/chat_channel/session_bridge.rs index d0edaf3..fb9cf15 100644 --- a/src-tauri/src/chat_channel/session_bridge.rs +++ b/src-tauri/src/chat_channel/session_bridge.rs @@ -18,6 +18,8 @@ pub struct ActiveSession { pub connection_id: String, pub content_buffer: String, pub tool_calls: Vec, + /// Stores raw_input by tool_call_id for detail extraction on completion. + pub tool_call_inputs: HashMap, pub last_flushed: Instant, pub pending_prompt: Option, pub permission_pending: Option, diff --git a/src-tauri/src/chat_channel/session_commands.rs b/src-tauri/src/chat_channel/session_commands.rs index 5545c36..0ac8c5f 100644 --- a/src-tauri/src/chat_channel/session_commands.rs +++ b/src-tauri/src/chat_channel/session_commands.rs @@ -364,6 +364,7 @@ pub async fn handle_task( connection_id: connection_id.clone(), content_buffer: String::new(), tool_calls: Vec::new(), + tool_call_inputs: std::collections::HashMap::new(), last_flushed: Instant::now(), pending_prompt: Some(task_description.to_string()), permission_pending: None, @@ -561,6 +562,7 @@ pub async fn handle_resume( connection_id: connection_id.clone(), content_buffer: String::new(), tool_calls: Vec::new(), + tool_call_inputs: std::collections::HashMap::new(), last_flushed: Instant::now(), pending_prompt: None, permission_pending: None, diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs index ac8cf1e..c8ca1a4 100644 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -166,20 +166,20 @@ async fn handle_acp_event_payload( .get("title") .and_then(|v| v.as_str()) .unwrap_or("tool"); - let status = payload - .get("status") + let tool_call_id = payload + .get("tool_call_id") .and_then(|v| v.as_str()) .unwrap_or(""); + let raw_input = payload.get("raw_input").and_then(|v| v.as_str()); let mut guard = bridge.lock().await; if let Some(session) = guard.get_mut(connection_id) { + // Store title for progress indicator; store raw_input for later session.tool_calls.push(title.to_string()); - let channel_id = session.channel_id; - drop(guard); - - if status != "completed" { - let msg = RichMessage::info(format!(">> {title}")); - let _ = manager.send_to_channel(channel_id, &msg).await; + if let Some(input) = raw_input { + session + .tool_call_inputs + .insert(tool_call_id.to_string(), input.to_string()); } } } @@ -187,14 +187,30 @@ async fn handle_acp_event_payload( "tool_call_update" => { let title = payload.get("title").and_then(|v| v.as_str()); let status = payload.get("status").and_then(|v| v.as_str()); + let tool_call_id = payload + .get("tool_call_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let raw_input = payload.get("raw_input").and_then(|v| v.as_str()); - if let (Some(title), Some("completed")) = (title, status) { - let guard = bridge.lock().await; - if let Some(session) = guard.get(connection_id) { + let mut guard = bridge.lock().await; + if let Some(session) = guard.get_mut(connection_id) { + // Accumulate raw_input if newly available + if let Some(input) = raw_input { + session + .tool_call_inputs + .insert(tool_call_id.to_string(), input.to_string()); + } + + if status == Some("completed") { + let stored_input = session.tool_call_inputs.remove(tool_call_id); + let effective_title = title.unwrap_or("tool"); + let input_ref = stored_input.as_deref().or(raw_input); + let detail = format_tool_call_detail(effective_title, input_ref); let channel_id = session.channel_id; drop(guard); - let msg = RichMessage::info(format!(">> {title} [done]")); + let msg = RichMessage::info(format!(">> {detail}")); let _ = manager.send_to_channel(channel_id, &msg).await; } } @@ -245,12 +261,23 @@ async fn handle_acp_event_payload( return; } - let tool_desc = tool_call + let tool_title = tool_call .get("title") .and_then(|v| v.as_str()) .or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str())) - .unwrap_or("Unknown tool") - .to_string(); + .unwrap_or("Unknown tool"); + + // Extract detail from rawInput / raw_input in the tool_call object + let raw_input_str = tool_call + .get("rawInput") + .or_else(|| tool_call.get("raw_input")) + .and_then(|v| match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Null => None, + other => Some(other.to_string()), + }); + let tool_desc = + format_tool_call_detail(tool_title, raw_input_str.as_deref()); session.permission_pending = Some(PendingPermission { request_id: request_id.to_string(), @@ -319,7 +346,7 @@ async fn handle_acp_event_payload( Lang::ZhCn | Lang::ZhTw => "结束原因", _ => "Stop Reason", }, - stop_reason, + localize_stop_reason(stop_reason, lang), ); let _ = manager.send_to_channel(channel_id, &msg).await; @@ -452,9 +479,19 @@ fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String { return body; } - // Truncate long content - let head = &content[..500.min(content.len())]; - let tail_start = content.len().saturating_sub(500); + // Truncate long content (use char boundaries to avoid panic on multi-byte) + let head_end = content + .char_indices() + .nth(500) + .map(|(i, _)| i) + .unwrap_or(content.len()); + let head = &content[..head_end]; + let tail_start = content + .char_indices() + .rev() + .nth(499) + .map(|(i, _)| i) + .unwrap_or(0); let tail = &content[tail_start..]; match lang { @@ -472,3 +509,218 @@ fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String { } } } + +fn localize_stop_reason(reason: &str, lang: Lang) -> String { + match lang { + Lang::ZhCn => match reason { + "end_turn" => "正常结束", + "cancelled" => "已取消", + "max_tokens" => "达到最大长度", + "stop_sequence" => "遇到停止序列", + "error" => "错误", + "timeout" => "超时", + other => other, + }, + Lang::ZhTw => match reason { + "end_turn" => "正常結束", + "cancelled" => "已取消", + "max_tokens" => "達到最大長度", + "stop_sequence" => "遇到停止序列", + "error" => "錯誤", + "timeout" => "逾時", + other => other, + }, + Lang::Ja => match reason { + "end_turn" => "正常終了", + "cancelled" => "キャンセル", + "max_tokens" => "最大トークン数到達", + "stop_sequence" => "停止シーケンス", + "error" => "エラー", + "timeout" => "タイムアウト", + other => other, + }, + Lang::Ko => match reason { + "end_turn" => "정상 종료", + "cancelled" => "취소됨", + "max_tokens" => "최대 길이 도달", + "stop_sequence" => "정지 시퀀스", + "error" => "오류", + "timeout" => "시간 초과", + other => other, + }, + Lang::Es => match reason { + "end_turn" => "Finalizado", + "cancelled" => "Cancelado", + "max_tokens" => "Longitud máxima alcanzada", + "error" => "Error", + "timeout" => "Tiempo agotado", + other => other, + }, + Lang::De => match reason { + "end_turn" => "Abgeschlossen", + "cancelled" => "Abgebrochen", + "max_tokens" => "Maximale Länge erreicht", + "error" => "Fehler", + "timeout" => "Zeitüberschreitung", + other => other, + }, + Lang::Fr => match reason { + "end_turn" => "Terminé", + "cancelled" => "Annulé", + "max_tokens" => "Longueur maximale atteinte", + "error" => "Erreur", + "timeout" => "Délai dépassé", + other => other, + }, + Lang::Pt => match reason { + "end_turn" => "Concluído", + "cancelled" => "Cancelado", + "max_tokens" => "Comprimento máximo atingido", + "error" => "Erro", + "timeout" => "Tempo esgotado", + other => other, + }, + Lang::Ar => match reason { + "end_turn" => "اكتمل", + "cancelled" => "ملغى", + "max_tokens" => "تم بلوغ الحد الأقصى", + "error" => "خطأ", + "timeout" => "انتهت المهلة", + other => other, + }, + Lang::En => match reason { + "end_turn" => "Completed", + "cancelled" => "Cancelled", + "max_tokens" => "Max length reached", + "stop_sequence" => "Stop sequence", + "error" => "Error", + "timeout" => "Timeout", + other => other, + }, + } + .to_string() +} + +/// Extract a concise detail string from a tool call's `raw_input` JSON. +/// +/// Returns a formatted string like `"Read: src/main.rs"` or `"Bash: npm test"`. +/// Falls back to the original title if no detail can be extracted. +fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String { + let parsed = raw_input.and_then(|s| serde_json::from_str::(s).ok()); + + let normalized_title = title.to_lowercase().replace([' ', '-'], "_"); + + if let Some(ref obj) = parsed { + // File operations: read, edit, write, delete + if let Some(path) = obj + .get("file_path") + .or_else(|| obj.get("path")) + .or_else(|| obj.get("notebook_path")) + .and_then(|v| v.as_str()) + { + let short = short_path(path); + let label = match normalized_title.as_str() { + s if s.contains("write") => "Write", + s if s.contains("edit") || s.contains("change") || s.contains("update") => "Edit", + s if s.contains("delete") => "Delete", + _ => "Read", + }; + return format!("{label}: {short}"); + } + + // Bash / shell commands + if let Some(cmd) = obj + .get("command") + .or_else(|| obj.get("cmd")) + .and_then(|v| v.as_str()) + { + let short = truncate_str(cmd.lines().next().unwrap_or(cmd), 80); + return format!("Bash: {short}"); + } + + // Grep / search + if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) { + let path = obj.get("path").and_then(|v| v.as_str()); + return if let Some(p) = path { + format!("Grep: \"{}\" in {}", truncate_str(pattern, 40), short_path(p)) + } else { + format!("Grep: \"{}\"", truncate_str(pattern, 60)) + }; + } + + // Glob + if let Some(pat) = obj.get("glob").and_then(|v| v.as_str()) { + return format!("Glob: {pat}"); + } + + // Agent / task + if obj.get("subagent_type").is_some() + || obj.get("task_id").is_some() + || obj.get("subject").is_some() + { + let desc = obj + .get("description") + .or_else(|| obj.get("subject")) + .or_else(|| obj.get("prompt")) + .and_then(|v| v.as_str()); + if let Some(d) = desc { + return format!("Agent: {}", truncate_str(d, 60)); + } + } + + // Web fetch + if let Some(url) = obj.get("url").and_then(|v| v.as_str()) { + return format!("Fetch: {}", truncate_str(url, 80)); + } + + // Web search + if let Some(query) = obj.get("query").and_then(|v| v.as_str()) { + return format!("Search: {}", truncate_str(query, 60)); + } + + // TodoWrite + if obj.get("todos").is_some() { + return "TodoWrite".to_string(); + } + } + + // Fallback: if raw_input is a plain string (e.g. a bare command), use it directly + if let Some(raw) = raw_input { + if !raw.starts_with('{') && !raw.starts_with('[') { + let short = truncate_str(raw.lines().next().unwrap_or(raw), 80); + if normalized_title.contains("bash") + || normalized_title.contains("shell") + || normalized_title.contains("exec") + { + return format!("Bash: {short}"); + } + } + } + + title.to_string() +} + +fn short_path(path: &str) -> &str { + // Show last 2 path components at most, or the full path if short enough + if path.len() <= 60 { + return path; + } + let parts: Vec<&str> = path.rsplitn(3, '/').collect(); + if parts.len() >= 2 { + // e.g. "src/main.rs" from "/very/long/path/src/main.rs" + let tail = &path[path.len() - parts[0].len() - parts[1].len() - 1..]; + if tail.len() < path.len() { + return tail; + } + } + path +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); + format!("{truncated}...") + } +}