Skip to main content

a_agent/tui/
render.rs

1use std::collections::HashMap;
2use std::io::{self, IsTerminal, Write};
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use crossterm::cursor::{MoveUp, RestorePosition, SavePosition};
7use crossterm::execute;
8use crossterm::style::{Attribute, Color, Stylize};
9use crossterm::terminal::{Clear, ClearType};
10use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
11use serde_json::Value;
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14use crate::model::{ContentBlock, ConversationItem, Role, StreamEvent, ToolResult};
15use crate::provider::EventSink;
16
17const GENERATION_ID: &str = "__a_generation";
18const GENERATION_CONTENT_ID: &str = "__a_generation_content";
19
20/// One file in a rewind plan: what will happen to it, and why.
21#[derive(Debug, Clone)]
22pub struct RevertLine {
23    /// Imperative verb, so the list reads as the plan it is rather than as
24    /// history: `delete`, `restore`, `recreate`, `keep`.
25    pub action: String,
26    pub path: String,
27    pub detail: String,
28    pub blocked: bool,
29}
30
31impl RevertLine {
32    fn color(&self) -> Color {
33        if self.blocked {
34            return Color::DarkGrey;
35        }
36        match self.action.as_str() {
37            "delete" => Color::DarkRed,
38            "recreate" => Color::DarkGreen,
39            _ => Color::DarkYellow,
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy)]
45pub struct RenderLimits {
46    pub tool_input_max_bytes: usize,
47    pub tool_output_max_bytes: usize,
48    pub tool_output_max_lines: usize,
49    pub tool_live_output_lines: usize,
50    pub patch_diff_max_lines: usize,
51}
52
53impl Default for RenderLimits {
54    fn default() -> Self {
55        Self {
56            tool_input_max_bytes: 2048,
57            tool_output_max_bytes: 8192,
58            tool_output_max_lines: 16,
59            tool_live_output_lines: 6,
60            patch_diff_max_lines: 24,
61        }
62    }
63}
64
65#[derive(Clone)]
66pub struct InlineRenderer {
67    inner: Arc<Mutex<State>>,
68}
69
70struct State {
71    writer: Box<dyn Write + Send>,
72    color: bool,
73    limits: RenderLimits,
74    reasoning_visible: bool,
75    reasoning_announced: bool,
76    reasoning_buffer: String,
77    reasoning_pending: String,
78    assistant_buffer: String,
79    reasoning_at_line_start: bool,
80    assistant_at_line_start: bool,
81    tools: HashMap<String, ToolDisplay>,
82    live: Option<LiveTools>,
83}
84
85struct ToolDisplay {
86    name: String,
87    arguments: BoundedInput,
88    output: TailBuffer,
89}
90
91struct BoundedInput {
92    text: String,
93    max_bytes: usize,
94    truncated: bool,
95}
96
97struct TailBuffer {
98    bytes: Vec<u8>,
99    max_bytes: usize,
100    total_bytes: usize,
101    total_lines: usize,
102}
103
104struct LimitedText {
105    lines: Vec<String>,
106    truncated: bool,
107}
108
109struct LiveTools {
110    multi: MultiProgress,
111    entries: HashMap<String, ProgressBar>,
112    max_lines: usize,
113    visible_lines: usize,
114    terminal_width: usize,
115    reserved: bool,
116    reserve_stdout_rows: bool,
117}
118
119impl InlineRenderer {
120    pub fn stdout(show_reasoning: bool) -> io::Result<Self> {
121        Self::stdout_with_limits(show_reasoning, RenderLimits::default())
122    }
123
124    pub fn stdout_with_limits(show_reasoning: bool, limits: RenderLimits) -> io::Result<Self> {
125        let interactive = io::stdout().is_terminal();
126        let renderer = Self::new_with_limits(
127            io::stdout(),
128            show_reasoning,
129            interactive && std::env::var_os("NO_COLOR").is_none(),
130            limits,
131        );
132        if interactive {
133            renderer.with_state(|state| {
134                state.live = Some(LiveTools::new(state.limits.tool_live_output_lines));
135                Ok(())
136            })?;
137        }
138        Ok(renderer)
139    }
140
141    pub fn new(writer: impl Write + Send + 'static, show_reasoning: bool, color: bool) -> Self {
142        Self::new_with_limits(writer, show_reasoning, color, RenderLimits::default())
143    }
144
145    pub fn new_with_limits(
146        writer: impl Write + Send + 'static,
147        show_reasoning: bool,
148        color: bool,
149        limits: RenderLimits,
150    ) -> Self {
151        Self {
152            inner: Arc::new(Mutex::new(State {
153                writer: Box::new(writer),
154                color,
155                limits,
156                reasoning_visible: show_reasoning,
157                reasoning_announced: false,
158                reasoning_buffer: String::new(),
159                reasoning_pending: String::new(),
160                assistant_buffer: String::new(),
161                reasoning_at_line_start: true,
162                assistant_at_line_start: true,
163                tools: HashMap::new(),
164                live: None,
165            })),
166        }
167    }
168
169    pub fn begin_turn(&self) -> io::Result<()> {
170        self.with_state(|state| {
171            state.finish_open_lines()?;
172            state.reasoning_announced = false;
173            state.reasoning_buffer.clear();
174            state.reasoning_pending.clear();
175            state.assistant_buffer.clear();
176            state.tools.clear();
177            if let Some(live) = &mut state.live {
178                live.clear();
179            }
180            Ok(())
181        })
182    }
183
184    pub fn render_user(&self, message: &str) -> io::Result<()> {
185        self.begin_turn()?;
186        self.with_state(|state| {
187            write_prefixed_block(
188                &mut state.writer,
189                state.color,
190                "› ",
191                message,
192                Color::DarkCyan,
193                true,
194                Color::Reset,
195            )?;
196            state.writer.flush()
197        })
198    }
199
200    /// Lists what a rewind would do to each file, one line per path, using the
201    /// same colors as a patch block so the plan reads at a glance.
202    pub fn render_revert_plan(&self, lines: &[RevertLine]) -> io::Result<()> {
203        let width = lines
204            .iter()
205            .map(|line| line.action.len())
206            .max()
207            .unwrap_or_default();
208        self.with_state(|state| {
209            state.flush_generation_pending()?;
210            state.finish_generation();
211            state.finish_open_lines()?;
212            for line in lines {
213                write_styled(&mut state.writer, state.color, "    ", Color::Reset, false)?;
214                write_styled(
215                    &mut state.writer,
216                    state.color,
217                    &line.action,
218                    line.color(),
219                    true,
220                )?;
221                write_styled(
222                    &mut state.writer,
223                    state.color,
224                    &" ".repeat(width - line.action.len() + 1),
225                    Color::Reset,
226                    false,
227                )?;
228                write_styled(
229                    &mut state.writer,
230                    state.color,
231                    &line.path,
232                    if line.blocked {
233                        Color::DarkGrey
234                    } else {
235                        Color::Reset
236                    },
237                    false,
238                )?;
239                write_styled(
240                    &mut state.writer,
241                    state.color,
242                    &format!("  {}\n", line.detail),
243                    Color::DarkGrey,
244                    false,
245                )?;
246            }
247            state.writer.flush()
248        })
249    }
250
251    pub fn render_status(&self, message: &str) -> io::Result<()> {
252        self.with_state(|state| {
253            state.flush_generation_pending()?;
254            state.finish_generation();
255            state.finish_open_lines()?;
256            write_styled(
257                &mut state.writer,
258                state.color,
259                &format!("· {message}\n"),
260                Color::DarkGrey,
261                false,
262            )?;
263            state.writer.flush()
264        })
265    }
266
267    /// Shows a labelled spinner in the transient region while something is being
268    /// awaited. Reuses the generation spinner, so nothing is drawn when stdout is
269    /// not a terminal and nothing reaches the scrollback either way.
270    pub fn begin_transient(&self, label: &str) -> io::Result<()> {
271        self.with_state(|state| {
272            state.start_generation()?;
273            state.update_generation_message(label);
274            Ok(())
275        })
276    }
277
278    pub fn end_transient(&self) -> io::Result<()> {
279        self.with_state(|state| {
280            state.finish_generation();
281            Ok(())
282        })
283    }
284
285    pub fn render_resumed_history(&self, items: &[ConversationItem]) -> io::Result<()> {
286        self.begin_turn()?;
287        self.with_state(|state| {
288            write_styled(
289                &mut state.writer,
290                state.color,
291                "──────── Resumed conversation ────────\n",
292                Color::DarkGrey,
293                true,
294            )?;
295            state.writer.flush()
296        })?;
297        for item in items {
298            match item.role {
299                Role::User => {
300                    let text = item
301                        .blocks
302                        .iter()
303                        .filter_map(|block| match block {
304                            ContentBlock::Text(text) => Some(text.as_str()),
305                            _ => None,
306                        })
307                        .collect::<Vec<_>>()
308                        .join("\n");
309                    let text = without_request_scaffolding(&text);
310                    if !text.is_empty() {
311                        self.render_user(text)?;
312                    }
313                }
314                Role::Assistant => {
315                    self.begin_turn()?;
316                    for block in &item.blocks {
317                        match block {
318                            ContentBlock::Reasoning(delta) => {
319                                self.render_event(StreamEvent::ReasoningDelta {
320                                    delta: delta.clone(),
321                                })?
322                            }
323                            ContentBlock::Text(delta) => {
324                                self.render_event(StreamEvent::TextDelta {
325                                    delta: delta.clone(),
326                                })?
327                            }
328                            ContentBlock::ToolCall(call) => {
329                                self.render_event(StreamEvent::ToolCallStart {
330                                    id: call.id.clone(),
331                                    name: call.name.clone(),
332                                })?;
333                                self.render_event(StreamEvent::ToolCallArgsDelta {
334                                    id: call.id.clone(),
335                                    delta: call.arguments.clone(),
336                                })?;
337                                self.render_event(StreamEvent::ToolCallEnd {
338                                    id: call.id.clone(),
339                                })?;
340                            }
341                            ContentBlock::ToolResult(_) => {}
342                        }
343                    }
344                    self.render_event(StreamEvent::Done)?;
345                }
346                Role::Tool => {
347                    for block in &item.blocks {
348                        if let ContentBlock::ToolResult(result) = block {
349                            self.render_event(StreamEvent::ToolExecutionEnd {
350                                id: result.call_id.clone(),
351                                result: result.clone(),
352                            })?;
353                        }
354                    }
355                }
356                Role::System => {}
357            }
358        }
359        Ok(())
360    }
361
362    pub fn event_sink(&self) -> EventSink {
363        let renderer = self.clone();
364        EventSink::new(move |event| {
365            let _ = renderer.render_event(event);
366        })
367    }
368
369    pub fn toggle_reasoning(&self) -> io::Result<bool> {
370        self.with_state(|state| {
371            state.finish_open_lines()?;
372            state.reasoning_visible = !state.reasoning_visible;
373            if state.reasoning_visible && !state.reasoning_buffer.is_empty() {
374                write_styled(
375                    &mut state.writer,
376                    state.color,
377                    "▾ Reasoning\n",
378                    Color::DarkGrey,
379                    true,
380                )?;
381                let buffered = state.reasoning_buffer.clone();
382                append_stream(
383                    &mut state.writer,
384                    state.color,
385                    "  ",
386                    &buffered,
387                    Color::DarkGrey,
388                    Color::DarkGrey,
389                    &mut state.reasoning_at_line_start,
390                )?;
391                state.finish_open_lines()?;
392            }
393            state.writer.flush()?;
394            Ok(state.reasoning_visible)
395        })
396    }
397
398    fn render_event(&self, event: StreamEvent) -> io::Result<()> {
399        self.with_state(|state| {
400            match event {
401                StreamEvent::GenerationStart => state.start_generation()?,
402                StreamEvent::ReasoningDelta { delta } => state.reasoning_delta(&delta)?,
403                StreamEvent::TextDelta { delta } => state.assistant_delta(&delta)?,
404                StreamEvent::ToolCallStart { id, name } => {
405                    state.flush_generation_pending()?;
406                    state.update_generation_message(&format!("generating  {name}"));
407                    state.tools.insert(
408                        id,
409                        ToolDisplay::new(
410                            name,
411                            state.limits.tool_input_max_bytes,
412                            state.limits.tool_output_max_bytes,
413                        ),
414                    );
415                }
416                StreamEvent::ToolCallArgsDelta { id, delta } => {
417                    if let Some(tool) = state.tools.get_mut(&id) {
418                        tool.arguments.push(&delta);
419                    }
420                }
421                StreamEvent::ToolCallEnd { .. } => {}
422                StreamEvent::ToolExecutionStart { id } => state.start_live_tool(&id),
423                StreamEvent::ToolExecutionOutput { id, delta } => {
424                    if let Some(tool) = state.tools.get_mut(&id) {
425                        tool.output.push(delta.as_bytes());
426                    }
427                    state.update_live_tool(&id);
428                }
429                StreamEvent::ToolExecutionEnd { id, result } => {
430                    state.render_completed_tool(&id, &result)?;
431                }
432                StreamEvent::Error { message } => {
433                    state.flush_generation_pending()?;
434                    state.finish_generation();
435                    state.finish_open_lines()?;
436                    write_styled(
437                        &mut state.writer,
438                        state.color,
439                        &format!("× {message}\n"),
440                        Color::DarkRed,
441                        true,
442                    )?;
443                }
444                StreamEvent::Done => {
445                    state.flush_generation_pending()?;
446                    state.finish_generation();
447                    state.finish_open_lines()?;
448                    state.reasoning_announced = false;
449                    state.reasoning_buffer.clear();
450                    state.reasoning_pending.clear();
451                    state.assistant_buffer.clear();
452                }
453                StreamEvent::Usage(_) => {}
454            }
455            state.writer.flush()
456        })
457    }
458
459    fn with_state<T>(&self, action: impl FnOnce(&mut State) -> io::Result<T>) -> io::Result<T> {
460        let mut state = self
461            .inner
462            .lock()
463            .map_err(|_| io::Error::other("renderer lock poisoned"))?;
464        action(&mut state)
465    }
466}
467
468impl State {
469    fn start_generation(&mut self) -> io::Result<()> {
470        self.finish_open_lines()?;
471        if let Some(live) = &mut self.live {
472            live.reserve_generation();
473            live.start(GENERATION_ID, "thinking".into());
474        }
475        Ok(())
476    }
477
478    fn finish_generation(&mut self) {
479        if let Some(live) = &mut self.live
480            && live.entries.contains_key(GENERATION_ID)
481        {
482            live.finish(GENERATION_CONTENT_ID);
483            live.finish(GENERATION_ID);
484            live.restore_for_commit();
485        }
486    }
487
488    fn generation_active(&self) -> bool {
489        self.live
490            .as_ref()
491            .is_some_and(|live| live.entries.contains_key(GENERATION_ID))
492    }
493
494    fn update_generation_message(&mut self, message: &str) {
495        if let Some(live) = &mut self.live
496            && let Some(progress) = live.entries.get(GENERATION_ID)
497        {
498            progress.set_message(message.to_owned());
499            progress.tick();
500        }
501    }
502
503    fn reasoning_delta(&mut self, delta: &str) -> io::Result<()> {
504        self.reasoning_buffer.push_str(delta);
505        if self.generation_active() {
506            self.announce_generation_reasoning()?;
507            self.update_generation_message("thinking");
508            if self.reasoning_visible {
509                self.reasoning_pending.push_str(delta);
510                let complete = take_complete_lines(&mut self.reasoning_pending);
511                let pending = self.reasoning_pending.clone();
512                self.set_generation_partial("  ", &pending, Color::DarkGrey, Color::DarkGrey);
513                if !complete.is_empty() {
514                    self.commit_generation_text("  ", &complete, Color::DarkGrey, Color::DarkGrey)?;
515                }
516            }
517            return Ok(());
518        }
519        if !self.reasoning_announced {
520            self.finish_open_lines()?;
521            write_styled(
522                &mut self.writer,
523                self.color,
524                if self.reasoning_visible {
525                    "▾ Reasoning\n"
526                } else {
527                    "▸ Reasoning\n"
528                },
529                Color::DarkGrey,
530                true,
531            )?;
532            self.reasoning_announced = true;
533        }
534        if self.reasoning_visible {
535            append_stream(
536                &mut self.writer,
537                self.color,
538                "  ",
539                delta,
540                Color::DarkGrey,
541                Color::DarkGrey,
542                &mut self.reasoning_at_line_start,
543            )?;
544        }
545        Ok(())
546    }
547
548    fn assistant_delta(&mut self, delta: &str) -> io::Result<()> {
549        if self.generation_active() {
550            self.flush_generation_reasoning()?;
551            self.update_generation_message("generating");
552            self.assistant_buffer.push_str(delta);
553            let complete = take_complete_lines(&mut self.assistant_buffer);
554            let pending = self.assistant_buffer.clone();
555            self.set_generation_partial("│ ", &pending, Color::DarkGreen, Color::Reset);
556            if !complete.is_empty() {
557                self.commit_generation_text("│ ", &complete, Color::DarkGreen, Color::Reset)?;
558            }
559            return Ok(());
560        }
561        if !self.reasoning_at_line_start {
562            writeln!(self.writer)?;
563            self.reasoning_at_line_start = true;
564        }
565        append_stream(
566            &mut self.writer,
567            self.color,
568            "│ ",
569            delta,
570            Color::DarkGreen,
571            Color::Reset,
572            &mut self.assistant_at_line_start,
573        )
574    }
575
576    fn announce_generation_reasoning(&mut self) -> io::Result<()> {
577        if self.reasoning_announced {
578            return Ok(());
579        }
580        self.reasoning_announced = true;
581        let active = self.generation_active();
582        if active && let Some(live) = &mut self.live {
583            live.restore_for_commit();
584        }
585        write_styled(
586            &mut self.writer,
587            self.color,
588            if self.reasoning_visible {
589                "▾ Reasoning\n"
590            } else {
591                "▸ Reasoning\n"
592            },
593            Color::DarkGrey,
594            true,
595        )?;
596        if active && let Some(live) = &mut self.live {
597            live.resume_after_commit();
598        }
599        Ok(())
600    }
601
602    fn set_generation_partial(
603        &mut self,
604        prefix: &str,
605        pending: &str,
606        prefix_color: Color,
607        content_color: Color,
608    ) {
609        let Some(live) = &mut self.live else {
610            return;
611        };
612        if pending.is_empty() {
613            live.set_generation_content(None);
614            return;
615        }
616        let content = clean_terminal_line(pending);
617        let available = live
618            .terminal_width
619            .saturating_sub(UnicodeWidthStr::width(prefix));
620        let content = truncate_display_width(&content, available);
621        let message = if self.color {
622            format!(
623                "{}{}",
624                prefix.with(prefix_color),
625                content.with(content_color)
626            )
627        } else {
628            format!("{prefix}{content}")
629        };
630        live.set_generation_content(Some(message));
631    }
632
633    fn commit_generation_text(
634        &mut self,
635        prefix: &str,
636        text: &str,
637        prefix_color: Color,
638        content_color: Color,
639    ) -> io::Result<()> {
640        let active = self.generation_active();
641        if active && let Some(live) = &mut self.live {
642            live.restore_for_commit();
643        }
644        let mut at_line_start = true;
645        append_stream(
646            &mut self.writer,
647            self.color,
648            prefix,
649            text,
650            prefix_color,
651            content_color,
652            &mut at_line_start,
653        )?;
654        if !at_line_start {
655            writeln!(self.writer)?;
656        }
657        if active && let Some(live) = &mut self.live {
658            live.resume_after_commit();
659        }
660        Ok(())
661    }
662
663    fn flush_generation_reasoning(&mut self) -> io::Result<()> {
664        let pending = std::mem::take(&mut self.reasoning_pending);
665        if pending.is_empty() {
666            return Ok(());
667        }
668        if let Some(live) = &mut self.live {
669            live.set_generation_content(None);
670        }
671        self.commit_generation_text("  ", &pending, Color::DarkGrey, Color::DarkGrey)
672    }
673
674    fn flush_generation_pending(&mut self) -> io::Result<()> {
675        if !self.generation_active() {
676            return Ok(());
677        }
678        self.flush_generation_reasoning()?;
679        let pending = std::mem::take(&mut self.assistant_buffer);
680        if pending.is_empty() {
681            return Ok(());
682        }
683        if let Some(live) = &mut self.live {
684            live.set_generation_content(None);
685        }
686        self.commit_generation_text("│ ", &pending, Color::DarkGreen, Color::Reset)
687    }
688
689    fn finish_open_lines(&mut self) -> io::Result<()> {
690        for at_line_start in [
691            &mut self.reasoning_at_line_start,
692            &mut self.assistant_at_line_start,
693        ] {
694            if !*at_line_start {
695                writeln!(self.writer)?;
696                *at_line_start = true;
697            }
698        }
699        Ok(())
700    }
701
702    fn start_live_tool(&mut self, id: &str) {
703        let Some(tool) = self.tools.get(id) else {
704            return;
705        };
706        if let Some(live) = &mut self.live {
707            live.reserve(self.tools.len());
708            let message =
709                live_tool_message(tool, live.visible_lines, live.terminal_width, self.color);
710            live.start(id, message);
711        }
712    }
713
714    fn update_live_tool(&mut self, id: &str) {
715        let Some(tool) = self.tools.get(id) else {
716            return;
717        };
718        if let Some(live) = &mut self.live {
719            let message =
720                live_tool_message(tool, live.visible_lines, live.terminal_width, self.color);
721            live.update(id, message);
722        }
723    }
724
725    fn finish_live_tool(&mut self, id: &str) {
726        if let Some(live) = &mut self.live {
727            live.finish(id);
728        }
729    }
730
731    fn render_completed_tool(&mut self, id: &str, result: &ToolResult) -> io::Result<()> {
732        let has_live = self.live.is_some();
733        self.finish_live_tool(id);
734        if has_live && let Some(live) = &mut self.live {
735            live.restore_for_commit();
736        }
737        self.render_tool_result(id, result)?;
738        if has_live && let Some(live) = &mut self.live {
739            live.resume_after_commit();
740        }
741        Ok(())
742    }
743
744    fn render_tool_input(
745        &mut self,
746        id: &str,
747        symbol: &str,
748        color: Color,
749        summary: &str,
750    ) -> io::Result<()> {
751        let Some(tool) = self.tools.get_mut(id) else {
752            return Ok(());
753        };
754        let name = tool.name.clone();
755        let raw_arguments = tool.arguments.text.clone();
756        let truncated = tool.arguments.truncated;
757        self.finish_open_lines()?;
758        match name.as_str() {
759            "read" => {
760                let detail = read_detail(&raw_arguments);
761                write_tool_completion(
762                    &mut self.writer,
763                    self.color,
764                    symbol,
765                    color,
766                    &name,
767                    &format!("{detail} · {summary}"),
768                )
769            }
770            "apply_patch" => {
771                write_tool_completion(&mut self.writer, self.color, symbol, color, &name, summary)?;
772                render_patch_operations(
773                    &mut self.writer,
774                    self.color,
775                    &raw_arguments,
776                    self.limits.tool_input_max_bytes,
777                    truncated,
778                    self.limits.patch_diff_max_lines,
779                )
780            }
781            "bash" => {
782                let summary = match bash_timeout_label(&raw_arguments) {
783                    Some(timeout) => format!("{summary} · {timeout}"),
784                    None => summary.to_owned(),
785                };
786                write_tool_completion(
787                    &mut self.writer,
788                    self.color,
789                    symbol,
790                    color,
791                    &name,
792                    &summary,
793                )?;
794                let input = limited_text(
795                    &format_tool_input(&name, &raw_arguments),
796                    self.limits.tool_input_max_bytes,
797                    8,
798                    false,
799                );
800                render_bash_command(&mut self.writer, self.color, &input, truncated)
801            }
802            _ => {
803                write_tool_header(&mut self.writer, self.color, &name, None)?;
804                let input = limited_text(
805                    &format_tool_input(&name, &raw_arguments),
806                    self.limits.tool_input_max_bytes,
807                    8,
808                    false,
809                );
810                render_section(&mut self.writer, self.color, "input", &input, truncated)
811            }
812        }
813    }
814
815    fn render_tool_result(&mut self, id: &str, result: &ToolResult) -> io::Result<()> {
816        let name = self
817            .tools
818            .get(id)
819            .map(|tool| tool.name.clone())
820            .unwrap_or_else(|| "tool".to_owned());
821        let exit_code = parse_exit_code(&result.output);
822        let bash_interrupted = name == "bash"
823            && (result.output.contains("[bash cancelled]")
824                || result.output.contains("[bash timed out after "));
825        let failed = result.is_error || bash_interrupted || exit_code.is_some_and(|code| code != 0);
826        let symbol = if failed { "×" } else { "✓" };
827        let color = if failed {
828            Color::DarkRed
829        } else {
830            Color::DarkGreen
831        };
832        let summary = tool_summary(&name, result, exit_code);
833        self.render_tool_input(id, symbol, color, &summary)?;
834        let output = match self.tools.get(id) {
835            Some(tool) => {
836                if tool.output.total_bytes > 0 {
837                    tool.output.limited(self.limits.tool_output_max_lines)
838                } else if tool.name == "bash" {
839                    let output = bash_visible_output(&result.output);
840                    limited_text(
841                        &output,
842                        self.limits.tool_output_max_bytes,
843                        self.limits.tool_output_max_lines,
844                        true,
845                    )
846                } else {
847                    limited_text(
848                        &result.output,
849                        self.limits.tool_output_max_bytes,
850                        self.limits.tool_output_max_lines,
851                        tool.name == "bash",
852                    )
853                }
854            }
855            None => limited_text(
856                &result.output,
857                self.limits.tool_output_max_bytes,
858                self.limits.tool_output_max_lines,
859                false,
860            ),
861        };
862        match name.as_str() {
863            "bash" => render_bash_output(&mut self.writer, self.color, &output)?,
864            "read" => render_direct_output(&mut self.writer, self.color, &output, Color::Reset)?,
865            "apply_patch" if result.is_error => {
866                render_direct_output(&mut self.writer, self.color, &output, Color::DarkRed)?
867            }
868            "apply_patch" => {}
869            _ => render_section(
870                &mut self.writer,
871                self.color,
872                "output",
873                &output,
874                output.truncated,
875            )?,
876        }
877        if !matches!(name.as_str(), "bash" | "read" | "apply_patch") {
878            write_tool_completion(&mut self.writer, self.color, symbol, color, &name, &summary)?;
879        }
880        self.tools.remove(id);
881        Ok(())
882    }
883}
884
885impl ToolDisplay {
886    fn new(name: String, input_max_bytes: usize, output_max_bytes: usize) -> Self {
887        Self {
888            name,
889            arguments: BoundedInput::new(input_max_bytes.max(64 * 1024)),
890            output: TailBuffer::new(output_max_bytes),
891        }
892    }
893}
894
895impl LiveTools {
896    fn new(max_lines: usize) -> Self {
897        let terminal_width = crossterm::terminal::size()
898            .map(|(width, _)| usize::from(width))
899            .unwrap_or(80);
900        let mut live = Self::with_draw_target(
901            ProgressDrawTarget::stdout_with_hz(20),
902            max_lines,
903            terminal_width,
904        );
905        live.reserve_stdout_rows = true;
906        live
907    }
908
909    fn with_draw_target(
910        draw_target: ProgressDrawTarget,
911        max_lines: usize,
912        terminal_width: usize,
913    ) -> Self {
914        Self {
915            multi: MultiProgress::with_draw_target(draw_target),
916            entries: HashMap::new(),
917            max_lines,
918            visible_lines: max_lines,
919            terminal_width: terminal_width.max(1),
920            reserved: false,
921            reserve_stdout_rows: false,
922        }
923    }
924
925    fn reserve(&mut self, tool_count: usize) {
926        if self.reserved {
927            return;
928        }
929        let (terminal_width, terminal_height) =
930            crossterm::terminal::size().unwrap_or((self.terminal_width as u16, 24));
931        self.terminal_width = usize::from(terminal_width).max(1);
932        let available_rows = usize::from(terminal_height.saturating_sub(1));
933        let rows_per_tool = available_rows / tool_count.max(1);
934        self.visible_lines = self.max_lines.min(rows_per_tool.saturating_sub(2));
935        let desired = tool_count.saturating_mul(self.visible_lines.saturating_add(2));
936        let height = desired.min(available_rows) as u16;
937        self.reserve_rows(height);
938    }
939
940    fn reserve_generation(&mut self) {
941        self.reserve_rows(1);
942    }
943
944    fn reserve_rows(&mut self, height: u16) {
945        if self.reserved {
946            return;
947        }
948        if self.reserve_stdout_rows && height > 0 {
949            let mut stdout = io::stdout();
950            for _ in 0..height {
951                let _ = writeln!(stdout);
952            }
953            let _ = execute!(stdout, MoveUp(height));
954            let _ = execute!(stdout, SavePosition);
955            let _ = stdout.flush();
956        }
957        self.reserved = true;
958    }
959
960    fn start(&mut self, id: &str, message: String) {
961        if self.entries.contains_key(id) {
962            self.update(id, message);
963            return;
964        }
965        let progress = self.multi.add(ProgressBar::new_spinner());
966        progress.set_style(
967            ProgressStyle::with_template("{spinner:.yellow} {msg}")
968                .expect("static progress template is valid")
969                .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
970        );
971        progress.set_message(message);
972        progress.enable_steady_tick(Duration::from_millis(80));
973        progress.force_draw();
974        self.entries.insert(id.to_owned(), progress);
975    }
976
977    fn update(&mut self, id: &str, message: String) {
978        if let Some(progress) = self.entries.get(id) {
979            progress.set_message(message);
980            progress.tick();
981        }
982    }
983
984    fn set_generation_content(&mut self, message: Option<String>) {
985        match message {
986            Some(message) => {
987                if self.entries.contains_key(GENERATION_CONTENT_ID) {
988                    self.update(GENERATION_CONTENT_ID, message);
989                    return;
990                }
991                self.restore_for_commit();
992                let progress = ProgressBar::new_spinner();
993                progress.set_style(
994                    ProgressStyle::with_template("{msg}")
995                        .expect("static progress template is valid"),
996                );
997                progress.set_message(message);
998                let progress = if let Some(spinner) = self.entries.get(GENERATION_ID) {
999                    self.multi.insert_before(spinner, progress)
1000                } else {
1001                    self.multi.add(progress)
1002                };
1003                self.entries
1004                    .insert(GENERATION_CONTENT_ID.to_owned(), progress);
1005                self.resume_after_commit();
1006            }
1007            None if self.entries.contains_key(GENERATION_CONTENT_ID) => {
1008                self.finish(GENERATION_CONTENT_ID);
1009                self.restore_for_commit();
1010                self.resume_after_commit();
1011            }
1012            None => {}
1013        }
1014    }
1015
1016    fn finish(&mut self, id: &str) {
1017        if let Some(progress) = self.entries.remove(id) {
1018            progress.disable_steady_tick();
1019            self.multi.remove(&progress);
1020        }
1021    }
1022
1023    fn restore_for_commit(&mut self) {
1024        let _ = self.multi.clear();
1025        if self.reserved {
1026            if self.reserve_stdout_rows {
1027                let mut stdout = io::stdout();
1028                let _ = execute!(stdout, RestorePosition, Clear(ClearType::FromCursorDown));
1029                let _ = stdout.flush();
1030            }
1031            self.reserved = false;
1032        }
1033    }
1034
1035    fn resume_after_commit(&mut self) {
1036        if self.entries.is_empty() {
1037            return;
1038        }
1039        if self.entries.contains_key(GENERATION_ID) {
1040            let rows = 1 + usize::from(self.entries.contains_key(GENERATION_CONTENT_ID));
1041            self.reserve_rows(rows as u16);
1042        } else {
1043            self.reserve(self.entries.len());
1044        }
1045        for progress in self.entries.values() {
1046            progress.force_draw();
1047        }
1048    }
1049
1050    fn clear(&mut self) {
1051        for (_, progress) in self.entries.drain() {
1052            progress.disable_steady_tick();
1053            self.multi.remove(&progress);
1054        }
1055        self.restore_for_commit();
1056    }
1057}
1058
1059impl Drop for LiveTools {
1060    fn drop(&mut self) {
1061        self.clear();
1062    }
1063}
1064
1065impl BoundedInput {
1066    fn new(max_bytes: usize) -> Self {
1067        Self {
1068            text: String::new(),
1069            max_bytes,
1070            truncated: false,
1071        }
1072    }
1073
1074    fn push(&mut self, delta: &str) {
1075        let remaining = self.max_bytes.saturating_sub(self.text.len());
1076        let mut end = remaining.min(delta.len());
1077        while end > 0 && !delta.is_char_boundary(end) {
1078            end -= 1;
1079        }
1080        self.text.push_str(&delta[..end]);
1081        self.truncated |= end < delta.len();
1082    }
1083}
1084
1085impl TailBuffer {
1086    fn new(max_bytes: usize) -> Self {
1087        Self {
1088            bytes: Vec::new(),
1089            max_bytes,
1090            total_bytes: 0,
1091            total_lines: 0,
1092        }
1093    }
1094
1095    fn push(&mut self, bytes: &[u8]) {
1096        self.total_bytes += bytes.len();
1097        self.total_lines += bytes.iter().filter(|byte| **byte == b'\n').count();
1098        self.bytes.extend_from_slice(bytes);
1099        if self.bytes.len() > self.max_bytes {
1100            self.bytes.drain(..self.bytes.len() - self.max_bytes);
1101        }
1102    }
1103
1104    fn limited(&self, max_lines: usize) -> LimitedText {
1105        let text = String::from_utf8_lossy(&self.bytes);
1106        let mut lines = text.lines().map(ToOwned::to_owned).collect::<Vec<_>>();
1107        if lines.len() > max_lines {
1108            lines = lines.split_off(lines.len() - max_lines);
1109        }
1110        LimitedText {
1111            lines,
1112            truncated: self.total_bytes > self.bytes.len() || self.total_lines > max_lines,
1113        }
1114    }
1115}
1116
1117fn format_tool_input(name: &str, raw: &str) -> String {
1118    let Ok(value) = serde_json::from_str::<Value>(raw) else {
1119        return raw.to_owned();
1120    };
1121    match name {
1122        "read" => {
1123            let mut lines = Vec::new();
1124            if let Some(path) = value.get("path").and_then(Value::as_str) {
1125                lines.push(format!("path: {path}"));
1126            }
1127            if let Some(offset) = value.get("offset").and_then(Value::as_u64) {
1128                lines.push(format!("offset: {offset}"));
1129            }
1130            if let Some(limit) = value.get("limit").and_then(Value::as_u64) {
1131                lines.push(format!("limit: {limit}"));
1132            }
1133            lines.join("\n")
1134        }
1135        "bash" => value
1136            .get("command")
1137            .and_then(Value::as_str)
1138            .unwrap_or(raw)
1139            .to_owned(),
1140        "apply_patch" => value
1141            .get("patch")
1142            .and_then(Value::as_str)
1143            .unwrap_or(raw)
1144            .to_owned(),
1145        _ => serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.to_owned()),
1146    }
1147}
1148
1149/// The timeout a bash call asked for, when it raised its own limit. A command
1150/// that sits there for minutes should not look like a hang.
1151fn bash_timeout_label(raw: &str) -> Option<String> {
1152    let value = serde_json::from_str::<Value>(raw).ok()?;
1153    let seconds = value.get("timeout_seconds").and_then(Value::as_u64)?;
1154    Some(format!("timeout {seconds}s"))
1155}
1156
1157fn live_tool_message(
1158    tool: &ToolDisplay,
1159    max_lines: usize,
1160    terminal_width: usize,
1161    color_enabled: bool,
1162) -> String {
1163    let mut bash_command = None;
1164    let label = match tool.name.as_str() {
1165        "bash" => {
1166            let command = format_tool_input("bash", &tool.arguments.text);
1167            let mut lines = command.lines();
1168            let first_line = lines.next().unwrap_or_default();
1169            bash_command = Some(format!(
1170                "{first_line}{}",
1171                if lines.next().is_some() { "…" } else { "" }
1172            ));
1173            match bash_timeout_label(&tool.arguments.text) {
1174                Some(timeout) => format!("bash  {timeout}"),
1175                None => "bash".into(),
1176            }
1177        }
1178        "read" => format!("read  {}", read_detail(&tool.arguments.text)),
1179        "apply_patch" => {
1180            let count = patch_operations(&tool.arguments.text).len();
1181            format!(
1182                "apply_patch  {count} file{}",
1183                if count == 1 { "" } else { "s" }
1184            )
1185        }
1186        name => name.to_owned(),
1187    };
1188    let label = clean_terminal_line(&label);
1189    let label = truncate_display_width(&label, terminal_width.saturating_sub(2));
1190    let mut message = label;
1191    if let Some(command) = bash_command {
1192        let command = clean_terminal_line(&command);
1193        let command = truncate_display_width(&command, terminal_width.saturating_sub(4));
1194        message.push('\n');
1195        if color_enabled {
1196            message.push_str("  \x1b[1;36m$\x1b[0m ");
1197        } else {
1198            message.push_str("  $ ");
1199        }
1200        message.push_str(&command);
1201    }
1202    let output = tool.output.limited(max_lines);
1203    if max_lines == 0 {
1204        return message;
1205    }
1206    for (index, line) in output.lines.into_iter().enumerate() {
1207        let prefix = if output.truncated && index == 0 {
1208            "  … "
1209        } else {
1210            "  │ "
1211        };
1212        let line = clean_terminal_line(&line);
1213        let line = truncate_display_width(&line, terminal_width.saturating_sub(4));
1214        message.push('\n');
1215        message.push_str(prefix);
1216        message.push_str(&line);
1217    }
1218    message
1219}
1220
1221fn bash_visible_output(raw: &str) -> String {
1222    let mut lines = raw.lines().collect::<Vec<_>>();
1223    if lines
1224        .last()
1225        .is_some_and(|line| line.starts_with("[exit code: ") && line.ends_with(']'))
1226    {
1227        lines.pop();
1228    }
1229    if lines.last().is_some_and(|line| {
1230        *line == "[bash cancelled]"
1231            || (line.starts_with("[bash timed out after ") && line.ends_with(']'))
1232    }) {
1233        lines.pop();
1234    }
1235    while lines.last().is_some_and(|line| line.is_empty()) {
1236        lines.pop();
1237    }
1238    lines.join("\n")
1239}
1240
1241fn clean_terminal_line(value: &str) -> String {
1242    String::from_utf8_lossy(&strip_ansi_escapes::strip(value.as_bytes()))
1243        .chars()
1244        .map(|character| {
1245            if character.is_control() {
1246                ' '
1247            } else {
1248                character
1249            }
1250        })
1251        .collect()
1252}
1253
1254fn truncate_display_width(value: &str, max_width: usize) -> String {
1255    if UnicodeWidthStr::width(value) <= max_width {
1256        return value.to_owned();
1257    }
1258    if max_width == 0 {
1259        return String::new();
1260    }
1261    let content_width = max_width.saturating_sub(1);
1262    let mut result = String::new();
1263    let mut width = 0;
1264    for character in value.chars() {
1265        let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
1266        if width + character_width > content_width {
1267            break;
1268        }
1269        result.push(character);
1270        width += character_width;
1271    }
1272    result.push('…');
1273    result
1274}
1275
1276fn write_tool_header(
1277    writer: &mut dyn Write,
1278    color_enabled: bool,
1279    name: &str,
1280    detail: Option<&str>,
1281) -> io::Result<()> {
1282    write_styled(
1283        writer,
1284        color_enabled,
1285        &format!("● {name}"),
1286        Color::DarkYellow,
1287        true,
1288    )?;
1289    if let Some(detail) = detail.filter(|detail| !detail.is_empty()) {
1290        write_styled(
1291            writer,
1292            color_enabled,
1293            &format!("  {detail}"),
1294            Color::Reset,
1295            false,
1296        )?;
1297    }
1298    writeln!(writer)
1299}
1300
1301fn write_tool_completion(
1302    writer: &mut dyn Write,
1303    color_enabled: bool,
1304    symbol: &str,
1305    color: Color,
1306    name: &str,
1307    summary: &str,
1308) -> io::Result<()> {
1309    write_styled(
1310        writer,
1311        color_enabled,
1312        &format!("{symbol} {name}  {summary}\n"),
1313        color,
1314        true,
1315    )
1316}
1317
1318fn read_detail(raw_arguments: &str) -> String {
1319    let value = serde_json::from_str::<Value>(raw_arguments).ok();
1320    let path = value
1321        .as_ref()
1322        .and_then(|value| value.get("path"))
1323        .and_then(Value::as_str)
1324        .unwrap_or("?");
1325    let offset = value
1326        .as_ref()
1327        .and_then(|value| value.get("offset"))
1328        .and_then(Value::as_u64)
1329        .unwrap_or(0);
1330    let limit = value
1331        .as_ref()
1332        .and_then(|value| value.get("limit"))
1333        .and_then(Value::as_u64);
1334    match (offset, limit) {
1335        (0, None) => path.to_owned(),
1336        (offset, None) => format!("{path}  from line {}", offset + 1),
1337        (offset, Some(limit)) => format!("{path}  lines {}-{}", offset + 1, offset + limit),
1338    }
1339}
1340
1341fn render_patch_operations(
1342    writer: &mut dyn Write,
1343    color_enabled: bool,
1344    raw_arguments: &str,
1345    max_bytes: usize,
1346    capture_truncated: bool,
1347    diff_max_lines: usize,
1348) -> io::Result<()> {
1349    let operations = patch_operations(raw_arguments).join("\n");
1350    let limited = limited_text(&operations, max_bytes, 12, false);
1351    for line in &limited.lines {
1352        let (operation, path) = line.split_once(' ').unwrap_or(("?", line));
1353        let color = match operation {
1354            "A" => Color::DarkGreen,
1355            "M" => Color::DarkYellow,
1356            "D" => Color::DarkRed,
1357            _ => Color::Reset,
1358        };
1359        write_styled(
1360            writer,
1361            color_enabled,
1362            &format!("  {operation} "),
1363            color,
1364            true,
1365        )?;
1366        write_styled(
1367            writer,
1368            color_enabled,
1369            &format!("{path}\n"),
1370            Color::Reset,
1371            false,
1372        )?;
1373    }
1374    if capture_truncated || limited.truncated {
1375        write_styled(
1376            writer,
1377            color_enabled,
1378            "  … patch file list truncated\n",
1379            Color::DarkYellow,
1380            false,
1381        )?;
1382    }
1383    render_patch_diff(writer, color_enabled, raw_arguments, diff_max_lines)
1384}
1385
1386/// Shows the hunks that were applied. The patch text the model sent *is* the
1387/// diff, so no diffing is needed and nothing can drift between what is shown and
1388/// what was written.
1389fn render_patch_diff(
1390    writer: &mut dyn Write,
1391    color_enabled: bool,
1392    raw_arguments: &str,
1393    max_lines: usize,
1394) -> io::Result<()> {
1395    if max_lines == 0 {
1396        return Ok(());
1397    }
1398    let patch = patch_text(raw_arguments);
1399    let mut shown = 0;
1400    let mut truncated = false;
1401    for line in patch.lines() {
1402        if line.starts_with("*** ") {
1403            continue;
1404        }
1405        if shown == max_lines {
1406            truncated = true;
1407            break;
1408        }
1409        let color = match line.as_bytes().first() {
1410            Some(b'+') => Color::DarkGreen,
1411            Some(b'-') => Color::DarkRed,
1412            Some(b'@') => Color::DarkGrey,
1413            _ => Color::Reset,
1414        };
1415        write_styled(
1416            writer,
1417            color_enabled,
1418            &format!("    {line}\n"),
1419            color,
1420            false,
1421        )?;
1422        shown += 1;
1423    }
1424    if truncated {
1425        write_styled(
1426            writer,
1427            color_enabled,
1428            "    … diff truncated\n",
1429            Color::DarkYellow,
1430            false,
1431        )?;
1432    }
1433    Ok(())
1434}
1435
1436fn patch_text(raw_arguments: &str) -> String {
1437    serde_json::from_str::<Value>(raw_arguments)
1438        .ok()
1439        .and_then(|value| {
1440            value
1441                .get("patch")
1442                .and_then(Value::as_str)
1443                .map(ToOwned::to_owned)
1444        })
1445        .unwrap_or_default()
1446}
1447
1448fn patch_operations(raw_arguments: &str) -> Vec<String> {
1449    patch_text(raw_arguments)
1450        .lines()
1451        .filter_map(|line| {
1452            line.strip_prefix("*** Update File: ")
1453                .map(|path| ('M', path))
1454                .or_else(|| line.strip_prefix("*** Add File: ").map(|path| ('A', path)))
1455                .or_else(|| {
1456                    line.strip_prefix("*** Delete File: ")
1457                        .map(|path| ('D', path))
1458                })
1459        })
1460        .map(|(operation, path)| format!("{operation} {path}"))
1461        .collect()
1462}
1463
1464fn limited_text(raw: &str, max_bytes: usize, max_lines: usize, tail: bool) -> LimitedText {
1465    let mut truncated = raw.len() > max_bytes;
1466    let bounded = if raw.len() <= max_bytes {
1467        raw
1468    } else if tail {
1469        let mut start = raw.len() - max_bytes;
1470        while start < raw.len() && !raw.is_char_boundary(start) {
1471            start += 1;
1472        }
1473        &raw[start..]
1474    } else {
1475        let mut end = max_bytes;
1476        while end > 0 && !raw.is_char_boundary(end) {
1477            end -= 1;
1478        }
1479        &raw[..end]
1480    };
1481    let all_lines = bounded.lines().map(ToOwned::to_owned).collect::<Vec<_>>();
1482    truncated |= all_lines.len() > max_lines;
1483    let lines = if all_lines.len() <= max_lines {
1484        all_lines
1485    } else if tail {
1486        all_lines[all_lines.len() - max_lines..].to_vec()
1487    } else {
1488        all_lines[..max_lines].to_vec()
1489    };
1490    LimitedText { lines, truncated }
1491}
1492
1493fn render_section(
1494    writer: &mut dyn Write,
1495    color_enabled: bool,
1496    label: &str,
1497    content: &LimitedText,
1498    truncated: bool,
1499) -> io::Result<()> {
1500    write_styled(
1501        writer,
1502        color_enabled,
1503        &format!("  {label}\n"),
1504        Color::DarkGrey,
1505        true,
1506    )?;
1507    for line in &content.lines {
1508        write_styled(writer, color_enabled, "  │ ", Color::DarkGrey, false)?;
1509        write_styled(
1510            writer,
1511            color_enabled,
1512            &format!("{line}\n"),
1513            Color::Reset,
1514            false,
1515        )?;
1516    }
1517    if truncated || content.truncated {
1518        write_styled(
1519            writer,
1520            color_enabled,
1521            &format!("  … {label} truncated\n"),
1522            Color::DarkYellow,
1523            false,
1524        )?;
1525    }
1526    Ok(())
1527}
1528
1529fn render_bash_command(
1530    writer: &mut dyn Write,
1531    color_enabled: bool,
1532    content: &LimitedText,
1533    truncated: bool,
1534) -> io::Result<()> {
1535    for (index, line) in content.lines.iter().enumerate() {
1536        let prompt = if index == 0 { "  $ " } else { "  > " };
1537        write_styled(writer, color_enabled, prompt, Color::DarkCyan, true)?;
1538        write_styled(
1539            writer,
1540            color_enabled,
1541            &format!("{line}\n"),
1542            Color::Reset,
1543            false,
1544        )?;
1545    }
1546    if truncated || content.truncated {
1547        write_styled(
1548            writer,
1549            color_enabled,
1550            "  … command truncated\n",
1551            Color::DarkYellow,
1552            false,
1553        )?;
1554    }
1555    Ok(())
1556}
1557
1558fn render_bash_output(
1559    writer: &mut dyn Write,
1560    color_enabled: bool,
1561    content: &LimitedText,
1562) -> io::Result<()> {
1563    if content.lines.is_empty() {
1564        write_styled(writer, color_enabled, "  ", Color::DarkGrey, false)?;
1565        if color_enabled {
1566            writeln!(
1567                writer,
1568                "{}",
1569                "(no output)"
1570                    .with(Color::DarkGrey)
1571                    .attribute(Attribute::Italic)
1572            )?;
1573        } else {
1574            writeln!(writer, "(no output)")?;
1575        }
1576    }
1577    for line in &content.lines {
1578        write_styled(writer, color_enabled, "  │ ", Color::DarkGrey, false)?;
1579        write_styled(
1580            writer,
1581            color_enabled,
1582            &format!("{line}\n"),
1583            Color::Reset,
1584            false,
1585        )?;
1586    }
1587    if content.truncated {
1588        write_styled(
1589            writer,
1590            color_enabled,
1591            "  … output truncated\n",
1592            Color::DarkYellow,
1593            false,
1594        )?;
1595    }
1596    Ok(())
1597}
1598
1599fn render_direct_output(
1600    writer: &mut dyn Write,
1601    color_enabled: bool,
1602    content: &LimitedText,
1603    content_color: Color,
1604) -> io::Result<()> {
1605    for line in &content.lines {
1606        write_styled(writer, color_enabled, "  │ ", Color::DarkGrey, false)?;
1607        write_styled(
1608            writer,
1609            color_enabled,
1610            &format!("{line}\n"),
1611            content_color,
1612            false,
1613        )?;
1614    }
1615    if content.truncated {
1616        write_styled(
1617            writer,
1618            color_enabled,
1619            "  … output truncated\n",
1620            Color::DarkYellow,
1621            false,
1622        )?;
1623    }
1624    Ok(())
1625}
1626
1627fn parse_exit_code(output: &str) -> Option<i32> {
1628    output.lines().rev().find_map(|line| {
1629        line.trim()
1630            .strip_prefix("[exit code: ")?
1631            .strip_suffix(']')?
1632            .parse()
1633            .ok()
1634    })
1635}
1636
1637fn tool_summary(name: &str, result: &ToolResult, exit_code: Option<i32>) -> String {
1638    if name == "bash" && result.output.contains("[bash cancelled]") {
1639        return "cancelled".into();
1640    }
1641    if name == "bash" && result.output.contains("[bash timed out after ") {
1642        return "timed out".into();
1643    }
1644    if let Some(exit_code) = exit_code {
1645        return format!("exit {exit_code}");
1646    }
1647    if result.is_error {
1648        return "failed".into();
1649    }
1650    match name {
1651        "read" => {
1652            let lines = result
1653                .output
1654                .lines()
1655                .filter(|line| {
1656                    line.split_once(": ")
1657                        .is_some_and(|(number, _)| number.parse::<usize>().is_ok())
1658                })
1659                .count();
1660            format!("{lines} line{}", if lines == 1 { "" } else { "s" })
1661        }
1662        "apply_patch" => patch_result_summary(&result.output),
1663        _ => "done".into(),
1664    }
1665}
1666
1667fn patch_result_summary(output: &str) -> String {
1668    let mut files = 0usize;
1669    let mut added = 0usize;
1670    let mut removed = 0usize;
1671    for line in output.lines() {
1672        let Some((_, counts)) = line.rsplit_once(" (+") else {
1673            continue;
1674        };
1675        let Some((added_text, removed_text)) = counts
1676            .strip_suffix(')')
1677            .and_then(|counts| counts.split_once(" -"))
1678        else {
1679            continue;
1680        };
1681        let (Ok(line_added), Ok(line_removed)) =
1682            (added_text.parse::<usize>(), removed_text.parse::<usize>())
1683        else {
1684            continue;
1685        };
1686        files += 1;
1687        added += line_added;
1688        removed += line_removed;
1689    }
1690    if files == 0 {
1691        format!("{} files", output.lines().count())
1692    } else {
1693        format!("{files} files  +{added} -{removed}")
1694    }
1695}
1696
1697fn append_stream(
1698    writer: &mut dyn Write,
1699    color_enabled: bool,
1700    prefix: &str,
1701    delta: &str,
1702    prefix_color: Color,
1703    content_color: Color,
1704    at_line_start: &mut bool,
1705) -> io::Result<()> {
1706    for segment in delta.split_inclusive('\n') {
1707        if *at_line_start {
1708            write_styled(writer, color_enabled, prefix, prefix_color, false)?;
1709        }
1710        write_styled(writer, color_enabled, segment, content_color, false)?;
1711        *at_line_start = segment.ends_with('\n');
1712    }
1713    Ok(())
1714}
1715
1716fn take_complete_lines(pending: &mut String) -> String {
1717    let Some(last_newline) = pending.rfind('\n') else {
1718        return String::new();
1719    };
1720    pending.drain(..=last_newline).collect()
1721}
1722
1723fn write_prefixed_block(
1724    writer: &mut dyn Write,
1725    color_enabled: bool,
1726    prefix: &str,
1727    content: &str,
1728    prefix_color: Color,
1729    prefix_bold: bool,
1730    content_color: Color,
1731) -> io::Result<()> {
1732    for line in content.lines() {
1733        write_styled(writer, color_enabled, prefix, prefix_color, prefix_bold)?;
1734        write_styled(writer, color_enabled, line, content_color, false)?;
1735        writeln!(writer)?;
1736    }
1737    if content.is_empty() {
1738        writeln!(writer)?;
1739    }
1740    Ok(())
1741}
1742
1743/// Strips the target list the runtime prepends to a user message. It is part of
1744/// the stored item because the model needs it again on resume, but replaying it
1745/// would show the user machine scaffolding as their own words.
1746///
1747/// Only this block is stripped. A piped-stdin block contains blank lines of its
1748/// own, so where it ends cannot be determined without guessing, and a wrong
1749/// guess would hide part of the message instead.
1750fn without_request_scaffolding(text: &str) -> &str {
1751    const HEADER: &str = "Files provided with this request:\n";
1752    let Some(rest) = text.strip_prefix(HEADER) else {
1753        return text;
1754    };
1755    let mut cursor = rest;
1756    while let Some((line, tail)) = cursor.split_once('\n') {
1757        if line.starts_with("- ") {
1758            cursor = tail;
1759            continue;
1760        }
1761        // The blank line that separated this block from the next section.
1762        return if line.is_empty() { tail } else { cursor };
1763    }
1764    cursor
1765}
1766
1767fn write_styled(
1768    writer: &mut dyn Write,
1769    color_enabled: bool,
1770    text: &str,
1771    color: Color,
1772    bold: bool,
1773) -> io::Result<()> {
1774    if !color_enabled {
1775        return write!(writer, "{text}");
1776    }
1777    let styled = text.with(color);
1778    if bold {
1779        write!(writer, "{}", styled.attribute(Attribute::Bold))
1780    } else {
1781        write!(writer, "{styled}")
1782    }
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use indicatif::{InMemoryTerm, ProgressDrawTarget};
1788    use unicode_width::UnicodeWidthStr;
1789
1790    use super::{GENERATION_ID, LiveTools, ToolDisplay, live_tool_message};
1791
1792    #[test]
1793    fn live_parallel_tools_keep_only_the_latest_output_lines() {
1794        let terminal = InMemoryTerm::new(12, 100);
1795        let target = ProgressDrawTarget::term_like(Box::new(terminal.clone()));
1796        let mut live = LiveTools::with_draw_target(target, 2, 100);
1797        let mut bash = ToolDisplay::new("bash".into(), 1024, 4096);
1798        bash.arguments.push(r#"{"command":"cargo test"}"#);
1799        bash.output.push(
1800            (0..6)
1801                .map(|index| format!("line {index}\n"))
1802                .collect::<String>()
1803                .as_bytes(),
1804        );
1805        live.start("bash", live_tool_message(&bash, 2, 100, false));
1806
1807        let mut read = ToolDisplay::new("read".into(), 1024, 4096);
1808        read.arguments.push(r#"{"path":"src/lib.rs"}"#);
1809        live.start("read", live_tool_message(&read, 2, 100, false));
1810        let contents = terminal.contents();
1811        assert!(contents.contains("  $ cargo test"), "{contents}");
1812        assert!(contents.contains("read  src/lib.rs"), "{contents}");
1813        assert!(contents.contains("line 4"), "{contents}");
1814        assert!(contents.contains("line 5"), "{contents}");
1815        assert!(!contents.contains("line 0"), "{contents}");
1816
1817        live.finish("bash");
1818        live.update("read", live_tool_message(&read, 2, 100, false));
1819        let contents = terminal.contents();
1820        assert!(!contents.contains("  $ cargo test"), "{contents}");
1821        assert!(contents.contains("read  src/lib.rs"), "{contents}");
1822    }
1823
1824    #[test]
1825    fn live_parallel_tools_without_output_are_adjacent() {
1826        let terminal = InMemoryTerm::new(30, 100);
1827        let target = ProgressDrawTarget::term_like(Box::new(terminal.clone()));
1828        let mut live = LiveTools::with_draw_target(target, 6, 100);
1829        for index in 0..3 {
1830            let mut bash = ToolDisplay::new("bash".into(), 1024, 4096);
1831            bash.arguments.push(r#"{"command":"sleep 20"}"#);
1832            live.start(
1833                &format!("bash-{index}"),
1834                live_tool_message(&bash, 6, 100, false),
1835            );
1836        }
1837
1838        let contents = terminal.contents();
1839        let positions = contents
1840            .lines()
1841            .enumerate()
1842            .filter_map(|(index, line)| line.contains("  $ sleep 20").then_some(index))
1843            .collect::<Vec<_>>();
1844        assert_eq!(positions.len(), 3, "{contents}");
1845        assert_eq!(positions[1] - positions[0], 2, "{contents}");
1846        assert_eq!(positions[2] - positions[1], 2, "{contents}");
1847    }
1848
1849    #[test]
1850    fn live_tool_lines_fit_the_terminal_and_strip_control_sequences() {
1851        let mut bash = ToolDisplay::new("bash".into(), 4096, 4096);
1852        bash.arguments
1853            .push(r#"{"command":"cd /a/very/long/directory && grep -rn pyright .gitlab-ci.yml"}"#);
1854        bash.output
1855            .push(b"old\n\x1b[31ma very long output line that must be shortened safely\x1b[0m\nlatest\rvalue\n");
1856
1857        let message = live_tool_message(&bash, 2, 40, false);
1858        let lines = message.lines().collect::<Vec<_>>();
1859        assert_eq!(lines.len(), 4, "{message:?}");
1860        assert!(UnicodeWidthStr::width(lines[0]) <= 38, "{message:?}");
1861        assert!(
1862            lines[1..]
1863                .iter()
1864                .all(|line| UnicodeWidthStr::width(*line) <= 40),
1865            "{message:?}"
1866        );
1867        assert!(!message.contains('\x1b'), "{message:?}");
1868        assert!(!message.contains('\r'), "{message:?}");
1869    }
1870
1871    #[test]
1872    fn live_bash_dollar_uses_the_completed_command_color() {
1873        let mut bash = ToolDisplay::new("bash".into(), 1024, 4096);
1874        bash.arguments.push(r#"{"command":"cargo test"}"#);
1875        let message = live_tool_message(&bash, 2, 100, true);
1876        let plain = String::from_utf8(strip_ansi_escapes::strip(message.as_bytes())).unwrap();
1877        assert_eq!(
1878            plain.lines().collect::<Vec<_>>(),
1879            ["bash", "  $ cargo test"]
1880        );
1881        let dollar = message.find('$').unwrap();
1882        assert!(message[..dollar].contains("\x1b[1;36m"), "{message:?}");
1883    }
1884
1885    #[test]
1886    fn generation_content_is_visible_above_the_spinner_before_done() {
1887        let terminal = InMemoryTerm::new(8, 100);
1888        let target = ProgressDrawTarget::term_like(Box::new(terminal.clone()));
1889        let mut live = LiveTools::with_draw_target(target, 2, 100);
1890        live.reserve_generation();
1891        live.start(GENERATION_ID, "generating".into());
1892        live.set_generation_content(Some("│ streamed before done".into()));
1893
1894        let contents = terminal.contents();
1895        let text = contents.find("│ streamed before done").unwrap();
1896        let spinner = contents.find("generating").unwrap();
1897        assert!(text < spinner, "{contents:?}");
1898    }
1899}