optimize: channel tool call details, permission context and stop reason i18n

- Display specific file paths, commands, and patterns in tool call messages
  instead of generic titles (e.g. ">> Read: src/main.rs")
- Show tool call details only on completion to ensure raw_input availability
- Enrich permission request messages with tool details from rawInput
- Localize stop_reason in turn_complete messages for all 10 languages
- Fix UTF-8 byte-slice panic in format_completion for multi-byte content

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-01 18:28:34 +08:00
parent ce95148232
commit 098c9adb80
3 changed files with 275 additions and 19 deletions

View File

@@ -18,6 +18,8 @@ pub struct ActiveSession {
pub connection_id: String, pub connection_id: String,
pub content_buffer: String, pub content_buffer: String,
pub tool_calls: Vec<String>, pub tool_calls: Vec<String>,
/// Stores raw_input by tool_call_id for detail extraction on completion.
pub tool_call_inputs: HashMap<String, String>,
pub last_flushed: Instant, pub last_flushed: Instant,
pub pending_prompt: Option<String>, pub pending_prompt: Option<String>,
pub permission_pending: Option<PendingPermission>, pub permission_pending: Option<PendingPermission>,

View File

@@ -364,6 +364,7 @@ pub async fn handle_task(
connection_id: connection_id.clone(), connection_id: connection_id.clone(),
content_buffer: String::new(), content_buffer: String::new(),
tool_calls: Vec::new(), tool_calls: Vec::new(),
tool_call_inputs: std::collections::HashMap::new(),
last_flushed: Instant::now(), last_flushed: Instant::now(),
pending_prompt: Some(task_description.to_string()), pending_prompt: Some(task_description.to_string()),
permission_pending: None, permission_pending: None,
@@ -561,6 +562,7 @@ pub async fn handle_resume(
connection_id: connection_id.clone(), connection_id: connection_id.clone(),
content_buffer: String::new(), content_buffer: String::new(),
tool_calls: Vec::new(), tool_calls: Vec::new(),
tool_call_inputs: std::collections::HashMap::new(),
last_flushed: Instant::now(), last_flushed: Instant::now(),
pending_prompt: None, pending_prompt: None,
permission_pending: None, permission_pending: None,

View File

@@ -166,20 +166,20 @@ async fn handle_acp_event_payload(
.get("title") .get("title")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("tool"); .unwrap_or("tool");
let status = payload let tool_call_id = payload
.get("status") .get("tool_call_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
let mut guard = bridge.lock().await; let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) { 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()); session.tool_calls.push(title.to_string());
let channel_id = session.channel_id; if let Some(input) = raw_input {
drop(guard); session
.tool_call_inputs
if status != "completed" { .insert(tool_call_id.to_string(), input.to_string());
let msg = RichMessage::info(format!(">> {title}"));
let _ = manager.send_to_channel(channel_id, &msg).await;
} }
} }
} }
@@ -187,14 +187,30 @@ async fn handle_acp_event_payload(
"tool_call_update" => { "tool_call_update" => {
let title = payload.get("title").and_then(|v| v.as_str()); let title = payload.get("title").and_then(|v| v.as_str());
let status = payload.get("status").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 mut guard = bridge.lock().await;
let guard = bridge.lock().await; if let Some(session) = guard.get_mut(connection_id) {
if let Some(session) = guard.get(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; let channel_id = session.channel_id;
drop(guard); drop(guard);
let msg = RichMessage::info(format!(">> {title} [done]")); let msg = RichMessage::info(format!(">> {detail}"));
let _ = manager.send_to_channel(channel_id, &msg).await; let _ = manager.send_to_channel(channel_id, &msg).await;
} }
} }
@@ -245,12 +261,23 @@ async fn handle_acp_event_payload(
return; return;
} }
let tool_desc = tool_call let tool_title = tool_call
.get("title") .get("title")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str())) .or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str()))
.unwrap_or("Unknown tool") .unwrap_or("Unknown tool");
.to_string();
// 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 { session.permission_pending = Some(PendingPermission {
request_id: request_id.to_string(), request_id: request_id.to_string(),
@@ -319,7 +346,7 @@ async fn handle_acp_event_payload(
Lang::ZhCn | Lang::ZhTw => "结束原因", Lang::ZhCn | Lang::ZhTw => "结束原因",
_ => "Stop Reason", _ => "Stop Reason",
}, },
stop_reason, localize_stop_reason(stop_reason, lang),
); );
let _ = manager.send_to_channel(channel_id, &msg).await; 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; return body;
} }
// Truncate long content // Truncate long content (use char boundaries to avoid panic on multi-byte)
let head = &content[..500.min(content.len())]; let head_end = content
let tail_start = content.len().saturating_sub(500); .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..]; let tail = &content[tail_start..];
match lang { 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::<serde_json::Value>(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}...")
}
}