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