diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index d3e96ed5..9f728ba9 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -60,8 +60,8 @@ impl std::fmt::Display for JsonRpcError { pub enum AcpEvent { Text(String), Thinking, - ToolStart { title: String }, - ToolDone { title: String, status: String }, + ToolStart { id: String, title: String }, + ToolDone { id: String, title: String, status: String }, Status, } @@ -79,16 +79,18 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { Some(AcpEvent::Thinking) } "tool_call" => { + let id = update.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string(); let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - Some(AcpEvent::ToolStart { title }) + Some(AcpEvent::ToolStart { id, title }) } "tool_call_update" => { + let id = update.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string(); let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string(); if status == "completed" || status == "failed" { - Some(AcpEvent::ToolDone { title, status }) + Some(AcpEvent::ToolDone { id, title, status }) } else { - Some(AcpEvent::ToolStart { title }) + Some(AcpEvent::ToolStart { id, title }) } } "plan" => Some(AcpEvent::Status), diff --git a/src/discord.rs b/src/discord.rs index 32c495ab..c374504c 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -192,6 +192,7 @@ async fn stream_prompt( let mut text_buf = String::new(); let mut tool_lines: Vec = Vec::new(); + let mut tool_ids: Vec = Vec::new(); let current_msg_id = msg_id; if reset { @@ -210,19 +211,16 @@ async fn stream_prompt( if buf_rx.has_changed().unwrap_or(false) { let content = buf_rx.borrow_and_update().clone(); if content != last_content { - if content.len() > 1900 { - let chunks = format::split_message(&content, 1900); - if let Some(first) = chunks.first() { - let _ = edit(&ctx, channel, current_edit_msg, first).await; - } - for chunk in chunks.iter().skip(1) { - if let Ok(new_msg) = channel.say(&ctx.http, chunk).await { - current_edit_msg = new_msg.id; - } - } + // During streaming, only edit the single placeholder message. + // Truncate if over Discord's limit — the final edit handles + // proper multi-message splitting after streaming completes. + let display = if content.len() > 1900 { + let end = content.floor_char_boundary(1900); + format!("{}…", &content[..end]) } else { - let _ = edit(&ctx, channel, current_edit_msg, &content).await; - } + content.clone() + }; + let _ = edit(&ctx, channel, current_edit_msg, &display).await; last_content = content; } } @@ -253,16 +251,27 @@ async fn stream_prompt( AcpEvent::Thinking => { reactions.set_thinking().await; } - AcpEvent::ToolStart { title, .. } if !title.is_empty() => { + AcpEvent::ToolStart { id, title, .. } if !title.is_empty() => { reactions.set_tool(&title).await; + tool_ids.push(id); tool_lines.push(format!("🔧 `{title}`...")); let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); } - AcpEvent::ToolDone { title, status, .. } => { + AcpEvent::ToolDone { id, title, status, .. } => { reactions.set_thinking().await; let icon = if status == "completed" { "✅" } else { "❌" }; - if let Some(line) = tool_lines.iter_mut().rev().find(|l| l.contains(&title)) { - *line = format!("{icon} `{title}`"); + // Match by toolCallId instead of title text. + // On match, swap the icon but keep the original ToolStart title, + // since ToolDone often returns a different or empty title. + let idx = tool_ids.iter().rposition(|tid| !tid.is_empty() && tid == &id); + if let Some(i) = idx { + let kept_title = tool_lines[i] + .split('`').nth(1) + .unwrap_or(&title); + tool_lines[i] = format!("{icon} `{kept_title}`"); + } else if !title.is_empty() { + tool_ids.push(id); + tool_lines.push(format!("{icon} `{title}`")); } let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); } diff --git a/src/format.rs b/src/format.rs index a0026ebb..495e37e3 100644 --- a/src/format.rs +++ b/src/format.rs @@ -18,11 +18,14 @@ pub fn split_message(text: &str, limit: usize) -> Vec { } // If a single line exceeds limit, hard-split it if line.len() > limit { - for chunk in line.as_bytes().chunks(limit) { + let mut pos = 0; + while pos < line.len() { + let end = line.floor_char_boundary((pos + limit).min(line.len())); if !current.is_empty() { chunks.push(current); } - current = String::from_utf8_lossy(chunk).to_string(); + current = line[pos..end].to_string(); + pos = end; } } else { current.push_str(line);