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}