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