@@ -407,7 +407,14 @@ async fn stream_prompt(
407407 let ( buf_tx, buf_rx) = watch:: channel ( initial) ;
408408
409409 let mut text_buf = String :: new ( ) ;
410- let mut tool_lines: Vec < String > = Vec :: new ( ) ;
410+ // Tool calls indexed by toolCallId. Vec preserves first-seen
411+ // order. We store id + title + state separately so a ToolDone
412+ // event that arrives without a refreshed title (claude-agent-acp's
413+ // update events don't always re-send the title field) can still
414+ // reuse the title we already learned from a prior
415+ // tool_call_update — only the icon flips 🔧 → ✅ / ❌. Rendering
416+ // happens on the fly in compose_display().
417+ let mut tool_lines: Vec < ToolEntry > = Vec :: new ( ) ;
411418 let current_msg_id = msg_id;
412419
413420 if reset {
@@ -474,16 +481,53 @@ async fn stream_prompt(
474481 AcpEvent :: Thinking => {
475482 reactions. set_thinking ( ) . await ;
476483 }
477- AcpEvent :: ToolStart { title , .. } if !title. is_empty ( ) => {
484+ AcpEvent :: ToolStart { id , title } if !title. is_empty ( ) => {
478485 reactions. set_tool ( & title) . await ;
479- tool_lines. push ( format ! ( "🔧 `{title}`..." ) ) ;
486+ let title = sanitize_title ( & title) ;
487+ // Dedupe by toolCallId: replace if we've already
488+ // seen this id, otherwise append a new entry.
489+ // claude-agent-acp emits a placeholder title
490+ // ("Terminal", "Edit", etc.) on the first event
491+ // and refines it via tool_call_update; without
492+ // dedup the placeholder and refined version
493+ // appear as two separate orphaned lines.
494+ if let Some ( slot) = tool_lines. iter_mut ( ) . find ( |e| e. id == id) {
495+ slot. title = title;
496+ slot. state = ToolState :: Running ;
497+ } else {
498+ tool_lines. push ( ToolEntry {
499+ id,
500+ title,
501+ state : ToolState :: Running ,
502+ } ) ;
503+ }
480504 let _ = buf_tx. send ( compose_display ( & tool_lines, & text_buf) ) ;
481505 }
482- AcpEvent :: ToolDone { title , status , .. } => {
506+ AcpEvent :: ToolDone { id , title , status } => {
483507 reactions. set_thinking ( ) . await ;
484- let icon = if status == "completed" { "✅" } else { "❌" } ;
485- if let Some ( line) = tool_lines. iter_mut ( ) . rev ( ) . find ( |l| l. contains ( & title) ) {
486- * line = format ! ( "{icon} `{title}`" ) ;
508+ let new_state = if status == "completed" {
509+ ToolState :: Completed
510+ } else {
511+ ToolState :: Failed
512+ } ;
513+ // Find by id (the title is unreliable — substring
514+ // match against the placeholder "Terminal" would
515+ // never find the refined entry). Preserve the
516+ // existing title if the Done event omits it.
517+ if let Some ( slot) = tool_lines. iter_mut ( ) . find ( |e| e. id == id) {
518+ if !title. is_empty ( ) {
519+ slot. title = sanitize_title ( & title) ;
520+ }
521+ slot. state = new_state;
522+ } else if !title. is_empty ( ) {
523+ // Done arrived without a prior Start (rare
524+ // race) — record it so we still show
525+ // something.
526+ tool_lines. push ( ToolEntry {
527+ id,
528+ title : sanitize_title ( & title) ,
529+ state : new_state,
530+ } ) ;
487531 }
488532 let _ = buf_tx. send ( compose_display ( & tool_lines, & text_buf) ) ;
489533 }
@@ -529,11 +573,47 @@ async fn stream_prompt(
529573 . await
530574}
531575
532- fn compose_display ( tool_lines : & [ String ] , text : & str ) -> String {
576+ /// Flatten a tool-call title into a single line that's safe to render
577+ /// inside Discord inline-code spans. Discord renders single-backtick
578+ /// code on a single line only, so multi-line shell commands (heredocs,
579+ /// `&&`-chained commands split across lines) appear truncated; we
580+ /// collapse newlines to ` ; ` and rewrite embedded backticks so they
581+ /// don't break the wrapping span.
582+ fn sanitize_title ( title : & str ) -> String {
583+ title. replace ( '\r' , "" ) . replace ( '\n' , " ; " ) . replace ( '`' , "'" )
584+ }
585+
586+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
587+ enum ToolState {
588+ Running ,
589+ Completed ,
590+ Failed ,
591+ }
592+
593+ #[ derive( Debug , Clone ) ]
594+ struct ToolEntry {
595+ id : String ,
596+ title : String ,
597+ state : ToolState ,
598+ }
599+
600+ impl ToolEntry {
601+ fn render ( & self ) -> String {
602+ let icon = match self . state {
603+ ToolState :: Running => "🔧" ,
604+ ToolState :: Completed => "✅" ,
605+ ToolState :: Failed => "❌" ,
606+ } ;
607+ let suffix = if self . state == ToolState :: Running { "..." } else { "" } ;
608+ format ! ( "{icon} `{}`{}" , self . title, suffix)
609+ }
610+ }
611+
612+ fn compose_display ( tool_lines : & [ ToolEntry ] , text : & str ) -> String {
533613 let mut out = String :: new ( ) ;
534614 if !tool_lines. is_empty ( ) {
535- for line in tool_lines {
536- out. push_str ( line ) ;
615+ for entry in tool_lines {
616+ out. push_str ( & entry . render ( ) ) ;
537617 out. push ( '\n' ) ;
538618 }
539619 out. push ( '\n' ) ;
0 commit comments