Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/acp/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ impl std::fmt::Display for JsonRpcError {
#[derive(Debug)]
pub enum AcpEvent {
Text(String),
Thinking,
ToolStart { title: String },
ToolDone { title: String, status: String },
Thinking(String),
ToolStart { id: String, title: String },
ToolDone { id: String, title: String, status: String },
Status,
}

Expand All @@ -70,25 +70,49 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option<AcpEvent> {
let update = params.get("update")?;
let session_update = update.get("sessionUpdate")?.as_str()?;

// toolCallId is the stable identity across tool_call β†’ tool_call_update
// events for the same tool invocation. claude-agent-acp emits the first
// event before the input fields are streamed in (so the title falls back
// to "Terminal" / "Edit" / etc.) and refines them in a later
// tool_call_update; without the id we can't tell those events belong to
// the same call and end up rendering placeholder + refined as two
// separate lines.
let tool_id = update
.get("toolCallId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();

match session_update {
"agent_message_chunk" => {
let text = update.get("content")?.get("text")?.as_str()?;
Some(AcpEvent::Text(text.to_string()))
}
"agent_thought_chunk" => {
Some(AcpEvent::Thinking)
// Extract the streamed thinking text. claude-agent-acp emits
// these as `{ content: { type: "text", text: "..." } }` (same
// shape as agent_message_chunk). If the field is missing we
// still emit Thinking with an empty string so the reactions
// controller still flips πŸ€”.
let text = update
.get("content")
.and_then(|c| c.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(AcpEvent::Thinking(text))
}
"tool_call" => {
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(AcpEvent::ToolStart { title })
Some(AcpEvent::ToolStart { id: tool_id, title })
}
"tool_call_update" => {
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: tool_id, title, status })
} else {
Some(AcpEvent::ToolStart { title })
Some(AcpEvent::ToolStart { id: tool_id, title })
}
}
"plan" => Some(AcpEvent::Status),
Expand Down
166 changes: 151 additions & 15 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,18 @@ async fn stream_prompt(
let (buf_tx, buf_rx) = watch::channel(initial);

let mut text_buf = String::new();
let mut tool_lines: Vec<String> = Vec::new();
// Accumulated thinking content from agent_thought_chunk events.
// Rendered above tool_lines + text as a blockquote so users can
// see the bot's reasoning before / alongside its actions.
let mut thought_buf = String::new();
// Tool calls indexed by toolCallId. Vec preserves first-seen
// order. We store id + title + state separately so a ToolDone
// event that arrives without a refreshed title (claude-agent-acp's
// update events don't always re-send the title field) can still
// reuse the title we already learned from a prior
// tool_call_update β€” only the icon flips πŸ”§ β†’ βœ… / ❌. Rendering
// happens on the fly in compose_display().
let mut tool_lines: Vec<ToolEntry> = Vec::new();
let current_msg_id = msg_id;

if reset {
Expand Down Expand Up @@ -267,23 +278,79 @@ async fn stream_prompt(
// Reaction: back to thinking after tools
}
text_buf.push_str(&t);
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
let _ = buf_tx.send(compose_display(&tool_lines, &thought_buf, &text_buf));
}
AcpEvent::Thinking => {
AcpEvent::Thinking(t) => {
reactions.set_thinking().await;
if !t.is_empty() {
// claude-agent-acp emits both `thinking`
// (new block) and `thinking_delta`
// (continuation) events as the same
// `agent_thought_chunk` shape, so we can't
// tell block boundaries from the protocol
// alone. Heuristic: if the previous chunk
// ended with sentence-ending punctuation
// and the new one starts with a letter
// (no leading whitespace), it's almost
// certainly a new thinking block β€” insert
// a paragraph break so they don't visually
// run together as ".There's".
if needs_thinking_separator(&thought_buf, &t) {
thought_buf.push_str("\n\n");
}
thought_buf.push_str(&t);
let _ = buf_tx.send(compose_display(&tool_lines, &thought_buf, &text_buf));
}
}
AcpEvent::ToolStart { title, .. } if !title.is_empty() => {
AcpEvent::ToolStart { id, title } if !title.is_empty() => {
reactions.set_tool(&title).await;
tool_lines.push(format!("πŸ”§ `{title}`..."));
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
let title = sanitize_title(&title);
// Dedupe by toolCallId: replace if we've already
// seen this id, otherwise append a new entry.
// claude-agent-acp emits a placeholder title
// ("Terminal", "Edit", etc.) on the first event
// and refines it via tool_call_update; without
// dedup the placeholder and refined version
// appear as two separate orphaned lines.
if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) {
slot.title = title;
slot.state = ToolState::Running;
} else {
tool_lines.push(ToolEntry {
id,
title,
state: ToolState::Running,
});
}
let _ = buf_tx.send(compose_display(&tool_lines, &thought_buf, &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}`");
let new_state = if status == "completed" {
ToolState::Completed
} else {
ToolState::Failed
};
// Find by id (the title is unreliable β€” substring
// match against the placeholder "Terminal" would
// never find the refined entry). Preserve the
// existing title if the Done event omits it.
if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) {
if !title.is_empty() {
slot.title = sanitize_title(&title);
}
slot.state = new_state;
} else if !title.is_empty() {
// Done arrived without a prior Start (rare
// race) β€” record it so we still show
// something.
tool_lines.push(ToolEntry {
id,
title: sanitize_title(&title),
state: new_state,
});
}
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
let _ = buf_tx.send(compose_display(&tool_lines, &thought_buf, &text_buf));
}
_ => {}
}
Expand All @@ -295,7 +362,7 @@ async fn stream_prompt(
let _ = edit_handle.await;

// Final edit
let final_content = compose_display(&tool_lines, &text_buf);
let final_content = compose_display(&tool_lines, &thought_buf, &text_buf);
let final_content = if final_content.is_empty() {
"_(no response)_".to_string()
} else {
Expand All @@ -317,15 +384,84 @@ async fn stream_prompt(
.await
}

fn compose_display(tool_lines: &[String], text: &str) -> String {
/// Flatten a tool-call title into a single line that's safe to render
/// inside Discord inline-code spans. Discord renders single-backtick
/// code on a single line only, so multi-line shell commands (heredocs,
/// `&&`-chained commands split across lines) appear truncated; we
/// collapse newlines to ` ; ` and rewrite embedded backticks so they
/// don't break the wrapping span.
fn sanitize_title(title: &str) -> String {
title.replace('\r', "").replace('\n', " ; ").replace('`', "'")
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolState {
Running,
Completed,
Failed,
}

#[derive(Debug, Clone)]
pub struct ToolEntry {
pub id: String,
pub title: String,
pub state: ToolState,
}

impl ToolEntry {
fn render(&self) -> String {
let icon = match self.state {
ToolState::Running => "πŸ”§",
ToolState::Completed => "βœ…",
ToolState::Failed => "❌",
};
let suffix = if self.state == ToolState::Running { "..." } else { "" };
format!("{icon} `{}`{}", self.title, suffix)
}
}

/// Heuristic for detecting a thinking block boundary in a stream of
/// `agent_thought_chunk` deltas. claude-agent-acp doesn't tag the first
/// delta of a new block, so we infer it: if the previous chunk ended in
/// sentence-ending punctuation (`. ! ? 。 ! ?`) and the new chunk starts
/// with a letter (no leading whitespace), they're almost certainly two
/// separate thoughts that need a paragraph break.
fn needs_thinking_separator(prev: &str, new: &str) -> bool {
if prev.is_empty() || new.is_empty() {
return false;
}
let last = match prev.chars().last() {
Some(c) => c,
None => return false,
};
let first = match new.chars().next() {
Some(c) => c,
None => return false,
};
let sentence_end = matches!(last, '.' | '!' | '?' | '。' | '!' | '?');
let starts_word = first.is_alphabetic();
sentence_end && starts_word
}

fn compose_display(tool_lines: &[ToolEntry], thought: &str, text: &str) -> String {
let mut out = String::new();
if !tool_lines.is_empty() {
for line in tool_lines {
let trimmed_thought = thought.trim();
if !trimmed_thought.is_empty() {
out.push_str("> πŸ€” _Thinking_\n");
for line in trimmed_thought.lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
if !tool_lines.is_empty() {
for entry in tool_lines {
out.push_str(&entry.render());
out.push('\n');
}
out.push('\n');
}
out.push_str(text.trim_end());
out
}
Expand Down