1use std::path::Path;
6
7use crate::app::{App, Entry, ToolStatus};
8use crate::internals::StartupSection;
9use crate::renderer::row::Row;
10use crate::renderer::style::{CellStyle, Color, Span};
11use crate::{renderer, utils};
12
13const MAX_TOOL_OUTPUT_LINES: usize = 6;
15
16const MIN_TABLE_RENDER_WIDTH: usize = 24;
18
19pub const GUTTER: &str = " │ ";
21
22pub const ENTRY_RAIL: &str = "│ ";
24
25#[derive(Clone, Copy)]
26enum TableAlign {
27 Left,
28 Right,
29 Center,
30}
31
32#[derive(Clone)]
34pub struct TranscriptRowContext<'a> {
35 pub user_label: &'a str,
36 pub cwd: &'a Path,
37 pub width: usize,
38 pub entry_index: Option<usize>,
42 pub tool_group_start: bool,
44}
45
46impl TranscriptRowContext<'_> {
47 pub fn rows_for_entry(&self, entry: &Entry) -> Vec<Row> {
49 let mut rows = entry_to_rows(entry, self.user_label, self.width, self.cwd, self.tool_group_start);
50 if let Some(index) = self.entry_index {
51 let group_id = renderer::row::RowGroupId { entry_index: index };
52 for row in &mut rows {
53 row.group_id = Some(group_id);
54 }
55 }
56 rows
57 }
58
59 pub fn rows_for_entry_stable_and_live_rows(&self, entry: &Entry) -> (Vec<Row>, Vec<Row>) {
64 let rows = self.rows_for_entry(entry);
65 match entry {
66 Entry::Agent { streaming: true, .. }
67 | Entry::Reasoning { streaming: true, .. }
68 | Entry::Tool { status: ToolStatus::Running, .. } => (Vec::new(), rows),
69 _ => (rows, Vec::new()),
70 }
71 }
72}
73
74impl<'a> TranscriptRowContext<'a> {
75 #[cfg(test)]
77 pub fn for_test(user_label: &'a str, cwd: &'a Path, width: usize) -> Self {
78 Self { user_label, cwd, width, entry_index: None, tool_group_start: true }
79 }
80}
81
82#[derive(Clone, Copy)]
83struct StartupBannerTheme {
84 width: usize,
85 brand_style: CellStyle,
86 attention_style: CellStyle,
87 muted_style: CellStyle,
88 hint_style: CellStyle,
89 rail_style: CellStyle,
90 success_style: CellStyle,
91 meta_style: CellStyle,
92}
93
94impl StartupBannerTheme {
95 fn body_width(self) -> usize {
96 super::layout::content_width(self.width).saturating_sub(3)
97 }
98}
99
100#[derive(Clone, Copy)]
101struct LabeledBlock {
102 rail_style: CellStyle,
103 label_style: CellStyle,
104 text_style: CellStyle,
105 bg: Color,
106 width: usize,
107 body_width: usize,
108}
109
110impl LabeledBlock {
111 fn new(
112 rail_style: CellStyle, label_style: CellStyle, text_style: CellStyle, bg: Color, width: usize,
113 body_width: usize,
114 ) -> Self {
115 Self { rail_style, label_style, text_style, bg, width, body_width }
116 }
117
118 fn build(self, label: &str, text: &str) -> Vec<Row> {
120 let mut rows = vec![
121 Row::blank(self.width, CellStyle::new().bg(self.bg)),
122 Row::padded(
123 vec![
124 Span::styled(ENTRY_RAIL, self.rail_style),
125 Span::styled(label.to_string(), self.label_style),
126 ],
127 self.width,
128 CellStyle::new().bg(self.bg),
129 ),
130 ];
131
132 for line in super::layout::wrap_text(text, self.body_width) {
133 match line.is_empty() {
134 true => rows.push(Row::blank(self.width, CellStyle::new().bg(self.bg))),
135 false => rows.push(Row::padded(
136 vec![
137 Span::styled(ENTRY_RAIL, self.rail_style),
138 Span::styled(line, self.text_style),
139 ],
140 self.width,
141 CellStyle::new().bg(self.bg),
142 )),
143 }
144 }
145 rows
146 }
147}
148
149#[derive(Copy, Clone)]
150struct BannerIndent(usize, usize);
151
152impl BannerIndent {
153 fn first(&self) -> usize {
154 self.0
155 }
156
157 fn continuation(&self) -> usize {
158 self.1
159 }
160}
161
162struct ToolBlockView<'a> {
164 name: &'a str,
165 args: &'a str,
166 status: ToolStatus,
167 output: &'a [String],
168 width: usize,
169 body_width: usize,
170 bg: Color,
171 cwd: &'a Path,
172 group_start: bool,
173}
174
175impl ToolBlockView<'_> {
176 fn rows(&self) -> Vec<Row> {
177 let p = super::style::palette();
178 let (status_label, status_color, icon) = match self.status {
179 ToolStatus::Running => ("running", p.peach, "·"),
180 ToolStatus::Ok => ("ok", p.green, "✓"),
181 ToolStatus::Failed => ("failed", p.red, "✕"),
182 ToolStatus::Cancelled => ("cancelled", p.peach, "○"),
183 };
184 let header_style = CellStyle::new().fg(p.text).bg(self.bg).bold();
185 let status_style = CellStyle::new().fg(status_color).bg(self.bg);
186 let muted_style = CellStyle::new().fg(p.subtext0).bg(self.bg);
187 let gutter_style = CellStyle::new().fg(p.overlay0).bg(self.bg);
188 let rail_style = CellStyle::new().fg(p.yellow).bg(self.bg);
189 let content_width = self.body_width.saturating_sub(utils::text_width(ENTRY_RAIL));
190 let tool_content_width = content_width.saturating_sub(utils::text_width(GUTTER));
191
192 let base_name = self.name.split('#').next().unwrap_or(self.name);
193 let args_summary = summarize_tool_invocation(base_name, self.args, self.cwd);
194 let lang = super::highlight::tool_output_language(base_name, self.args);
195
196 let mut rows = Vec::new();
197 if self.group_start {
198 rows.push(Row::blank(self.width, CellStyle::new().bg(self.bg)));
199 rows.push(Row::padded(
200 vec![
201 Span::styled(ENTRY_RAIL, rail_style),
202 Span::styled("Activity", CellStyle::new().fg(p.yellow).bg(self.bg).bold()),
203 ],
204 self.width,
205 CellStyle::new().bg(self.bg),
206 ));
207 }
208
209 let mut header_spans = vec![
210 Span::styled(ENTRY_RAIL, rail_style),
211 Span::styled(format!("{icon} "), status_style),
212 Span::styled(base_name.to_string(), header_style),
213 ];
214 if self.status != ToolStatus::Ok {
215 header_spans.push(Span::styled(format!(" [{status_label}]"), status_style));
216 }
217
218 if !args_summary.is_empty() {
219 let header_width: usize = header_spans.iter().map(|s| utils::text_width(&s.text)).sum();
220 if header_width + 2 + utils::text_width(&args_summary) <= self.body_width {
221 header_spans.push(Span::styled(" ", CellStyle::new().bg(self.bg)));
222 header_spans.push(Span::styled(args_summary, muted_style));
223 } else {
224 rows.push(Row::padded(header_spans, self.width, CellStyle::new().bg(self.bg)));
225 for wrapped in super::layout::wrap_text(&args_summary, content_width.saturating_sub(2)) {
226 let spans = vec![
227 Span::styled(ENTRY_RAIL, rail_style),
228 Span::styled(" ", CellStyle::new().bg(self.bg)),
229 Span::styled(wrapped, muted_style),
230 ];
231 rows.push(Row::padded(spans, self.width, CellStyle::new().bg(self.bg)));
232 }
233 header_spans = Vec::new();
234 }
235 }
236 if self.status == ToolStatus::Ok && !self.output.is_empty() && !header_spans.is_empty() {
237 header_spans.push(Span::styled(" ▸", CellStyle::new().fg(p.overlay0).bg(self.bg)));
238 }
239 if !header_spans.is_empty() {
240 rows.push(Row::padded(header_spans, self.width, CellStyle::new().bg(self.bg)));
241 }
242
243 if let Some(summary) = edit_summary_line(self.name, self.output, self.status, self.cwd) {
244 rows.push(Row::padded(
245 vec![
246 Span::styled(ENTRY_RAIL, rail_style),
247 Span::styled(" edit ", CellStyle::new().fg(p.overlay0).bg(self.bg).bold()),
248 Span::styled(summary, muted_style),
249 ],
250 self.width,
251 CellStyle::new().bg(self.bg),
252 ));
253 }
254
255 if let Some(summary) = diff_summary_line(self.output) {
256 rows.push(Row::padded(
257 vec![
258 Span::styled(ENTRY_RAIL, rail_style),
259 Span::styled(" diff ", CellStyle::new().fg(p.overlay0).bg(self.bg).bold()),
260 Span::styled(summary, muted_style),
261 ],
262 self.width,
263 CellStyle::new().bg(self.bg),
264 ));
265 }
266
267 if self.status == ToolStatus::Ok {
268 return rows;
269 }
270
271 match lang {
272 Some(lang_str) => {
273 let joined: String = self
274 .output
275 .iter()
276 .take(MAX_TOOL_OUTPUT_LINES)
277 .map(|line| {
278 let line = super::path_display::transcript_line(line, self.cwd);
279 format!("{line}\n")
280 })
281 .collect();
282 let highlighted = super::highlight::highlight_lines(&joined, Some(lang_str));
283 for hl_row in highlighted {
284 let mut spans = vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(GUTTER, gutter_style)];
285 let content_spans: Vec<_> = hl_row
286 .into_iter()
287 .map(|s| Span { text: s.text, style: s.style.bg(self.bg) })
288 .collect();
289 spans.extend(super::layout::truncate_spans(
290 &content_spans,
291 tool_content_width,
292 muted_style,
293 ));
294 rows.push(Row::padded(spans, self.width, CellStyle::new().bg(self.bg)));
295 }
296 }
297 None => {
298 for line in self.output.iter().take(MAX_TOOL_OUTPUT_LINES) {
299 let line = super::path_display::transcript_line(line, self.cwd);
300 let content_style = if is_section_header(&line) {
301 CellStyle::new().fg(p.overlay1).bg(self.bg).bold()
302 } else {
303 CellStyle::new().fg(p.subtext0).bg(self.bg)
304 };
305 for wrapped in super::layout::wrap_text_preserving_whitespace(&line, tool_content_width) {
306 let spans = vec![
307 Span::styled(ENTRY_RAIL, rail_style),
308 Span::styled(GUTTER, gutter_style),
309 Span::styled(wrapped, content_style),
310 ];
311 rows.push(Row::padded(spans, self.width, CellStyle::new().bg(self.bg)));
312 }
313 }
314 }
315 }
316
317 if self.output.len() > MAX_TOOL_OUTPUT_LINES {
318 rows.push(Row::padded(
319 vec![
320 Span::styled(ENTRY_RAIL, rail_style),
321 Span::styled(
322 format!(
323 " │ … ({} lines stored, {} shown here)",
324 self.output.len(),
325 MAX_TOOL_OUTPUT_LINES
326 ),
327 muted_style,
328 ),
329 ],
330 self.width,
331 CellStyle::new().bg(self.bg),
332 ));
333 }
334
335 rows
336 }
337}
338
339#[derive(Clone, Copy)]
340struct MarkdownTableTheme {
341 cell_style: CellStyle,
342 separator_style: CellStyle,
343 rail_style: CellStyle,
344 bg: Color,
345 width: usize,
346}
347
348struct MarkdownTable {
349 headers: Vec<String>,
350 alignments: Vec<TableAlign>,
351 rows: Vec<Vec<String>>,
352}
353
354impl MarkdownTable {
355 fn new(header: &str, separator: &str) -> Self {
356 Self { headers: Self::parse_cells(header), alignments: Self::parse_alignments(separator), rows: Vec::new() }
357 }
358
359 fn is_valid(&self) -> bool {
360 self.headers.len() >= 2 && self.headers.len() == self.alignments.len()
361 }
362
363 fn push_row(&mut self, row: &str) {
364 let mut cells = Self::parse_cells(row);
365 cells.resize(self.headers.len(), String::new());
366 cells.truncate(self.headers.len());
367 self.rows.push(cells);
368 }
369
370 fn render(&self, rail_style: CellStyle, bg: Color, width: usize, body_width: usize) -> Vec<Row> {
371 let p = super::style::palette();
372 let text_style = CellStyle::new().fg(p.text).bg(bg);
373 let separator_style = CellStyle::new().fg(p.overlay0).bg(bg);
374 let header_theme = MarkdownTableTheme {
375 cell_style: CellStyle::new().fg(p.text).bg(bg).bold().underlined(),
376 separator_style,
377 rail_style,
378 bg,
379 width,
380 };
381 let body_theme = MarkdownTableTheme { cell_style: text_style, separator_style, rail_style, bg, width };
382
383 if body_width <= MIN_TABLE_RENDER_WIDTH {
384 return self.render_narrow(rail_style, text_style, bg, width, body_width);
385 }
386
387 let column_widths = self.column_widths(body_width);
388 if column_widths.contains(&0) {
389 return self.render_narrow(rail_style, text_style, bg, width, body_width);
390 }
391
392 let mut rows = Vec::new();
393 rows.push(Self::row(&self.headers, &self.alignments, &column_widths, header_theme));
394 rows.push(Row::padded(
395 vec![
396 Span::styled(ENTRY_RAIL, rail_style),
397 Span::styled(Self::separator(&column_widths), separator_style),
398 ],
399 width,
400 CellStyle::new().bg(bg),
401 ));
402 for cells in &self.rows {
403 rows.push(Self::row(cells, &self.alignments, &column_widths, body_theme));
404 }
405 if self.rows.is_empty() {
406 rows.push(Row::padded(
407 vec![
408 Span::styled(ENTRY_RAIL, rail_style),
409 Span::styled("(no rows)", CellStyle::new().fg(p.subtext0).bg(bg)),
410 ],
411 width,
412 CellStyle::new().bg(bg),
413 ));
414 }
415 rows
416 }
417
418 fn render_narrow(
419 &self, rail_style: CellStyle, text_style: CellStyle, bg: Color, width: usize, body_width: usize,
420 ) -> Vec<Row> {
421 let mut rows = Vec::new();
422 let header = self.headers.join(" / ");
423 for wrapped in super::layout::wrap_text(&header, body_width) {
424 rows.push(Row::padded(
425 vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(wrapped, text_style)],
426 width,
427 CellStyle::new().bg(bg),
428 ));
429 }
430 for cells in &self.rows {
431 let line = self
432 .headers
433 .iter()
434 .zip(cells.iter())
435 .map(|(header, cell)| format!("{header}: {cell}"))
436 .collect::<Vec<_>>()
437 .join("; ");
438 for wrapped in super::layout::wrap_text(&line, body_width) {
439 rows.push(Row::padded(
440 vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(wrapped, text_style)],
441 width,
442 CellStyle::new().bg(bg),
443 ));
444 }
445 }
446 rows
447 }
448
449 fn row(cells: &[String], alignments: &[TableAlign], widths: &[usize], theme: MarkdownTableTheme) -> Row {
450 let mut spans = vec![Span::styled(ENTRY_RAIL, theme.rail_style)];
451 for (index, cell) in cells.iter().enumerate() {
452 if index > 0 {
453 spans.push(Span::styled(" ", theme.separator_style));
454 }
455 let text = Self::align_cell(
456 &utils::truncate_ellipsis(cell, widths[index]),
457 widths[index],
458 alignments[index],
459 );
460 spans.push(Span::styled(text, theme.cell_style));
461 }
462 Row::padded(spans, theme.width, CellStyle::new().bg(theme.bg))
463 }
464
465 fn separator(widths: &[usize]) -> String {
466 widths
467 .iter()
468 .map(|width| "─".repeat((*width).max(1)))
469 .collect::<Vec<_>>()
470 .join(" ")
471 }
472
473 fn column_widths(&self, body_width: usize) -> Vec<usize> {
474 let columns = self.headers.len();
475 let separators = columns.saturating_sub(1) * 2;
476 let available = body_width.saturating_sub(separators);
477 if available < columns {
478 return vec![0; columns];
479 }
480
481 let mut desired = self
482 .headers
483 .iter()
484 .enumerate()
485 .map(|(index, header)| {
486 std::iter::once(header)
487 .chain(self.rows.iter().filter_map(|row| row.get(index)))
488 .map(|cell| utils::text_width(cell))
489 .max()
490 .unwrap_or(1)
491 .max(3)
492 })
493 .collect::<Vec<_>>();
494 let desired_total: usize = desired.iter().sum();
495 if desired_total <= available {
496 return desired;
497 }
498
499 let mut widths = desired
500 .iter()
501 .map(|desired_width| ((*desired_width * available) / desired_total).max(3))
502 .collect::<Vec<_>>();
503 while widths.iter().sum::<usize>() > available {
504 if let Some((index, _)) = widths
505 .iter()
506 .enumerate()
507 .filter(|(_, width)| **width > 3)
508 .max_by_key(|(_, width)| **width)
509 {
510 widths[index] -= 1;
511 } else {
512 break;
513 }
514 }
515 while widths.iter().sum::<usize>() < available {
516 if let Some((index, _)) = desired
517 .iter_mut()
518 .enumerate()
519 .max_by_key(|(index, desired_width)| desired_width.saturating_sub(widths[*index]))
520 {
521 widths[index] += 1;
522 } else {
523 break;
524 }
525 }
526 widths
527 }
528
529 fn align_cell(text: &str, width: usize, align: TableAlign) -> String {
530 let used = utils::text_width(text);
531 if used >= width {
532 return text.to_string();
533 }
534 let pad = width - used;
535 match align {
536 TableAlign::Left => format!("{text}{}", " ".repeat(pad)),
537 TableAlign::Right => format!("{}{text}", " ".repeat(pad)),
538 TableAlign::Center => {
539 let left = pad / 2;
540 let right = pad - left;
541 format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
542 }
543 }
544 }
545
546 fn parse_cells(line: &str) -> Vec<String> {
547 line.trim()
548 .trim_matches('|')
549 .split('|')
550 .map(|cell| cell.trim().to_string())
551 .collect()
552 }
553
554 fn parse_alignments(line: &str) -> Vec<TableAlign> {
555 Self::parse_cells(line)
556 .into_iter()
557 .map(|cell| {
558 let left = cell.starts_with(':');
559 let right = cell.ends_with(':');
560 match (left, right) {
561 (true, true) => TableAlign::Center,
562 (false, true) => TableAlign::Right,
563 _ => TableAlign::Left,
564 }
565 })
566 .collect()
567 }
568
569 fn is_row(line: &str) -> bool {
570 line.trim().matches('|').count() >= 2
571 }
572
573 fn is_separator(line: &str) -> bool {
574 let cells = Self::parse_cells(line);
575 cells.len() >= 2
576 && cells.iter().all(|cell| {
577 let inner = cell.trim().trim_matches(':');
578 inner.len() >= 3 && inner.chars().all(|ch| ch == '-')
579 })
580 }
581}
582
583impl App {
584 pub fn render_banner_rows(&self, width: usize) -> Vec<Row> {
586 let p = super::style::palette();
587 let bg = Color::Reset;
588 let theme = StartupBannerTheme {
589 width,
590 brand_style: CellStyle::new().fg(p.accent).bg(bg).bold(),
591 attention_style: CellStyle::new().fg(p.peach).bg(bg).bold(),
592 muted_style: CellStyle::new().fg(p.subtext0).bg(bg),
593 hint_style: CellStyle::new().fg(p.yellow).bg(bg).bold(),
594 rail_style: CellStyle::new().fg(p.overlay0).bg(bg),
595 success_style: CellStyle::new().fg(p.green).bg(bg),
596 meta_style: CellStyle::new().fg(p.overlay1).bg(bg),
597 };
598 let snapshot = self.self_knowledge_snapshot();
599 let sections = snapshot.startup_sections();
600 let context_lines = self.startup_section_lines(§ions, "Context");
601 let diagnostics = self.startup_section_lines(§ions, "Diagnostics");
602
603 let mut rows = Vec::new();
604 push_banner_brand_row(&mut rows, theme);
605 push_wrapped_banner_text(
606 &mut rows,
607 "Ask for change, run a command, or inspect the repo.",
608 BannerIndent(0, 0),
609 theme,
610 theme.muted_style,
611 );
612 rows.push(Row::blank(width, CellStyle::new()));
613
614 let context_ready = context_lines.iter().any(|line| line != "(none)");
615 let context_text = readiness_context_text(&context_lines);
616 let context_path = context_lines
617 .iter()
618 .find(|line| line.as_str() != "(none)")
619 .map(|line| readiness_context_path(line))
620 .unwrap_or_else(|| "—".to_string());
621 push_banner_readiness_row(&mut rows, &context_text, &context_path, context_ready, theme);
622
623 let skill_count = snapshot.inventory.references.skills.len();
624 let skills_text = match skill_count {
625 0 => "No skills available".to_string(),
626 1 => "1 skill available".to_string(),
627 count => format!("{count} skills available"),
628 };
629 push_banner_readiness_row(&mut rows, &skills_text, "skills", skill_count > 0, theme);
630
631 let search_mode = snapshot.runtime.provider.search.mode.as_str();
632 push_banner_readiness_row(&mut rows, "Web Search", search_mode, true, theme);
633
634 if diagnostics.iter().any(|line| line != "(none)") {
635 rows.push(Row::blank(width, CellStyle::new()));
636 push_banner_attention_heading(&mut rows, theme);
637 for line in diagnostics {
638 push_wrapped_banner_text(&mut rows, &line, BannerIndent(2, 2), theme, theme.muted_style);
639 }
640 }
641
642 rows.push(Row::blank(width, CellStyle::new()));
643 push_banner_help(&mut rows, theme);
644 rows.push(Row::blank(width, CellStyle::new()));
645 rows
646 }
647
648 fn startup_section_lines(&self, sections: &[StartupSection], heading: &str) -> Vec<String> {
649 sections
650 .iter()
651 .find(|section| section.heading == heading)
652 .map(|section| {
653 section
654 .lines
655 .iter()
656 .map(|line| match section.heading {
657 "Context" | "Diagnostics" => super::path_display::transcript_line(line, &self.cwd),
658 _ => line.clone(),
659 })
660 .collect()
661 })
662 .unwrap_or_default()
663 }
664}
665
666pub fn summarize_tool_invocation(name: &str, args: &str, cwd: &Path) -> String {
668 let summary = summarize_tool_args(args, cwd);
669 if name != "run_shell" || summary.is_empty() {
670 return summary;
671 }
672
673 let command = summary
674 .strip_prefix("argv: ")
675 .or_else(|| summary.strip_prefix("program: "))
676 .unwrap_or(&summary);
677 format!("$ {command}")
678}
679
680pub fn summarize_tool_args(args: &str, cwd: &Path) -> String {
682 let trimmed = args.trim();
683 if trimmed.is_empty() || trimmed == "{}" {
684 return String::new();
685 }
686
687 let v = match serde_json::from_str::<serde_json::Value>(trimmed) {
688 Ok(v) => v,
689 Err(_) => return utils::truncate_ellipsis(trimmed, 48),
690 };
691
692 match v.as_object() {
693 Some(obj) => {
694 if let Some(argv) = obj.get("argv").and_then(|value| value.as_array()) {
695 let command = argv
696 .iter()
697 .filter_map(|value| value.as_str())
698 .map(|value| super::path_display::transcript_line(value, cwd))
699 .collect::<Vec<_>>()
700 .join(" ");
701 if !command.is_empty() {
702 return format!("argv: {}", utils::truncate_ellipsis(&command, 72));
703 }
704 }
705 for key in &["pattern", "path", "query", "root", "glob", "file", "program", "url"] {
706 if let Some(val) = obj.get(*key).and_then(|f| f.as_str()) {
707 let val = super::path_display::transcript_line(val, cwd);
708 return format!("{}: {}", key, utils::truncate_ellipsis(&val, 40));
709 }
710 }
711 for (k, val) in obj {
712 if let Some(s) = val.as_str() {
713 let s = super::path_display::transcript_line(s, cwd);
714 return format!("{k}: {}", utils::truncate_ellipsis(&s, 40));
715 }
716 }
717 utils::truncate_ellipsis(trimmed, 48)
718 }
719 None => utils::truncate_ellipsis(trimmed, 48),
720 }
721}
722
723fn wrap_with_indent(text: &str, indent: BannerIndent, width: usize) -> Vec<String> {
724 let first_width = width.saturating_sub(indent.first()).max(1);
725 let continuation_width = width.saturating_sub(indent.continuation()).max(1);
726 let mut out = Vec::new();
727 for (index, line) in super::layout::wrap_text(text, first_width).into_iter().enumerate() {
728 if index == 0 {
729 out.push(format!("{}{}", " ".repeat(indent.first()), line));
730 } else {
731 for continued in super::layout::wrap_text(&line, continuation_width) {
732 out.push(format!("{}{}", " ".repeat(indent.continuation()), continued));
733 }
734 }
735 }
736 out
737}
738
739fn readiness_context_text(lines: &[String]) -> String {
740 let visible = lines
741 .iter()
742 .filter(|line| line.as_str() != "(none)")
743 .collect::<Vec<_>>();
744 let Some(first) = visible.first() else {
745 return "No project instructions".to_string();
746 };
747 let path = first.split(" (truncated").next().unwrap_or(first);
748 let name = Path::new(path)
749 .file_name()
750 .and_then(|name| name.to_str())
751 .unwrap_or(path);
752 format!("{name} loaded")
753}
754
755fn readiness_context_path(line: &str) -> String {
756 let path = line.split(" (truncated").next().unwrap_or(line);
757 if Path::new(path).components().count() == 1 { format!("./{path}") } else { path.to_string() }
758}
759
760fn push_banner_brand_row(rows: &mut Vec<Row>, theme: StartupBannerTheme) {
761 rows.push(Row::padded(
762 vec![
763 Span::styled("thndrs", theme.brand_style),
764 Span::styled(" / ready", theme.meta_style.bold()),
765 ],
766 theme.width,
767 CellStyle::new(),
768 ));
769}
770
771fn push_banner_readiness_row(rows: &mut Vec<Row>, text: &str, meta: &str, ready: bool, theme: StartupBannerTheme) {
772 let prefix_width = utils::text_width("│ ✓ ");
773 let available = theme.body_width().saturating_sub(prefix_width).max(1);
774 let wrapped = super::layout::wrap_text(text, available);
775 for (index, line) in wrapped.into_iter().enumerate() {
776 let marker = if index == 0 { if ready { "✓ " } else { "· " } } else { " " };
777 let marker_style = if ready { theme.success_style } else { theme.muted_style };
778 let mut spans = vec![
779 Span::styled("│ ", theme.rail_style),
780 Span::styled(marker, marker_style),
781 Span::styled(line, theme.muted_style),
782 ];
783 if index == 0 {
784 let used = super::layout::spans_width(&spans);
785 let meta_width = utils::text_width(meta);
786 if used + meta_width + 2 <= theme.body_width() {
787 spans.push(Span::styled(
788 " ".repeat(theme.body_width() - used - meta_width),
789 theme.muted_style,
790 ));
791 spans.push(Span::styled(meta, theme.meta_style));
792 }
793 }
794 rows.push(Row::padded(spans, theme.width, CellStyle::new()));
795 }
796}
797
798fn push_banner_attention_heading(rows: &mut Vec<Row>, theme: StartupBannerTheme) {
799 rows.push(Row::padded(
800 vec![Span::styled("ATTENTION", theme.attention_style)],
801 theme.width,
802 CellStyle::new(),
803 ));
804}
805
806fn push_banner_help(rows: &mut Vec<Row>, theme: StartupBannerTheme) {
807 let spans = vec![
808 Span::styled("?", theme.hint_style),
809 Span::styled(" help ", theme.muted_style),
810 Span::styled("/model", theme.hint_style),
811 Span::styled(" switch ", theme.muted_style),
812 Span::styled("/search", theme.hint_style),
813 Span::styled(" configure", theme.muted_style),
814 ];
815 for line in super::layout::wrap_spans(&spans, theme.body_width()) {
816 rows.push(Row::padded(line, theme.width, CellStyle::new()));
817 }
818}
819
820fn push_wrapped_banner_text(
821 rows: &mut Vec<Row>, text: &str, indent: BannerIndent, theme: StartupBannerTheme, text_style: CellStyle,
822) {
823 let wrapped = wrap_with_indent(text, indent, theme.body_width());
824 for line in wrapped {
825 rows.push(Row::padded(
826 vec![Span::styled(line, text_style)],
827 theme.width,
828 CellStyle::new(),
829 ));
830 }
831}
832
833fn edit_summary_line(name: &str, output: &[String], status: ToolStatus, cwd: &Path) -> Option<String> {
834 let operation = name.split('#').next().unwrap_or(name);
835 let is_edit_tool = matches!(operation, "create_file" | "replace_range" | "write_patch");
836 if !is_edit_tool
837 && !output
838 .iter()
839 .any(|line| line.contains("wrote") || line.contains("replaced"))
840 {
841 return None;
842 }
843 let path = output
844 .iter()
845 .find_map(|line| path_like_suffix(line))
846 .map(|path| super::path_display::transcript_line(&path, cwd))
847 .unwrap_or_else(|| "(path unavailable)".to_string());
848 Some(format!("{operation} {path} [{}]", status.label()))
849}
850
851fn diff_summary_line(output: &[String]) -> Option<String> {
852 let mut added = 0usize;
853 let mut removed = 0usize;
854 let mut files = Vec::new();
855 for line in output {
856 if let Some(path) = line.strip_prefix("+++ ") {
857 files.push(path.trim_start_matches("b/").to_string());
858 } else if line.starts_with('+') && !line.starts_with("+++") {
859 added += 1;
860 } else if line.starts_with('-') && !line.starts_with("---") {
861 removed += 1;
862 }
863 }
864 if added == 0 && removed == 0 && files.is_empty() {
865 return None;
866 }
867 files.sort();
868 files.dedup();
869 let file_label = match files.as_slice() {
870 [] => "unknown file".to_string(),
871 [file] => file.clone(),
872 _ => format!("{} files", files.len()),
873 };
874 Some(format!("{file_label} +{added} -{removed}"))
875}
876
877fn path_like_suffix(line: &str) -> Option<String> {
878 line.rsplit_once(": ").map(|(_, path)| path.to_string()).or_else(|| {
879 line.split_whitespace()
880 .last()
881 .filter(|part| part.contains('/'))
882 .map(str::to_string)
883 })
884}
885
886fn entry_to_rows(entry: &Entry, user_label: &str, width: usize, cwd: &Path, tool_group_start: bool) -> Vec<Row> {
888 let p = super::style::palette();
889 let bg = Color::Reset;
890 let body_width = super::layout::content_width(width);
891 let railed_body_width = body_width.saturating_sub(utils::text_width(ENTRY_RAIL));
892
893 match entry {
894 Entry::User { text } => {
895 let rail_style = CellStyle::new().fg(p.blue).bg(bg).bold();
896 let label_style = CellStyle::new().fg(p.blue).bg(bg).bold();
897 let text_style = CellStyle::new().fg(p.subtext0).bg(bg).italic();
898 let mut rows = LabeledBlock::new(rail_style, label_style, text_style, bg, width, railed_body_width)
899 .build(user_label, text);
900 rows.push(Row::blank(width, CellStyle::new().bg(bg)));
901 rows
902 }
903 Entry::Agent { text, .. } => {
904 let rail_style = CellStyle::new().fg(p.green).bg(bg).bold();
905 let label_style = CellStyle::new().fg(p.green).bg(bg).bold();
906 assistant_block_rows(text, rail_style, label_style, bg, width, railed_body_width)
907 }
908 Entry::Reasoning { text, streaming } => {
909 let rail_style = CellStyle::new().fg(p.mauve).bg(bg).bold();
910 let label_style = CellStyle::new().fg(p.mauve).bg(bg).bold();
911 let text_style = CellStyle::new().fg(p.subtext0).bg(bg).italic();
912 let label = if *streaming { "Thinking ·" } else { "Thinking ✓" };
913 LabeledBlock::new(rail_style, label_style, text_style, bg, width, railed_body_width).build(label, text)
914 }
915 Entry::Tool { name, arguments, status, output } => ToolBlockView {
916 name,
917 args: arguments,
918 status: *status,
919 output,
920 width,
921 body_width,
922 bg,
923 cwd,
924 group_start: tool_group_start,
925 }
926 .rows(),
927 Entry::Status { text } => {
928 let rail_style = CellStyle::new().fg(p.overlay1).bg(bg);
929 let label_style = CellStyle::new().fg(p.overlay1).bg(bg).bold();
930 let text_style = CellStyle::new().fg(p.text).bg(bg);
931 LabeledBlock::new(rail_style, label_style, text_style, bg, width, railed_body_width)
932 .build(status_label_for(text), text)
933 }
934 Entry::Error { text } => {
935 let error_bg = p.surface_dim;
936 let rail_style = CellStyle::new().fg(p.red).bg(error_bg).bold();
937 let label_style = CellStyle::new().fg(p.red).bg(error_bg).bold();
938 let text_style = CellStyle::new().fg(p.text).bg(error_bg);
939 LabeledBlock::new(rail_style, label_style, text_style, error_bg, width, railed_body_width)
940 .build("⚠ Error", text)
941 }
942 }
943}
944
945fn assistant_block_rows(
947 text: &str, rail_style: CellStyle, label_style: CellStyle, bg: Color, width: usize, body_width: usize,
948) -> Vec<Row> {
949 let p = super::style::palette();
950 let text_style = CellStyle::new().fg(p.text).bg(bg);
951 let mut rows = vec![
952 Row::blank(width, CellStyle::new().bg(bg)),
953 Row::padded(
954 vec![
955 Span::styled(ENTRY_RAIL, rail_style),
956 Span::styled("Agent".to_string(), label_style),
957 ],
958 width,
959 CellStyle::new().bg(bg),
960 ),
961 ];
962
963 match assistant_markdown_body(text) {
964 Some(markdown) => rows.extend(render_markdown_body(
965 markdown, rail_style, text_style, bg, width, body_width,
966 )),
967 None => {
968 for line in super::layout::wrap_text(text, body_width) {
969 match line.is_empty() {
970 true => rows.push(Row::blank(width, CellStyle::new().bg(bg))),
971 false => rows.push(Row::padded(
972 vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(line, text_style)],
973 width,
974 CellStyle::new().bg(bg),
975 )),
976 }
977 }
978 }
979 }
980
981 if rows.len() == 2 {
982 rows.push(Row::blank(width, CellStyle::new().bg(bg)));
983 }
984 rows
985}
986
987fn assistant_markdown_body(text: &str) -> Option<&str> {
989 strip_outer_markdown_fence(text).or_else(|| text.contains("```").then_some(text))
990}
991
992fn strip_outer_markdown_fence(text: &str) -> Option<&str> {
995 for (opening, closing) in [
996 ("````markdown\r\n", "\r\n````"),
997 ("````markdown\n", "\n````"),
998 ("````md\r\n", "\r\n````"),
999 ("````md\n", "\n````"),
1000 ("```markdown\r\n", "\r\n```"),
1001 ("```markdown\n", "\n```"),
1002 ("```md\r\n", "\r\n```"),
1003 ("```md\n", "\n```"),
1004 ] {
1005 if let Some(body) = text.strip_prefix(opening) {
1006 return Some(body.strip_suffix(closing).unwrap_or(body));
1007 }
1008 }
1009 None
1010}
1011
1012fn render_markdown_body(
1014 markdown: &str, rail_style: CellStyle, text_style: CellStyle, bg: Color, width: usize, body_width: usize,
1015) -> Vec<Row> {
1016 let p = super::style::palette();
1017 let gutter_style = CellStyle::new().fg(p.overlay0).bg(bg);
1018 let code_width = body_width.saturating_sub(utils::text_width(GUTTER));
1019 let mut rows = Vec::new();
1020 let mut in_code_fence = false;
1021 let mut code_lang: Option<String> = None;
1022 let mut code_buf = String::new();
1023 let mut pending_plain = Vec::new();
1024 let mut lines = markdown.lines().peekable();
1025
1026 while let Some(line) = lines.next() {
1027 if line.starts_with("```") {
1028 flush_plain_markdown_lines(
1029 &mut rows,
1030 &mut pending_plain,
1031 rail_style,
1032 text_style,
1033 bg,
1034 width,
1035 body_width,
1036 );
1037 if !in_code_fence {
1038 in_code_fence = true;
1039 let lang_str = line.trim_start_matches('`').trim();
1040 code_lang = if lang_str.is_empty() { None } else { Some(lang_str.to_string()) };
1041 code_buf.clear();
1042 } else {
1043 let lang = code_lang.as_deref();
1044 let highlighted = super::highlight::highlight_lines(&code_buf, lang);
1045 push_highlighted_code_rows(&mut rows, highlighted, rail_style, gutter_style, bg, width, code_width);
1046 in_code_fence = false;
1047 code_lang = None;
1048 code_buf.clear();
1049 }
1050 continue;
1051 }
1052
1053 if in_code_fence {
1054 code_buf.push_str(line);
1055 code_buf.push('\n');
1056 continue;
1057 }
1058
1059 if MarkdownTable::is_separator(line)
1060 && !pending_plain.is_empty()
1061 && let Some(header) = pending_plain.pop()
1062 {
1063 let mut table = MarkdownTable::new(&header, line);
1064 while let Some(peeked) = lines.peek() {
1065 if !MarkdownTable::is_row(peeked) {
1066 break;
1067 }
1068 match lines.next() {
1069 Some(row_line) => table.push_row(row_line),
1070 None => break,
1071 }
1072 }
1073 if table.is_valid() {
1074 rows.extend(table.render(rail_style, bg, width, body_width));
1075 continue;
1076 }
1077 pending_plain.push(header);
1078 pending_plain.push(line.to_string());
1079 } else {
1080 pending_plain.push(line.to_string());
1081 }
1082 }
1083
1084 flush_plain_markdown_lines(
1085 &mut rows,
1086 &mut pending_plain,
1087 rail_style,
1088 text_style,
1089 bg,
1090 width,
1091 body_width,
1092 );
1093
1094 if in_code_fence && !code_buf.is_empty() {
1095 let lang = code_lang.as_deref();
1096 let highlighted = super::highlight::highlight_lines(&code_buf, lang);
1097 push_highlighted_code_rows(&mut rows, highlighted, rail_style, gutter_style, bg, width, code_width);
1098 }
1099
1100 if rows.is_empty() {
1101 rows.push(Row::blank(width, CellStyle::new().bg(bg)));
1102 }
1103
1104 rows
1105}
1106
1107fn push_highlighted_code_rows(
1110 rows: &mut Vec<Row>, highlighted: Vec<Vec<Span>>, rail_style: CellStyle, gutter_style: CellStyle, bg: Color,
1111 width: usize, code_width: usize,
1112) {
1113 for highlighted_line in highlighted {
1114 let content_spans = highlighted_line
1115 .into_iter()
1116 .map(|span| Span { text: span.text, style: span.style.bg(bg) })
1117 .collect::<Vec<_>>();
1118 for wrapped in super::layout::wrap_spans(&content_spans, code_width) {
1119 let mut spans = vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(GUTTER, gutter_style)];
1120 spans.extend(wrapped);
1121 rows.push(Row::padded(spans, width, CellStyle::new().bg(bg)));
1122 }
1123 }
1124}
1125
1126fn flush_plain_markdown_lines(
1127 rows: &mut Vec<Row>, pending: &mut Vec<String>, rail_style: CellStyle, text_style: CellStyle, bg: Color,
1128 width: usize, body_width: usize,
1129) {
1130 for line in pending.drain(..) {
1131 if line.is_empty() {
1132 rows.push(Row::blank(width, CellStyle::new().bg(bg)));
1133 } else {
1134 for wrapped in super::layout::wrap_text(&line, body_width) {
1135 rows.push(Row::padded(
1136 vec![Span::styled(ENTRY_RAIL, rail_style), Span::styled(wrapped, text_style)],
1137 width,
1138 CellStyle::new().bg(bg),
1139 ));
1140 }
1141 }
1142 }
1143}
1144
1145fn is_section_header(line: &str) -> bool {
1147 let trimmed = line.trim();
1148 trimmed.starts_with("── ") || trimmed.starts_with("$ ")
1149}
1150
1151fn status_label_for(text: &str) -> &'static str {
1153 if text.starts_with("context ") {
1154 "Context"
1155 } else if text.starts_with("logs ") {
1156 "Session log"
1157 } else if text.starts_with("provider:") || text.starts_with("tool budget:") {
1158 "Diagnostic"
1159 } else if text.starts_with("queued ") {
1160 "Queued"
1161 } else if text.starts_with("queue target:") {
1162 "Queue"
1163 } else if text.starts_with("background ") || text == "no background processes" {
1164 "Background"
1165 } else if text == "cancelled" {
1166 "Cancelled"
1167 } else {
1168 "System"
1169 }
1170}
1171
1172#[cfg(test)]
1173mod tests;