1use ratatui::{
2 Frame,
3 layout::{Constraint, Direction, Layout, Rect},
4 prelude::Stylize,
5 style::{Color, Modifier, Style},
6 text::{Line, Span, Text},
7 widgets::{Block, Clear, List, ListItem, Padding, Paragraph, Wrap},
8};
9use serde::Deserialize;
10use serde_json::Value;
11use std::iter::Peekable;
12
13use super::app::{ChatApp, ChatMessage, TodoItemView, TodoStatus};
14use super::markdown::markdown_to_lines_with_indent;
15use super::tool_presentation::render_tool_start;
16
17const MAX_TOOL_OUTPUT_LEN: usize = 200;
18const MIN_DIFF_COLUMN_WIDTH: usize = 24;
19const DIFF_LINE_NUMBER_WIDTH: usize = 4;
20const TOOL_PENDING_MARKER: &str = "-> ";
21const PROCESSING_STATUS_GAP: &str = " ";
22const SIDEBAR_INDENT: &str = " ";
23const SIDEBAR_LABEL_INDENT: &str = " ";
24
25const PAGE_BG: Color = Color::Rgb(246, 247, 251);
26const SIDEBAR_BG: Color = Color::Rgb(234, 238, 246);
27const INPUT_PANEL_BG: Color = Color::Rgb(229, 233, 241);
28const COMMAND_PALETTE_BG: Color = Color::Rgb(214, 220, 232);
29const TEXT_PRIMARY: Color = Color::Rgb(37, 45, 58);
30const TEXT_SECONDARY: Color = Color::Rgb(98, 108, 124);
31const TEXT_MUTED: Color = Color::Rgb(125, 133, 147);
32const ACCENT: Color = Color::Rgb(55, 114, 255);
33const INPUT_ACCENT: Color = Color::Rgb(19, 164, 151);
34const SELECTION_BG: Color = Color::Rgb(55, 114, 255);
35const NOTICE_BG: Color = Color::Rgb(224, 227, 233);
36const PROGRESS_HEAD: Color = Color::Rgb(124, 72, 227);
37const THINKING_LABEL: Color = Color::Rgb(227, 152, 67);
38const QUESTION_BORDER: Color = Color::Rgb(220, 96, 180);
39const CONTEXT_USAGE_YELLOW: Color = Color::Rgb(214, 168, 46);
40const CONTEXT_USAGE_ORANGE: Color = Color::Rgb(227, 136, 46);
41const CONTEXT_USAGE_RED: Color = Color::Rgb(196, 64, 64);
42const DIFF_ADD_FG: Color = Color::Rgb(25, 110, 61);
43const DIFF_ADD_BG: Color = Color::Rgb(226, 244, 235);
44const DIFF_REMOVE_FG: Color = Color::Rgb(152, 45, 45);
45const DIFF_REMOVE_BG: Color = Color::Rgb(252, 235, 235);
46const DIFF_META_FG: Color = Color::Rgb(106, 114, 128);
47const MAX_RENDERED_DIFF_LINES: usize = 120;
48const MAX_RENDERED_DIFF_CHARS: usize = 8_000;
49const MAX_INPUT_LINES: usize = 5;
50
51#[derive(Clone, Copy)]
52pub(crate) struct UiLayout {
53 sidebar_width: u16,
54 left_column_right_margin: u16,
55 main_outer_padding_x: u16,
56 main_outer_padding_y: u16,
57 main_content_left_offset: usize,
58 user_bubble_inner_padding: usize,
59 message_indent_width: usize,
60 command_palette_left_padding: usize,
61}
62
63impl Default for UiLayout {
64 fn default() -> Self {
65 let main_content_left_offset = 2;
66 Self {
67 sidebar_width: 38,
68 left_column_right_margin: 2,
69 main_outer_padding_x: 1,
70 main_outer_padding_y: 1,
71 main_content_left_offset,
72 user_bubble_inner_padding: 1,
73 message_indent_width: main_content_left_offset + 2,
74 command_palette_left_padding: main_content_left_offset,
75 }
76 }
77}
78
79impl UiLayout {
80 #[cfg(test)]
81 pub(crate) const fn main_content_left_offset(&self) -> usize {
82 self.main_content_left_offset
83 }
84
85 #[cfg(test)]
86 pub(crate) const fn message_indent_width(&self) -> usize {
87 self.message_indent_width
88 }
89
90 fn user_bubble_indent(&self) -> usize {
91 self.main_content_left_offset
92 }
93
94 fn message_indent(&self) -> String {
95 " ".repeat(self.message_indent_width)
96 }
97
98 fn message_child_indent(&self) -> String {
99 " ".repeat(self.message_indent_width + 2)
100 }
101}
102
103#[derive(Debug, Clone, Copy)]
104pub(crate) struct AppLayoutRects {
105 pub main_messages: Option<Rect>,
106 pub sidebar_content: Option<Rect>,
107}
108
109#[derive(Debug, Deserialize)]
110struct EditToolOutput {
111 path: String,
112 summary: EditDiffSummary,
113 diff: String,
114}
115
116#[derive(Debug, Deserialize)]
117struct EditDiffSummary {
118 added_lines: usize,
119 removed_lines: usize,
120}
121
122#[derive(Debug, Deserialize)]
123struct TaskToolRenderOutput {
124 name: String,
125 agent_name: String,
126 started_at: u64,
127 #[serde(default)]
128 finished_at: Option<u64>,
129}
130
131pub fn render_app(f: &mut Frame, app: &ChatApp) {
132 let layout = UiLayout::default();
133 f.render_widget(
134 Block::default().style(Style::default().bg(PAGE_BG)),
135 f.area(),
136 );
137
138 let app_area = inset_rect(
139 f.area(),
140 layout.main_outer_padding_x,
141 layout.main_outer_padding_y,
142 );
143 let columns = Layout::default()
144 .direction(Direction::Horizontal)
145 .constraints([
146 Constraint::Min(40),
147 Constraint::Length(layout.left_column_right_margin),
148 Constraint::Length(layout.sidebar_width),
149 ])
150 .split(app_area);
151
152 let main_area = columns[0];
153 let sidebar_area = if columns.len() > 2 {
154 Some(columns[2])
155 } else {
156 None
157 };
158
159 let input_content_width = main_area
160 .width
161 .saturating_sub(layout.user_bubble_indent() as u16 + 3)
162 as usize;
163 let input_line_count =
164 input_line_count(&app.input, input_content_width).clamp(1, MAX_INPUT_LINES);
165 let input_area_height = if app.has_pending_question() {
166 (question_prompt_line_count(app, input_content_width) + 2) as u16
167 } else {
168 (input_line_count + 4) as u16
169 };
170
171 let main_chunks = Layout::default()
172 .direction(Direction::Vertical)
173 .constraints([
174 Constraint::Min(3),
175 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(input_area_height), ])
180 .split(main_area);
181
182 render_messages(f, app, main_chunks[0]);
183 render_processing_indicator(f, app, main_chunks[2], layout);
184 render_input(f, app, main_chunks[4], layout);
185
186 if !app.filtered_commands.is_empty() {
187 let item_count = app.filtered_commands.len().min(5) as u16;
188 let popup_height = item_count;
189 let input_left = main_chunks[4]
190 .x
191 .saturating_add(layout.user_bubble_indent() as u16);
192 let input_width = main_chunks[4]
193 .width
194 .saturating_sub(layout.user_bubble_indent() as u16);
195 let popup_area = Rect {
196 x: input_left,
197 y: main_chunks[4].y.saturating_sub(popup_height),
198 width: input_width,
199 height: popup_height,
200 };
201 render_command_palette(f, app, popup_area, layout);
202 }
203
204 if let Some(area) = sidebar_area {
205 let sidebar_bottom = main_chunks[4].bottom();
206 let clipped_sidebar_area = Rect {
207 x: area.x,
208 y: area.y,
209 width: area.width,
210 height: sidebar_bottom.saturating_sub(area.y),
211 };
212 render_sidebar(f, app, clipped_sidebar_area);
213 }
214
215 render_clipboard_notice(f, app);
216}
217
218fn render_clipboard_notice(f: &mut Frame, app: &ChatApp) {
219 let Some(notice) = app.active_clipboard_notice() else {
220 return;
221 };
222
223 let label = "Copied";
224 let width = (label.len() as u16).saturating_add(4);
225 let height = 3u16;
226 let area = f.area();
227
228 if area.width < width || area.height < height {
229 return;
230 }
231
232 let max_x = area.right().saturating_sub(width);
233 let max_y = area.bottom().saturating_sub(height);
234 let x = notice.x.saturating_add(1).clamp(area.x, max_x);
235 let y = notice.y.saturating_sub(1).clamp(area.y, max_y);
236 let popup = Rect {
237 x,
238 y,
239 width,
240 height,
241 };
242
243 f.render_widget(Clear, popup);
244 let block = Block::default()
245 .style(Style::default().bg(NOTICE_BG).fg(TEXT_MUTED))
246 .padding(Padding::new(2, 2, 1, 1));
247 let content = block.inner(popup);
248 f.render_widget(block, popup);
249 f.render_widget(
250 Paragraph::new(label)
251 .style(Style::default().fg(TEXT_PRIMARY).bg(NOTICE_BG))
252 .wrap(Wrap { trim: true }),
253 content,
254 );
255}
256
257fn render_command_palette(f: &mut Frame, app: &ChatApp, area: Rect, layout: UiLayout) {
258 f.render_widget(Clear, area);
259 f.render_widget(
260 Block::default().style(Style::default().bg(COMMAND_PALETTE_BG)),
261 area,
262 );
263
264 let name_width = app
265 .filtered_commands
266 .iter()
267 .take(5)
268 .map(|cmd| cmd.name.chars().count())
269 .max()
270 .unwrap_or(0)
271 .clamp(12, 24)
272 + 1;
273
274 let content_width = area.width as usize;
275 let list_left_padding = layout.command_palette_left_padding;
276 let left_padding = " ".repeat(list_left_padding);
277 let description_width = content_width.saturating_sub(list_left_padding + name_width + 1);
278
279 let items: Vec<ListItem> = app
280 .filtered_commands
281 .iter()
282 .take(5)
283 .enumerate()
284 .map(|(i, cmd)| {
285 let style = if i == app.selected_command_index {
286 Style::default().fg(Color::White).bg(ACCENT)
287 } else {
288 Style::default().fg(TEXT_PRIMARY).bg(COMMAND_PALETTE_BG)
289 };
290
291 let description = truncate_chars(&cmd.description, description_width);
292
293 ListItem::new(Line::from(vec![
294 Span::raw(left_padding.clone()),
295 Span::styled(format!("{:<name_width$}", cmd.name), Style::default()),
296 Span::raw(" "),
297 Span::styled(
298 description,
299 if i == app.selected_command_index {
300 Style::default().fg(Color::White)
301 } else {
302 Style::default().fg(TEXT_SECONDARY)
303 },
304 ),
305 ]))
306 .style(style)
307 })
308 .collect();
309
310 let list = List::new(items).style(Style::default().bg(COMMAND_PALETTE_BG));
311
312 f.render_widget(list, area);
313}
314
315fn render_sidebar(f: &mut Frame, app: &ChatApp, area: Rect) {
316 let block = Block::default().style(Style::default().bg(SIDEBAR_BG));
317 let inner = block.inner(area);
318 let content = inset_rect(inner, 2, 0);
319 f.render_widget(block, area);
320
321 let lines = build_sidebar_lines(app, content.width);
322 let scroll_offset = app
323 .sidebar_scroll
324 .effective_offset(lines.len(), content.height as usize);
325
326 let sidebar = Paragraph::new(Text::from(lines))
327 .style(Style::default().bg(SIDEBAR_BG))
328 .wrap(Wrap { trim: true })
329 .scroll((scroll_offset as u16, 0));
330 f.render_widget(sidebar, content);
331}
332
333pub(crate) fn build_sidebar_lines(app: &ChatApp, content_width: u16) -> Vec<Line<'static>> {
334 let content_width = content_width.max(1);
335
336 let (used, budget) = app.context_usage();
337 let context_percent = if budget == 0 {
338 0
339 } else {
340 (used.saturating_mul(100) / budget).min(999)
341 };
342 let context_usage_color = if context_percent >= 60 {
343 CONTEXT_USAGE_RED
344 } else if context_percent >= 40 {
345 CONTEXT_USAGE_ORANGE
346 } else if context_percent >= 30 {
347 CONTEXT_USAGE_YELLOW
348 } else {
349 TEXT_PRIMARY
350 };
351
352 let directory_text =
353 format_sidebar_directory(&app.working_directory, app.git_branch.as_deref());
354 let mut lines: Vec<Line<'static>> = vec![
355 Line::from(""),
356 Line::from(Span::styled(
357 sidebar_prefixed(&app.session_name),
358 Style::default().fg(TEXT_PRIMARY).bold(),
359 )),
360 Line::from(""),
361 Line::from(Span::styled(
362 sidebar_prefixed(&abbreviate_path(
363 &directory_text,
364 content_width.saturating_sub(2) as usize,
365 )),
366 Style::default().fg(TEXT_PRIMARY),
367 )),
368 Line::from(""),
369 ];
370
371 let mut sections: Vec<Vec<Line<'static>>> = Vec::new();
372 sections.push(vec![
373 Line::from(Span::styled(
374 sidebar_label("Context"),
375 Style::default().fg(TEXT_SECONDARY).bold(),
376 )),
377 Line::from(Span::styled(
378 sidebar_prefixed(&format!("{} / {} ({}%)", used, budget, context_percent)),
379 Style::default().fg(context_usage_color),
380 )),
381 ]);
382
383 let modified_files = collect_modified_files(&app.messages);
384 if !modified_files.is_empty() {
385 let mut modified_lines = vec![Line::from(Span::styled(
386 sidebar_label("Modified Files"),
387 Style::default().fg(TEXT_SECONDARY).bold(),
388 ))];
389 append_modified_file_list(&mut modified_lines, &modified_files, content_width as usize);
390 sections.push(modified_lines);
391 }
392
393 if !app.todo_items.is_empty() {
394 let mut todo_lines = vec![Line::from(Span::styled(
395 sidebar_label("TODO"),
396 Style::default().fg(TEXT_SECONDARY).bold(),
397 ))];
398 let done = app
399 .todo_items
400 .iter()
401 .filter(|item| item.status == TodoStatus::Completed)
402 .count();
403 todo_lines.push(Line::from(Span::styled(
404 sidebar_label(&format!("{} / {} done", done, app.todo_items.len())),
405 Style::default().fg(TEXT_MUTED),
406 )));
407
408 append_sidebar_list(&mut todo_lines, &app.todo_items, app.todo_items.len());
409 sections.push(todo_lines);
410 }
411
412 let section_count = sections.len();
413 for (index, section) in sections.into_iter().enumerate() {
414 lines.extend(section);
415 if index + 1 < section_count {
416 lines.push(Line::from(""));
417 }
418 }
419
420 lines
421}
422
423fn render_messages(f: &mut Frame, app: &ChatApp, area: ratatui::layout::Rect) {
424 let panel = Block::default().style(Style::default().bg(PAGE_BG));
425 let inner = panel.inner(area);
426 f.render_widget(panel, area);
427
428 let content = inner;
429
430 let wrap_width = content.width as usize;
431 let visible_height = content.height as usize;
432
433 let lines = app.get_lines(wrap_width);
435 let total_lines = lines.len();
436
437 let scroll_offset = app
439 .message_scroll
440 .effective_offset(total_lines, visible_height);
441
442 let mut rendered_lines = lines.to_vec();
443 apply_selection_highlight(&mut rendered_lines, app);
444 let text = Text::from(rendered_lines);
445 let paragraph = Paragraph::new(text)
446 .style(Style::default().bg(PAGE_BG).fg(TEXT_PRIMARY))
447 .scroll((scroll_offset as u16, 0));
448
449 f.render_widget(paragraph, content);
450}
451
452fn apply_selection_highlight(lines: &mut [Line<'static>], app: &ChatApp) {
453 let Some((start, end)) = app.text_selection.get_range() else {
454 return;
455 };
456
457 for (line_idx, line) in lines.iter_mut().enumerate() {
458 if line_idx < start.line || line_idx > end.line {
459 continue;
460 }
461
462 let line_len = line_char_count(line);
463 let start_col = if line_idx == start.line {
464 start.column
465 } else {
466 0
467 };
468 let end_col = if line_idx == end.line {
469 end.column
470 } else {
471 line_len
472 };
473
474 let clamped_start = start_col.min(line_len);
475 let clamped_end = end_col.min(line_len);
476 if clamped_start >= clamped_end {
477 continue;
478 }
479
480 highlight_line_range(line, clamped_start, clamped_end);
481 }
482}
483
484fn highlight_line_range(line: &mut Line<'static>, start: usize, end: usize) {
485 let original_spans = std::mem::take(&mut line.spans);
486 let mut highlighted = Vec::with_capacity(original_spans.len() + 2);
487 let mut cursor = 0usize;
488
489 for span in original_spans {
490 let content = span.content.as_ref();
491 let span_len = content.chars().count();
492 let span_start = cursor;
493 let span_end = span_start + span_len;
494
495 if span_len == 0 || end <= span_start || start >= span_end {
496 highlighted.push(span);
497 cursor = span_end;
498 continue;
499 }
500
501 let local_start = start.saturating_sub(span_start).min(span_len);
502 let local_end = end.saturating_sub(span_start).min(span_len);
503
504 if local_start > 0 {
505 highlighted.push(Span::styled(
506 char_slice(content, 0, local_start),
507 span.style,
508 ));
509 }
510
511 if local_start < local_end {
512 let selected_style = span
513 .style
514 .patch(Style::default().bg(SELECTION_BG).fg(Color::White));
515 highlighted.push(Span::styled(
516 char_slice(content, local_start, local_end),
517 selected_style,
518 ));
519 }
520
521 if local_end < span_len {
522 highlighted.push(Span::styled(
523 char_slice(content, local_end, span_len),
524 span.style,
525 ));
526 }
527
528 cursor = span_end;
529 }
530
531 line.spans = highlighted;
532}
533
534fn line_char_count(line: &Line<'static>) -> usize {
535 line.spans
536 .iter()
537 .map(|span| span.content.as_ref().chars().count())
538 .sum()
539}
540
541fn char_slice(input: &str, start: usize, end: usize) -> String {
542 input
543 .chars()
544 .skip(start)
545 .take(end.saturating_sub(start))
546 .collect()
547}
548
549pub fn build_message_lines(app: &ChatApp, width: usize) -> Vec<Line<'static>> {
551 build_message_lines_impl(app, width, UiLayout::default())
552}
553
554fn build_message_lines_impl(app: &ChatApp, width: usize, layout: UiLayout) -> Vec<Line<'static>> {
555 let border_color = if app.has_pending_question() {
557 QUESTION_BORDER
558 } else {
559 app.selected_agent()
560 .and_then(|agent| agent.color.as_ref())
561 .and_then(|c| crate::agent::parse_color(c))
562 .unwrap_or(ACCENT)
563 };
564 let mut lines = Vec::new();
565 let message_indent = layout.message_indent();
566 let tool_done_continuation = layout.message_child_indent();
567 let tool_pending_prefix = format!("{message_indent}{TOOL_PENDING_MARKER}");
568 let tool_pending_continuation = " ".repeat(tool_pending_prefix.chars().count());
569 let tool_style = ToolCallRenderStyle {
570 done_continuation: &tool_done_continuation,
571 pending_prefix: &tool_pending_prefix,
572 pending_continuation: &tool_pending_continuation,
573 };
574 let tool_context = ToolRenderContext {
575 available_width: width.saturating_sub(4).max(1),
576 style: tool_style,
577 layout,
578 };
579
580 for (idx, msg) in app.messages.iter().enumerate() {
581 match msg {
582 ChatMessage::User(text) => {
583 render_user_message_block(&mut lines, text, width, layout, border_color);
584 }
585 ChatMessage::Assistant(text) => {
586 ensure_single_blank_line(&mut lines);
587 for line in parse_markdown_lines(text, width, &message_indent) {
588 lines.push(line);
589 }
590 }
591 ChatMessage::CompactionPending => {
592 render_compaction_block(&mut lines, None, width, &message_indent);
593 }
594 ChatMessage::Compaction(summary) => {
595 render_compaction_block(&mut lines, Some(summary), width, &message_indent);
596 }
597 ChatMessage::Thinking(text) => {
598 render_thinking_block(&mut lines, text, width, &message_indent);
599 }
600 ChatMessage::ToolCall {
601 name,
602 args,
603 output,
604 is_error,
605 } => {
606 if idx > 0 && matches!(app.messages.get(idx - 1), Some(ChatMessage::Assistant(_))) {
607 ensure_single_blank_line(&mut lines);
608 }
609 render_tool_call_message(
610 &mut lines,
611 ToolCallMessage {
612 name,
613 args,
614 output: output.as_deref(),
615 is_error: *is_error,
616 },
617 tool_context,
618 );
619 }
620 ChatMessage::Error(text) => {
621 lines.push(Line::from(""));
622 lines.push(Line::from(vec![
623 Span::raw(message_indent.clone()),
624 Span::styled("Error:", Style::default().fg(Color::Red).bold()),
625 Span::raw(" "),
626 Span::styled(text.clone(), Style::default().fg(Color::Red)),
627 ]));
628 }
629 ChatMessage::Footer {
630 agent_display_name,
631 provider_name,
632 model_name,
633 duration,
634 interrupted,
635 } => {
636 render_footer_block(
637 &mut lines,
638 FooterBlock {
639 agent_display_name,
640 provider_name,
641 model_name,
642 duration,
643 interrupted: *interrupted,
644 },
645 &message_indent,
646 app.selected_agent(),
647 );
648 }
649 }
650 }
651
652 lines
653}
654
655fn parse_markdown_lines(text: &str, width: usize, indent: &str) -> Vec<Line<'static>> {
657 markdown_to_lines_with_indent(text, width, indent)
658}
659
660fn parse_markdown_lines_unindented(text: &str, width: usize) -> Vec<Line<'static>> {
661 markdown_to_lines_with_indent(text, width, "")
662}
663
664fn render_thinking_block(lines: &mut Vec<Line<'static>>, text: &str, width: usize, indent: &str) {
665 ensure_single_blank_line(lines);
666
667 let label = format!("{indent}Thinking: ");
668 let label_width = label.chars().count();
669 let wrapped = parse_markdown_lines_unindented(text, width.saturating_sub(label_width).max(1));
670
671 if wrapped.is_empty() {
672 lines.push(Line::from(Span::styled(
673 label,
674 Style::default().fg(THINKING_LABEL).italic(),
675 )));
676 lines.push(Line::from(""));
677 return;
678 }
679
680 let continuation_indent = indent.to_string();
681 for (index, line) in wrapped.into_iter().enumerate() {
682 let mut spans = Vec::with_capacity(line.spans.len() + 1);
683 if index == 0 {
684 spans.push(Span::styled(
685 label.clone(),
686 Style::default().fg(THINKING_LABEL).italic(),
687 ));
688 } else {
689 spans.push(Span::raw(continuation_indent.clone()));
690 }
691
692 spans.extend(line.spans.into_iter().map(|span| {
693 let style = span.style.fg(TEXT_SECONDARY);
694 Span::styled(span.content.into_owned(), style)
695 }));
696
697 lines.push(Line::from(spans));
698 }
699
700 lines.push(Line::from(""));
701}
702
703fn render_compaction_block(
704 lines: &mut Vec<Line<'static>>,
705 summary: Option<&str>,
706 width: usize,
707 indent: &str,
708) {
709 ensure_single_blank_line(lines);
710
711 let label = " Compaction ";
712 let available = width.saturating_sub(indent.chars().count());
713 let total_rule = available.max(label.chars().count() + 4);
714 let side = total_rule.saturating_sub(label.chars().count()) / 2;
715 let left = "-".repeat(side);
716 let right = "-".repeat(total_rule.saturating_sub(side + label.chars().count()));
717
718 lines.push(Line::from(vec![
719 Span::raw(indent.to_string()),
720 Span::styled(left, Style::default().fg(TEXT_MUTED)),
721 Span::styled(label, Style::default().fg(TEXT_MUTED)),
722 Span::styled(right, Style::default().fg(TEXT_MUTED)),
723 ]));
724 lines.push(Line::from(""));
725
726 if let Some(summary) = summary
727 && !summary.trim().is_empty()
728 {
729 for line in parse_markdown_lines(summary, width, indent) {
730 lines.push(line);
731 }
732 }
733}
734
735struct FooterBlock<'a> {
736 agent_display_name: &'a str,
737 provider_name: &'a str,
738 model_name: &'a str,
739 duration: &'a str,
740 interrupted: bool,
741}
742
743fn render_footer_block(
744 lines: &mut Vec<Line<'static>>,
745 footer: FooterBlock<'_>,
746 indent: &str,
747 agent: Option<&super::app::AgentOptionView>,
748) {
749 let agent_color = agent
751 .and_then(|a| a.color.as_ref())
752 .and_then(|c| crate::agent::parse_color(c))
753 .unwrap_or(TEXT_PRIMARY);
754
755 let (status_symbol, status_color) = if footer.interrupted {
757 ("✗", Color::Red)
758 } else {
759 ("✓", Color::Rgb(25, 110, 61))
760 };
761
762 let mut footer_parts: Vec<Span<'static>> = vec![
764 Span::styled(status_symbol, Style::default().fg(status_color)),
765 Span::raw(" "),
766 Span::styled(
767 footer.agent_display_name.to_string(),
768 Style::default().fg(agent_color),
769 ),
770 Span::raw(" "),
771 Span::styled(
772 footer.provider_name.to_string(),
773 Style::default().fg(TEXT_MUTED),
774 ),
775 Span::raw(" "),
776 Span::styled(
777 footer.model_name.to_string(),
778 Style::default().fg(TEXT_MUTED),
779 ),
780 Span::raw(" "),
781 Span::styled(
782 footer.duration.to_string(),
783 Style::default().fg(TEXT_PRIMARY),
784 ),
785 ];
786
787 if footer.interrupted {
789 footer_parts.push(Span::raw(" "));
790 footer_parts.push(Span::styled("interrupted", Style::default().fg(Color::Red)));
791 }
792
793 lines.push(Line::from(""));
795
796 let mut indent_spans = vec![Span::raw(indent.to_string())];
798 indent_spans.extend(footer_parts);
799 lines.push(Line::from(indent_spans));
800
801 lines.push(Line::from(""));
802}
803
804fn wrap_text(text: &str, width: usize) -> Vec<String> {
806 if width == 0 {
807 return vec![text.to_string()];
808 }
809
810 let mut result = Vec::new();
811 for line in text.lines() {
812 if line.is_empty() {
813 result.push(String::new());
814 continue;
815 }
816 let mut current = String::new();
817 for word in line.split_whitespace() {
818 if current.is_empty() {
819 current = word.to_string();
820 } else if current.len() + 1 + word.len() <= width {
821 current.push(' ');
822 current.push_str(word);
823 } else {
824 result.push(current);
825 current = word.to_string();
826 }
827 }
828 if !current.is_empty() {
829 result.push(current);
830 }
831 }
832 if result.is_empty() {
833 result.push(String::new());
834 }
835 result
836}
837
838fn wrap_compact_text(text: &str, width: usize) -> Vec<String> {
839 if text.chars().count() > MAX_TOOL_OUTPUT_LEN {
840 let truncated = truncate_chars(text, MAX_TOOL_OUTPUT_LEN);
841 return wrap_text(&truncated, width);
842 }
843 wrap_text(text, width)
844}
845
846fn push_wrapped_tool_rows(
847 lines: &mut Vec<Line<'static>>,
848 wrapped: &[String],
849 first_prefix: Vec<Span<'static>>,
850 continuation_prefix: Vec<Span<'static>>,
851 text_style: Style,
852) {
853 for (index, text) in wrapped.iter().enumerate() {
854 let mut row = if index == 0 {
855 first_prefix.clone()
856 } else {
857 continuation_prefix.clone()
858 };
859 row.push(Span::styled(text.clone(), text_style));
860 lines.push(Line::from(row));
861 }
862}
863
864#[derive(Clone, Copy)]
865struct ToolCallRenderStyle<'a> {
866 done_continuation: &'a str,
867 pending_prefix: &'a str,
868 pending_continuation: &'a str,
869}
870
871#[derive(Clone, Copy)]
872struct ToolRenderContext<'a> {
873 available_width: usize,
874 style: ToolCallRenderStyle<'a>,
875 layout: UiLayout,
876}
877
878#[derive(Clone, Copy)]
879struct ToolCallMessage<'a> {
880 name: &'a str,
881 args: &'a str,
882 output: Option<&'a str>,
883 is_error: Option<bool>,
884}
885
886#[derive(Clone, Copy)]
887struct CompletedToolCall<'a> {
888 name: &'a str,
889 label: &'a str,
890 output: Option<&'a str>,
891 is_error: bool,
892}
893
894fn render_tool_call_message(
895 lines: &mut Vec<Line<'static>>,
896 message: ToolCallMessage<'_>,
897 context: ToolRenderContext<'_>,
898) {
899 let args_value: Value = serde_json::from_str(message.args).unwrap_or(Value::Null);
900 let label = render_tool_start(message.name, &args_value).line;
901
902 match message.is_error {
903 Some(error) => {
904 if !error
905 && (message.name == "edit" || message.name == "write")
906 && let Some(tool_output) = message.output
907 && render_edit_diff_block(
908 lines,
909 message.name,
910 tool_output,
911 context.available_width,
912 context.layout,
913 )
914 {
915 return;
916 }
917
918 render_completed_tool_call(
919 lines,
920 CompletedToolCall {
921 name: message.name,
922 label: &label,
923 output: message.output,
924 is_error: error,
925 },
926 context,
927 );
928 }
929 None => render_pending_tool_call(
930 lines,
931 message.name,
932 &label,
933 message.args,
934 context.available_width,
935 context.style.pending_prefix,
936 context.style.pending_continuation,
937 ),
938 }
939}
940
941fn render_completed_tool_call(
942 lines: &mut Vec<Line<'static>>,
943 completed: CompletedToolCall<'_>,
944 context: ToolRenderContext<'_>,
945) {
946 let completed_label = if completed.name == "task" {
947 task_completed_label(completed.label, completed.output)
948 } else if completed.is_error {
949 completed.label.to_string()
950 } else {
951 append_tool_result_count(completed.name, completed.label, completed.output)
952 };
953 let symbol = if completed.is_error { "x" } else { "✓" };
954 let color = if completed.is_error {
955 Color::Red
956 } else {
957 INPUT_ACCENT
958 };
959 let wrapped = wrap_compact_text(&completed_label, context.available_width);
960
961 push_wrapped_tool_rows(
962 lines,
963 &wrapped,
964 vec![
965 Span::raw(context.layout.message_indent()),
966 Span::styled(symbol, Style::default().fg(color).bold()),
967 Span::raw(" "),
968 ],
969 vec![Span::raw(context.style.done_continuation.to_string())],
970 Style::default().fg(TEXT_SECONDARY),
971 );
972}
973
974fn render_pending_tool_call(
975 lines: &mut Vec<Line<'static>>,
976 tool_name: &str,
977 label: &str,
978 args: &str,
979 available_width: usize,
980 tool_pending_prefix: &str,
981 tool_pending_continuation: &str,
982) {
983 let pending_label = if tool_name == "task" {
984 let elapsed = task_pending_elapsed_secs(args).unwrap_or(0);
985 format!("{label} {}", format_elapsed_seconds(elapsed))
986 } else {
987 label.to_string()
988 };
989 let wrapped = wrap_compact_text(&pending_label, available_width.saturating_sub(1));
990 push_wrapped_tool_rows(
991 lines,
992 &wrapped,
993 vec![Span::styled(
994 tool_pending_prefix.to_string(),
995 Style::default().fg(TEXT_MUTED),
996 )],
997 vec![Span::raw(tool_pending_continuation.to_string())],
998 Style::default().fg(TEXT_SECONDARY),
999 );
1000}
1001
1002fn task_pending_elapsed_secs(args: &str) -> Option<u64> {
1003 let args_value = serde_json::from_str::<Value>(args).ok()?;
1004 let started_at = args_value
1005 .as_object()
1006 .and_then(|map| map.get("__started_at"))
1007 .and_then(Value::as_u64)?;
1008 let now = std::time::SystemTime::now()
1009 .duration_since(std::time::UNIX_EPOCH)
1010 .ok()?
1011 .as_secs();
1012 Some(now.saturating_sub(started_at))
1013}
1014
1015fn task_completed_label(base_label: &str, output: Option<&str>) -> String {
1016 let Some(output) = output else {
1017 return format!("{base_label} 0s");
1018 };
1019
1020 let Ok(parsed) = serde_json::from_str::<TaskToolRenderOutput>(output) else {
1021 return format!("{base_label} 0s");
1022 };
1023
1024 let label = format!("Task [{}]: {}", title_case(&parsed.agent_name), parsed.name);
1025 let finished = parsed.finished_at.unwrap_or(parsed.started_at);
1026 format!(
1027 "{} {}",
1028 label,
1029 format_elapsed_seconds(finished.saturating_sub(parsed.started_at))
1030 )
1031}
1032
1033fn format_elapsed_seconds(secs: u64) -> String {
1034 if secs < 60 {
1035 return format!("{}s", secs);
1036 }
1037 let mins = secs / 60;
1038 let rem = secs % 60;
1039 format!("{}m {}s", mins, rem)
1040}
1041
1042fn title_case(name: &str) -> String {
1043 let mut result = String::new();
1044 let mut capitalize = true;
1045 for ch in name.chars() {
1046 if matches!(ch, '_' | '-' | ' ') {
1047 if !result.ends_with(' ') {
1048 result.push(' ');
1049 }
1050 capitalize = true;
1051 continue;
1052 }
1053 if capitalize {
1054 result.extend(ch.to_uppercase());
1055 capitalize = false;
1056 } else {
1057 result.extend(ch.to_lowercase());
1058 }
1059 }
1060 result.trim().to_string()
1061}
1062
1063fn render_input(f: &mut Frame, app: &ChatApp, area: Rect, layout: UiLayout) {
1064 let left_border_x = area.x.saturating_add(layout.user_bubble_indent() as u16);
1065 f.render_widget(Block::default().style(Style::default().bg(PAGE_BG)), area);
1066 let input_panel_area = Rect {
1067 x: left_border_x,
1068 y: area.y,
1069 width: area
1070 .width
1071 .saturating_sub(left_border_x.saturating_sub(area.x)),
1072 height: area.height,
1073 };
1074 f.render_widget(
1075 Block::default().style(Style::default().bg(INPUT_PANEL_BG)),
1076 input_panel_area,
1077 );
1078
1079 let border_color = app
1080 .selected_agent()
1081 .and_then(|agent| agent.color.as_ref())
1082 .and_then(|c| crate::agent::parse_color(c))
1083 .unwrap_or(ACCENT);
1084
1085 for y in area.y..area.bottom() {
1086 f.render_widget(
1087 Paragraph::new("▌").style(Style::default().fg(border_color).bg(INPUT_PANEL_BG)),
1088 Rect {
1089 x: left_border_x,
1090 y,
1091 width: 1,
1092 height: 1,
1093 },
1094 );
1095 }
1096
1097 let content_y = area
1098 .y
1099 .saturating_add(1)
1100 .min(area.bottom().saturating_sub(1));
1101 let content_x = left_border_x.saturating_add(2);
1102 let content_height = area.height.saturating_sub(2).max(1);
1103 let input_height = if app.has_pending_question() {
1104 content_height.max(1)
1105 } else {
1106 content_height.saturating_sub(2).max(1)
1107 };
1108 let content_area = Rect {
1109 x: content_x,
1110 y: content_y,
1111 width: area
1112 .width
1113 .saturating_sub(content_x.saturating_sub(area.x) + 1),
1114 height: input_height,
1115 };
1116
1117 if let Some(question) = app.pending_question_view() {
1118 let mut lines = Vec::new();
1119 let mut custom_input_row: Option<usize> = None;
1120 let mut custom_input_indent: usize = 0;
1121 lines.push(Line::from(Span::styled(
1122 question.question,
1123 Style::default().fg(TEXT_PRIMARY).bold(),
1124 )));
1125 lines.push(Line::from(""));
1126
1127 for (idx, option) in question.options.iter().enumerate() {
1128 let option_style = if option.active {
1129 Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
1130 } else if option.selected {
1131 Style::default().fg(INPUT_ACCENT)
1132 } else {
1133 Style::default().fg(TEXT_SECONDARY)
1134 };
1135
1136 let prefix = if option.submit {
1137 format!("{}. ", idx + 1)
1138 } else if question.multiple {
1139 format!(
1140 "{}. [{}] ",
1141 idx + 1,
1142 if option.selected { "x" } else { " " }
1143 )
1144 } else {
1145 format!("{}. ", idx + 1)
1146 };
1147 let prefix_width = prefix.chars().count();
1148
1149 lines.push(Line::from(vec![
1150 Span::styled(prefix, option_style),
1151 Span::styled(option.label.clone(), option_style),
1152 ]));
1153
1154 if option.custom {
1155 custom_input_indent = prefix_width;
1156 }
1157
1158 if !option.description.trim().is_empty() {
1159 for description_line in option.description.split('\n') {
1160 lines.push(Line::from(vec![
1161 Span::raw(" ".repeat(prefix_width)),
1162 Span::styled(
1163 description_line.to_string(),
1164 Style::default().fg(TEXT_MUTED),
1165 ),
1166 ]));
1167 }
1168 }
1169 }
1170
1171 if question.custom_mode {
1172 custom_input_row = Some(lines.len());
1173 if question.custom_value.is_empty() {
1174 lines.push(Line::from(vec![
1175 Span::raw(" ".repeat(custom_input_indent)),
1176 Span::styled("Type your own answer", Style::default().fg(TEXT_MUTED)),
1177 ]));
1178 } else {
1179 for custom_line in question.custom_value.split('\n') {
1180 lines.push(Line::from(vec![
1181 Span::raw(" ".repeat(custom_input_indent)),
1182 Span::styled(custom_line.to_string(), Style::default().fg(TEXT_SECONDARY)),
1183 ]));
1184 }
1185 }
1186 }
1187
1188 lines.push(Line::from(""));
1189 lines.push(Line::from(vec![
1190 Span::styled("↑↓", Style::default().fg(TEXT_PRIMARY)),
1191 Span::styled(" select", Style::default().fg(TEXT_MUTED)),
1192 Span::raw(" "),
1193 Span::styled("enter", Style::default().fg(TEXT_PRIMARY)),
1194 Span::styled(
1195 if question.custom_mode {
1196 " submit"
1197 } else if question.multiple {
1198 " toggle/submit"
1199 } else {
1200 " submit"
1201 },
1202 Style::default().fg(TEXT_MUTED),
1203 ),
1204 Span::raw(if question.custom_mode { " " } else { "" }),
1205 Span::styled(
1206 if question.custom_mode {
1207 "shift+enter"
1208 } else {
1209 ""
1210 },
1211 Style::default().fg(TEXT_PRIMARY),
1212 ),
1213 Span::styled(
1214 if question.custom_mode { " newline" } else { "" },
1215 Style::default().fg(TEXT_MUTED),
1216 ),
1217 Span::raw(" "),
1218 Span::styled("esc", Style::default().fg(TEXT_PRIMARY)),
1219 Span::styled(" dismiss", Style::default().fg(TEXT_MUTED)),
1220 ]));
1221
1222 f.render_widget(
1223 Paragraph::new(Text::from(lines))
1224 .style(Style::default().fg(TEXT_PRIMARY).bg(INPUT_PANEL_BG))
1225 .wrap(Wrap { trim: false }),
1226 content_area,
1227 );
1228
1229 if question.custom_mode
1230 && let Some(base_row) = custom_input_row
1231 {
1232 let custom_lines: Vec<&str> = if question.custom_value.is_empty() {
1233 vec![""]
1234 } else {
1235 question.custom_value.split('\n').collect()
1236 };
1237 let row = base_row + custom_lines.len().saturating_sub(1);
1238 let col = custom_input_indent
1239 + custom_lines
1240 .last()
1241 .map(|line| line.chars().count())
1242 .unwrap_or(0);
1243 if row < content_area.height as usize && col < content_area.width as usize {
1244 f.set_cursor_position((content_area.x + col as u16, content_area.y + row as u16));
1245 }
1246 }
1247 return;
1248 }
1249
1250 let (input_value, cursor_row, cursor_col) = if app.input.is_empty() {
1251 ("Tell me more about this project...".to_string(), 0, 0)
1252 } else {
1253 let layout = input_viewport_layout(
1254 &app.input,
1255 app.cursor,
1256 content_area.width as usize,
1257 content_area.height as usize,
1258 );
1259 (
1260 layout.lines.join("\n"),
1261 layout.cursor_row,
1262 layout.cursor_col,
1263 )
1264 };
1265
1266 f.render_widget(
1267 Paragraph::new(input_value)
1268 .style(Style::default().fg(TEXT_PRIMARY).bg(INPUT_PANEL_BG))
1269 .wrap(Wrap { trim: false }),
1270 content_area,
1271 );
1272
1273 if (cursor_col as u16) < content_area.width && (cursor_row as u16) < content_area.height {
1274 f.set_cursor_position((
1275 content_area.x + cursor_col as u16,
1276 content_area.y + cursor_row as u16,
1277 ));
1278 }
1279
1280 let status_y = content_y
1281 .saturating_add(content_height.saturating_sub(1))
1282 .min(area.bottom().saturating_sub(1));
1283
1284 let status_lines = build_status_line(app);
1286 f.render_widget(
1287 Paragraph::new(status_lines)
1288 .style(Style::default().fg(TEXT_MUTED).bg(INPUT_PANEL_BG))
1289 .wrap(Wrap { trim: false }),
1290 Rect {
1291 x: content_x,
1292 y: status_y,
1293 width: area
1294 .width
1295 .saturating_sub(content_x.saturating_sub(area.x) + 1),
1296 height: 1,
1297 },
1298 );
1299}
1300
1301fn question_prompt_line_count(app: &ChatApp, _width: usize) -> usize {
1302 let Some(question) = app.pending_question_view() else {
1303 return 1;
1304 };
1305
1306 let body_rows = question
1307 .options
1308 .iter()
1309 .map(|option| {
1310 let description_rows = if option.description.trim().is_empty() {
1311 0
1312 } else {
1313 option.description.split('\n').count()
1314 };
1315 1 + description_rows
1316 })
1317 .sum::<usize>();
1318 let custom_rows = if question.custom_mode {
1319 question.custom_value.split('\n').count().max(1)
1320 } else {
1321 0
1322 };
1323 (body_rows + custom_rows + 4).max(1)
1324}
1325
1326fn selected_provider_name(app: &ChatApp) -> String {
1327 app.available_models
1328 .iter()
1329 .find(|model| model.full_id == app.selected_model_ref())
1330 .map(|model| model.provider_name.clone())
1331 .or_else(|| {
1332 app.selected_model_ref()
1333 .split_once('/')
1334 .map(|(provider, _)| provider.to_string())
1335 })
1336 .filter(|name| !name.trim().is_empty())
1337 .unwrap_or_else(|| {
1338 app.selected_model_ref()
1339 .split_once('/')
1340 .map(|(provider, _)| provider.to_string())
1341 .unwrap_or_else(|| app.selected_model_ref().to_string())
1342 })
1343}
1344
1345fn selected_model_name(app: &ChatApp) -> String {
1346 app.available_models
1347 .iter()
1348 .find(|model| model.full_id == app.selected_model_ref())
1349 .map(|model| model.model_name.clone())
1350 .or_else(|| {
1351 app.selected_model_ref()
1352 .split_once('/')
1353 .map(|(_, model)| model.to_string())
1354 })
1355 .filter(|name| !name.trim().is_empty())
1356 .unwrap_or_else(|| {
1357 app.selected_model_ref()
1358 .split_once('/')
1359 .map(|(_, model)| model.to_string())
1360 .unwrap_or_else(|| app.selected_model_ref().to_string())
1361 })
1362}
1363
1364fn build_status_line(app: &ChatApp) -> Line<'static> {
1365 let provider_name = selected_provider_name(app);
1366 let model_name = selected_model_name(app);
1367
1368 if let Some(agent) = app.selected_agent() {
1369 let agent_color = agent
1371 .color
1372 .as_ref()
1373 .and_then(|c| crate::agent::parse_color(c))
1374 .unwrap_or(TEXT_PRIMARY);
1375
1376 Line::from(vec![
1377 Span::styled(agent.display_name.clone(), Style::default().fg(agent_color)),
1378 Span::raw(" "),
1379 Span::styled(provider_name, Style::default().fg(TEXT_MUTED)),
1380 Span::raw(" "),
1381 Span::styled(model_name, Style::default().fg(TEXT_MUTED)),
1382 ])
1383 } else {
1384 Line::from(vec![
1386 Span::styled(provider_name, Style::default().fg(TEXT_MUTED)),
1387 Span::raw(" "),
1388 Span::styled(model_name, Style::default().fg(TEXT_MUTED)),
1389 ])
1390 }
1391}
1392
1393#[derive(Clone)]
1394struct WrappedInputLine {
1395 text: String,
1396 start: usize,
1397 end: usize,
1398}
1399
1400struct InputViewportLayout {
1401 lines: Vec<String>,
1402 cursor_row: usize,
1403 cursor_col: usize,
1404}
1405
1406fn input_viewport_layout(
1407 input: &str,
1408 cursor: usize,
1409 width: usize,
1410 height: usize,
1411) -> InputViewportLayout {
1412 if input.is_empty() {
1413 return InputViewportLayout {
1414 lines: Vec::new(),
1415 cursor_row: 0,
1416 cursor_col: 0,
1417 };
1418 }
1419
1420 let wrapped = wrap_input_lines(input, width);
1421 let (cursor_line, cursor_col) = cursor_visual_position(input, cursor, &wrapped);
1422 let start = viewport_start(cursor_line, wrapped.len(), height);
1423 let end = (start + height.max(1)).min(wrapped.len());
1424 let lines = wrapped[start..end]
1425 .iter()
1426 .map(|line| line.text.clone())
1427 .collect();
1428
1429 InputViewportLayout {
1430 lines,
1431 cursor_row: cursor_line.saturating_sub(start),
1432 cursor_col,
1433 }
1434}
1435
1436fn wrap_input_lines(input: &str, width: usize) -> Vec<WrappedInputLine> {
1437 let max_width = width.max(1);
1438 let mut lines = Vec::new();
1439 let mut line_start = 0usize;
1440 let mut logical_lines = input.split('\n').peekable();
1441
1442 while let Some(raw_line) = logical_lines.next() {
1443 push_wrapped_input_logical_line(&mut lines, raw_line, line_start, max_width);
1444
1445 line_start += raw_line.len();
1446 if logical_lines.peek().is_some() {
1447 line_start += 1;
1448 }
1449 }
1450
1451 if lines.is_empty() {
1452 lines.push(WrappedInputLine {
1453 text: String::new(),
1454 start: 0,
1455 end: 0,
1456 });
1457 }
1458
1459 lines
1460}
1461
1462fn push_wrapped_input_logical_line(
1463 lines: &mut Vec<WrappedInputLine>,
1464 raw_line: &str,
1465 line_start: usize,
1466 max_width: usize,
1467) {
1468 if raw_line.is_empty() {
1469 lines.push(WrappedInputLine {
1470 text: String::new(),
1471 start: line_start,
1472 end: line_start,
1473 });
1474 return;
1475 }
1476
1477 let mut chunk_start_rel = 0usize;
1478 let mut chunk_chars = 0usize;
1479
1480 for (rel, ch) in raw_line.char_indices() {
1481 if chunk_chars >= max_width {
1482 push_wrapped_input_chunk(lines, raw_line, line_start, chunk_start_rel, rel);
1483 chunk_start_rel = rel;
1484 chunk_chars = 0;
1485 }
1486
1487 chunk_chars += 1;
1488 if rel + ch.len_utf8() == raw_line.len() {
1489 push_wrapped_input_chunk(lines, raw_line, line_start, chunk_start_rel, raw_line.len());
1490 }
1491 }
1492}
1493
1494fn push_wrapped_input_chunk(
1495 lines: &mut Vec<WrappedInputLine>,
1496 raw_line: &str,
1497 line_start: usize,
1498 chunk_start_rel: usize,
1499 chunk_end_rel: usize,
1500) {
1501 lines.push(WrappedInputLine {
1502 text: raw_line[chunk_start_rel..chunk_end_rel].to_string(),
1503 start: line_start + chunk_start_rel,
1504 end: line_start + chunk_end_rel,
1505 });
1506}
1507
1508fn cursor_visual_position(
1509 input: &str,
1510 cursor: usize,
1511 lines: &[WrappedInputLine],
1512) -> (usize, usize) {
1513 if lines.is_empty() {
1514 return (0, 0);
1515 }
1516
1517 let cursor = cursor.min(input.len());
1518 for (idx, line) in lines.iter().enumerate() {
1519 if cursor < line.start {
1520 continue;
1521 }
1522 if cursor == line.end
1523 && idx + 1 < lines.len()
1524 && lines[idx + 1].start == cursor
1525 && line.end > line.start
1526 {
1527 continue;
1528 }
1529 if cursor <= line.end {
1530 let slice_end = cursor.min(line.end);
1531 let col = input[line.start..slice_end].chars().count();
1532 return (idx, col);
1533 }
1534 }
1535
1536 let last = &lines[lines.len() - 1];
1537 (lines.len() - 1, input[last.start..last.end].chars().count())
1538}
1539
1540fn viewport_start(cursor_line: usize, total_lines: usize, height: usize) -> usize {
1541 let height = height.max(1);
1542 if total_lines <= height {
1543 return 0;
1544 }
1545 if cursor_line < height {
1546 return 0;
1547 }
1548 if cursor_line >= total_lines.saturating_sub(height) {
1549 return total_lines.saturating_sub(height);
1550 }
1551 cursor_line + 1 - height
1552}
1553
1554fn input_line_count(input: &str, width: usize) -> usize {
1555 wrap_input_lines(input, width).len()
1556}
1557
1558fn blend_color_with_white(color: Color, amount: f64) -> Color {
1559 let amount = amount.clamp(0.0, 1.0);
1560 let to_rgb = match color {
1561 Color::Rgb(r, g, b) => Some((r, g, b)),
1562 Color::Black => Some((0, 0, 0)),
1563 Color::Red => Some((255, 0, 0)),
1564 Color::Green => Some((0, 200, 0)),
1565 Color::Yellow => Some((220, 180, 0)),
1566 Color::Blue => Some((0, 102, 255)),
1567 Color::Magenta => Some((200, 0, 200)),
1568 Color::Cyan => Some((0, 180, 200)),
1569 Color::White => Some((255, 255, 255)),
1570 Color::Gray | Color::DarkGray => Some((128, 128, 128)),
1571 Color::LightRed => Some((255, 110, 103)),
1572 Color::LightGreen => Some((105, 255, 105)),
1573 Color::LightYellow => Some((255, 255, 105)),
1574 Color::LightBlue => Some((98, 114, 164)),
1575 Color::LightMagenta => Some((246, 108, 181)),
1576 Color::LightCyan => Some((114, 159, 207)),
1577 Color::Indexed(_) | Color::Reset => None,
1578 };
1579
1580 if let Some((r, g, b)) = to_rgb {
1581 Color::Rgb(
1582 (r as f64 + (255.0 - r as f64) * amount).round() as u8,
1583 (g as f64 + (255.0 - g as f64) * amount).round() as u8,
1584 (b as f64 + (255.0 - b as f64) * amount).round() as u8,
1585 )
1586 } else {
1587 color
1588 }
1589}
1590
1591fn render_processing_indicator(f: &mut Frame, app: &ChatApp, area: Rect, layout: UiLayout) {
1592 if !app.is_processing {
1593 return;
1594 }
1595
1596 let agent_color = app
1597 .selected_agent()
1598 .and_then(|agent| agent.color.as_ref())
1599 .and_then(|color_str| crate::agent::parse_color(color_str));
1600
1601 let mut spans: Vec<Span<'static>> = vec![Span::raw(layout.message_indent())];
1602
1603 let bar_len = area.width.saturating_sub(35).clamp(6, 10) as usize;
1604 let head = scanner_position(app.processing_step(85), bar_len, 6);
1605 let base_color = agent_color.unwrap_or(PROGRESS_HEAD);
1606
1607 for idx in 0..bar_len {
1608 let distance = head.abs_diff(idx);
1609 let (glyph, style) = if distance == 0 {
1610 (
1611 "■",
1612 Style::default().fg(base_color).add_modifier(Modifier::BOLD),
1613 )
1614 } else if distance == 1 {
1615 (
1616 "■",
1617 Style::default().fg(blend_color_with_white(base_color, 0.30)),
1618 )
1619 } else if distance == 2 {
1620 (
1621 "■",
1622 Style::default().fg(blend_color_with_white(base_color, 0.40)),
1623 )
1624 } else {
1625 (
1626 "⬝",
1627 Style::default().fg(blend_color_with_white(base_color, 0.52)),
1628 )
1629 };
1630 spans.push(Span::styled(glyph, style));
1631 }
1632
1633 spans.push(Span::raw(PROCESSING_STATUS_GAP));
1634 spans.push(Span::styled(
1635 app.processing_duration(),
1636 Style::default().fg(TEXT_MUTED),
1637 ));
1638 spans.push(Span::raw(PROCESSING_STATUS_GAP));
1639 spans.push(Span::styled(
1640 app.processing_interrupt_hint(),
1641 Style::default().fg(TEXT_MUTED),
1642 ));
1643
1644 let paragraph = Paragraph::new(Line::from(spans)).style(Style::default().bg(PAGE_BG));
1645 f.render_widget(paragraph, area);
1646}
1647
1648fn scanner_position(step: usize, width: usize, hold_frames: usize) -> usize {
1649 if width <= 1 {
1650 return 0;
1651 }
1652
1653 let travel = width - 1;
1654 let cycle = hold_frames + travel + hold_frames + travel;
1655 let phase = step % cycle;
1656
1657 if phase < hold_frames {
1658 0
1659 } else if phase < hold_frames + travel {
1660 phase - hold_frames
1661 } else if phase < hold_frames + travel + hold_frames {
1662 travel
1663 } else {
1664 travel - (phase - hold_frames - travel - hold_frames)
1665 }
1666}
1667
1668fn inset_rect(area: Rect, padding_x: u16, padding_y: u16) -> Rect {
1669 Rect {
1670 x: area.x.saturating_add(padding_x),
1671 y: area.y.saturating_add(padding_y),
1672 width: area.width.saturating_sub(padding_x.saturating_mul(2)),
1673 height: area.height.saturating_sub(padding_y.saturating_mul(2)),
1674 }
1675}
1676
1677pub(crate) fn compute_layout_rects(area: Rect, app: &ChatApp) -> AppLayoutRects {
1678 let layout = UiLayout::default();
1679 let app_area = inset_rect(
1680 area,
1681 layout.main_outer_padding_x,
1682 layout.main_outer_padding_y,
1683 );
1684 let columns = Layout::default()
1685 .direction(Direction::Horizontal)
1686 .constraints([
1687 Constraint::Min(40),
1688 Constraint::Length(layout.left_column_right_margin),
1689 Constraint::Length(layout.sidebar_width),
1690 ])
1691 .split(app_area);
1692
1693 let main_area = columns[0];
1694 let sidebar_area = if columns.len() > 2 {
1695 Some(columns[2])
1696 } else {
1697 None
1698 };
1699
1700 let input_content_width = main_area
1701 .width
1702 .saturating_sub(layout.user_bubble_indent() as u16 + 3)
1703 as usize;
1704 let input_line_count =
1705 input_line_count(&app.input, input_content_width).clamp(1, MAX_INPUT_LINES);
1706 let input_area_height = if app.has_pending_question() {
1707 (question_prompt_line_count(app, input_content_width) + 2) as u16
1708 } else {
1709 (input_line_count + 4) as u16
1710 };
1711 let main_chunks = Layout::default()
1712 .direction(Direction::Vertical)
1713 .constraints([
1714 Constraint::Min(3),
1715 Constraint::Length(1),
1716 Constraint::Length(1),
1717 Constraint::Length(1),
1718 Constraint::Length(input_area_height),
1719 ])
1720 .split(main_area);
1721
1722 let sidebar_content = sidebar_area.and_then(|sidebar_area| {
1723 let sidebar_bottom = main_chunks[4].bottom();
1724 let clipped_sidebar_area = Rect {
1725 x: sidebar_area.x,
1726 y: sidebar_area.y,
1727 width: sidebar_area.width,
1728 height: sidebar_bottom.saturating_sub(sidebar_area.y),
1729 };
1730 if clipped_sidebar_area.width == 0 || clipped_sidebar_area.height == 0 {
1731 return None;
1732 }
1733
1734 let block = Block::default().style(Style::default().bg(SIDEBAR_BG));
1735 let inner = block.inner(clipped_sidebar_area);
1736 let content = inset_rect(inner, 2, 0);
1737 if content.width == 0 || content.height == 0 {
1738 None
1739 } else {
1740 Some(content)
1741 }
1742 });
1743
1744 let main_messages = if main_chunks[0].height > 0 {
1745 Some(main_chunks[0])
1746 } else {
1747 None
1748 };
1749
1750 AppLayoutRects {
1751 main_messages,
1752 sidebar_content,
1753 }
1754}
1755
1756fn abbreviate_path(path: &str, max_chars: usize) -> String {
1757 if max_chars == 0 {
1758 return String::new();
1759 }
1760 let path_chars = path.chars().count();
1761 if path_chars <= max_chars {
1762 return path.to_string();
1763 }
1764
1765 let tail_chars = max_chars.saturating_sub(3);
1766 let tail: String = path
1767 .chars()
1768 .rev()
1769 .take(tail_chars)
1770 .collect::<Vec<_>>()
1771 .into_iter()
1772 .rev()
1773 .collect();
1774 format!("...{}", tail)
1775}
1776
1777fn format_sidebar_directory(path: &str, git_branch: Option<&str>) -> String {
1778 let simplified = simplify_home_path(path);
1779 match git_branch {
1780 Some(branch) if !branch.is_empty() => format!("{simplified} @ {branch}"),
1781 _ => simplified,
1782 }
1783}
1784
1785fn simplify_home_path(path: &str) -> String {
1786 let Some(home) = dirs::home_dir() else {
1787 return path.to_string();
1788 };
1789
1790 let home = home.to_string_lossy();
1791 if path == home {
1792 return "~".to_string();
1793 }
1794
1795 let home_prefix = format!("{home}/");
1796 if let Some(rest) = path.strip_prefix(&home_prefix) {
1797 return format!("~/{rest}");
1798 }
1799
1800 path.to_string()
1801}
1802
1803#[derive(Debug, Clone)]
1804struct ModifiedFileSummary {
1805 path: String,
1806 added_lines: usize,
1807 removed_lines: usize,
1808}
1809
1810fn collect_modified_files(messages: &[ChatMessage]) -> Vec<ModifiedFileSummary> {
1811 let mut files: Vec<ModifiedFileSummary> = Vec::new();
1812
1813 for message in messages {
1814 let ChatMessage::ToolCall {
1815 output, is_error, ..
1816 } = message
1817 else {
1818 continue;
1819 };
1820
1821 if !matches!(is_error, Some(false)) {
1822 continue;
1823 }
1824
1825 let Some(output) = output else {
1826 continue;
1827 };
1828
1829 let Some(parsed) = parse_modified_file_summary(output) else {
1830 continue;
1831 };
1832
1833 if let Some(existing) = files.iter_mut().find(|item| item.path == parsed.path) {
1834 existing.added_lines = existing.added_lines.saturating_add(parsed.added_lines);
1835 existing.removed_lines = existing.removed_lines.saturating_add(parsed.removed_lines);
1836 continue;
1837 }
1838
1839 files.push(parsed);
1840 }
1841
1842 files
1843}
1844
1845fn parse_modified_file_summary(output: &str) -> Option<ModifiedFileSummary> {
1846 let value = serde_json::from_str::<Value>(output).ok()?;
1847 let path = value.get("path")?.as_str()?.to_string();
1848 let summary = value.get("summary")?;
1849 let added_lines = summary.get("added_lines")?.as_u64()? as usize;
1850 let removed_lines = summary.get("removed_lines")?.as_u64()? as usize;
1851
1852 if added_lines == 0 && removed_lines == 0 {
1853 return None;
1854 }
1855
1856 Some(ModifiedFileSummary {
1857 path,
1858 added_lines,
1859 removed_lines,
1860 })
1861}
1862
1863fn append_modified_file_list(
1864 lines: &mut Vec<Line<'static>>,
1865 files: &[ModifiedFileSummary],
1866 content_width: usize,
1867) {
1868 let line_width = content_width.saturating_sub(SIDEBAR_INDENT.chars().count());
1869
1870 for file in files {
1871 let added_text = if file.added_lines > 0 {
1872 format!("+{}", file.added_lines)
1873 } else {
1874 String::new()
1875 };
1876 let removed_text = if file.removed_lines > 0 {
1877 format!("-{}", file.removed_lines)
1878 } else {
1879 String::new()
1880 };
1881 let has_added = !added_text.is_empty();
1882
1883 let gap = if has_added && !removed_text.is_empty() {
1884 1
1885 } else {
1886 0
1887 };
1888 let delta_len = added_text.chars().count() + removed_text.chars().count() + gap;
1889 let path_max = line_width.saturating_sub(delta_len + 1);
1890 let path_text = truncate_chars(&file.path, path_max.max(1));
1891 let spaces = line_width
1892 .saturating_sub(path_text.chars().count() + delta_len)
1893 .max(1);
1894
1895 let mut spans = vec![
1896 Span::styled(
1897 sidebar_prefixed(&path_text),
1898 Style::default().fg(TEXT_SECONDARY),
1899 ),
1900 Span::raw(" ".repeat(spaces)),
1901 ];
1902
1903 if has_added {
1904 spans.push(Span::styled(
1905 added_text,
1906 Style::default().fg(DIFF_ADD_FG).bold(),
1907 ));
1908 }
1909 if !removed_text.is_empty() {
1910 if has_added {
1911 spans.push(Span::raw(" "));
1912 }
1913 spans.push(Span::styled(
1914 removed_text,
1915 Style::default().fg(DIFF_REMOVE_FG).bold(),
1916 ));
1917 }
1918
1919 lines.push(Line::from(spans));
1920 }
1921}
1922
1923fn append_sidebar_list(lines: &mut Vec<Line<'static>>, items: &[TodoItemView], max_items: usize) {
1924 if max_items == 0 {
1925 return;
1926 }
1927 if items.is_empty() {
1928 lines.push(Line::from(Span::styled(
1929 sidebar_prefixed("none"),
1930 Style::default().fg(TEXT_MUTED),
1931 )));
1932 return;
1933 }
1934
1935 let shown = items.len().min(max_items);
1936 for item in items.iter().take(shown) {
1937 let (marker, item_style) = match item.status {
1938 TodoStatus::Pending | TodoStatus::InProgress => {
1939 ("[ ] ", Style::default().fg(TEXT_PRIMARY))
1940 }
1941 TodoStatus::Completed => ("[x] ", Style::default().fg(TEXT_MUTED)),
1942 TodoStatus::Cancelled => ("[-] ", Style::default().fg(TEXT_MUTED)),
1943 };
1944
1945 lines.push(Line::from(vec![
1946 Span::styled(sidebar_prefixed(marker), Style::default().fg(INPUT_ACCENT)),
1947 Span::styled(item.content.clone(), item_style),
1948 ]));
1949 }
1950
1951 if items.len() > shown {
1952 lines.push(Line::from(Span::styled(
1953 "...",
1954 Style::default().fg(TEXT_MUTED).italic(),
1955 )));
1956 }
1957}
1958
1959fn render_edit_diff_block(
1960 lines: &mut Vec<Line<'static>>,
1961 tool_name: &str,
1962 output: &str,
1963 available_width: usize,
1964 layout: UiLayout,
1965) -> bool {
1966 let parsed: EditToolOutput = match serde_json::from_str(output) {
1967 Ok(value) => value,
1968 Err(_) => return false,
1969 };
1970 let child_indent = layout.message_child_indent();
1971
1972 lines.push(Line::from(vec![
1973 Span::raw(layout.message_indent()),
1974 Span::styled("✓ ", Style::default().fg(INPUT_ACCENT).bold()),
1975 Span::styled(
1976 format!(
1977 "{} {} +{} -{}",
1978 tool_title(tool_name),
1979 parsed.path,
1980 parsed.summary.added_lines,
1981 parsed.summary.removed_lines
1982 ),
1983 Style::default().fg(TEXT_SECONDARY),
1984 ),
1985 ]));
1986
1987 let (left_width, right_width) = diff_column_widths(available_width);
1988 if left_width < MIN_DIFF_COLUMN_WIDTH || right_width < MIN_DIFF_COLUMN_WIDTH {
1989 return render_edit_diff_block_single_column(lines, &parsed.diff, available_width, layout);
1990 }
1991
1992 let mut rendered_chars = 0;
1993 let mut truncated = false;
1994
1995 let mut raw_lines = parsed.diff.lines().peekable();
1996 let mut cursor = DiffLineCursor::default();
1997 let mut rendered_lines = 0;
1998 while let Some(side_by_side) = next_diff_row(&mut raw_lines, &mut cursor) {
1999 let line_chars = side_by_side.total_chars();
2000 if rendered_lines >= MAX_RENDERED_DIFF_LINES
2001 || rendered_chars + line_chars > MAX_RENDERED_DIFF_CHARS
2002 {
2003 truncated = true;
2004 break;
2005 }
2006 rendered_chars += line_chars;
2007 rendered_lines += 1;
2008
2009 render_side_by_side_diff_row(lines, &side_by_side, left_width, right_width, layout);
2010 }
2011
2012 if truncated {
2013 lines.push(Line::from(vec![
2014 Span::raw(child_indent.clone()),
2015 Span::styled(
2016 "... diff truncated",
2017 Style::default().fg(TEXT_MUTED).italic(),
2018 ),
2019 ]));
2020 }
2021
2022 true
2023}
2024
2025fn render_edit_diff_block_single_column(
2026 lines: &mut Vec<Line<'static>>,
2027 diff: &str,
2028 available_width: usize,
2029 layout: UiLayout,
2030) -> bool {
2031 let mut rendered_chars = 0;
2032 let mut truncated = false;
2033 let child_indent = layout.message_child_indent();
2034
2035 for (rendered_lines, raw_line) in diff.lines().enumerate() {
2036 let line_chars = raw_line.chars().count();
2037 if rendered_lines >= MAX_RENDERED_DIFF_LINES
2038 || rendered_chars + line_chars > MAX_RENDERED_DIFF_CHARS
2039 {
2040 truncated = true;
2041 break;
2042 }
2043 rendered_chars += line_chars;
2044
2045 let shown = truncate_chars(raw_line, available_width);
2046 let style = if raw_line.starts_with('+') && !raw_line.starts_with("+++") {
2047 Style::default().fg(DIFF_ADD_FG).bg(DIFF_ADD_BG)
2048 } else if raw_line.starts_with('-') && !raw_line.starts_with("---") {
2049 Style::default().fg(DIFF_REMOVE_FG).bg(DIFF_REMOVE_BG)
2050 } else if raw_line.starts_with("@@")
2051 || raw_line.starts_with("---")
2052 || raw_line.starts_with("+++")
2053 {
2054 Style::default().fg(DIFF_META_FG)
2055 } else {
2056 Style::default().fg(TEXT_MUTED)
2057 };
2058
2059 lines.push(Line::from(vec![
2060 Span::raw(child_indent.clone()),
2061 Span::styled(shown, style),
2062 ]));
2063 }
2064
2065 if truncated {
2066 lines.push(Line::from(vec![
2067 Span::raw(child_indent.clone()),
2068 Span::styled(
2069 "... diff truncated",
2070 Style::default().fg(TEXT_MUTED).italic(),
2071 ),
2072 ]));
2073 }
2074
2075 true
2076}
2077
2078fn render_user_message_block(
2079 lines: &mut Vec<Line<'static>>,
2080 text: &str,
2081 width: usize,
2082 layout: UiLayout,
2083 border_color: Color,
2084) {
2085 let content_width = width.saturating_sub(layout.user_bubble_indent() + 1).max(1);
2086 let text_width = content_width
2087 .saturating_sub(layout.user_bubble_inner_padding * 2)
2088 .max(1);
2089 let wrapped = wrap_text(text, text_width);
2090
2091 ensure_single_blank_line(lines);
2092 lines.push(build_user_bubble_line(
2093 "",
2094 content_width,
2095 layout,
2096 border_color,
2097 ));
2098 for line in wrapped {
2099 lines.push(build_user_bubble_line(
2100 &line,
2101 content_width,
2102 layout,
2103 border_color,
2104 ));
2105 }
2106 lines.push(build_user_bubble_line(
2107 "",
2108 content_width,
2109 layout,
2110 border_color,
2111 ));
2112 lines.push(Line::from(""));
2113}
2114
2115fn ensure_single_blank_line(lines: &mut Vec<Line<'static>>) {
2116 if lines.is_empty() {
2117 return;
2118 }
2119 if let Some(last) = lines.last()
2120 && line_is_empty(last)
2121 {
2122 return;
2123 }
2124 lines.push(Line::from(""));
2125}
2126
2127fn line_is_empty(line: &Line<'_>) -> bool {
2128 line.spans.iter().all(|span| span.content.is_empty())
2129}
2130
2131fn build_user_bubble_line(
2132 content: &str,
2133 content_width: usize,
2134 layout: UiLayout,
2135 border_color: Color,
2136) -> Line<'static> {
2137 let trimmed = truncate_chars(
2138 content,
2139 content_width.saturating_sub(layout.user_bubble_inner_padding * 2),
2140 );
2141 let leading = " ".repeat(layout.user_bubble_inner_padding);
2142 let trailing_len = content_width
2143 .saturating_sub(layout.user_bubble_inner_padding * 2)
2144 .saturating_sub(trimmed.chars().count());
2145 let trailing = " ".repeat(trailing_len + layout.user_bubble_inner_padding);
2146
2147 Line::from(vec![
2148 Span::raw(" ".repeat(layout.user_bubble_indent())),
2149 Span::styled("▌", Style::default().fg(border_color).bg(INPUT_PANEL_BG)),
2150 Span::styled(
2151 format!("{}{}{}", leading, trimmed, trailing),
2152 Style::default().fg(TEXT_PRIMARY).bg(INPUT_PANEL_BG),
2153 ),
2154 ])
2155}
2156
2157fn append_tool_result_count(name: &str, label: &str, output: Option<&str>) -> String {
2158 let Some(raw_output) = output else {
2159 return label.to_string();
2160 };
2161 let Ok(value) = serde_json::from_str::<Value>(raw_output) else {
2162 return label.to_string();
2163 };
2164 let Some(count) = value.get("count").and_then(|v| v.as_u64()) else {
2165 return label.to_string();
2166 };
2167
2168 match name {
2169 "list" => format!("{label} ({count} entries)"),
2170 "glob" => format!("{label} ({count} files)"),
2171 "grep" => format!("{label} ({count} matches)"),
2172 _ => label.to_string(),
2173 }
2174}
2175
2176fn diff_column_widths(available_width: usize) -> (usize, usize) {
2177 let inner_width = available_width.saturating_sub(7);
2178 let left = inner_width / 2;
2179 let right = inner_width.saturating_sub(left);
2180 (left, right)
2181}
2182
2183#[derive(Debug)]
2184struct SideBySideDiffRow {
2185 left: Option<DiffCell>,
2186 right: Option<DiffCell>,
2187 kind: SideBySideDiffKind,
2188}
2189
2190impl SideBySideDiffRow {
2191 fn total_chars(&self) -> usize {
2192 self.left
2193 .as_ref()
2194 .map(|cell| cell.text.chars().count())
2195 .unwrap_or(0)
2196 + self
2197 .right
2198 .as_ref()
2199 .map(|cell| cell.text.chars().count())
2200 .unwrap_or(0)
2201 }
2202}
2203
2204#[derive(Debug, Clone)]
2205struct DiffCell {
2206 line_number: Option<usize>,
2207 marker: Option<char>,
2208 text: String,
2209}
2210
2211#[derive(Debug, Default)]
2212struct DiffLineCursor {
2213 left_line: Option<usize>,
2214 right_line: Option<usize>,
2215}
2216
2217#[derive(Debug, Clone, Copy)]
2218enum SideBySideDiffKind {
2219 Context,
2220 Removed,
2221 Added,
2222 Meta,
2223 Changed,
2224}
2225
2226fn next_diff_row<'a>(
2227 lines: &mut Peekable<impl Iterator<Item = &'a str>>,
2228 cursor: &mut DiffLineCursor,
2229) -> Option<SideBySideDiffRow> {
2230 let raw = lines.next()?;
2231
2232 if raw.starts_with("@@") || raw.starts_with("---") || raw.starts_with("+++") {
2233 if let Some((left, right)) = parse_hunk_line_numbers(raw) {
2234 cursor.left_line = Some(left);
2235 cursor.right_line = Some(right);
2236 }
2237
2238 return Some(SideBySideDiffRow {
2239 left: Some(DiffCell {
2240 line_number: None,
2241 marker: None,
2242 text: raw.to_string(),
2243 }),
2244 right: Some(DiffCell {
2245 line_number: None,
2246 marker: None,
2247 text: raw.to_string(),
2248 }),
2249 kind: SideBySideDiffKind::Meta,
2250 });
2251 }
2252
2253 if let Some(context_text) = raw.strip_prefix(' ') {
2254 return Some(SideBySideDiffRow {
2255 left: Some(DiffCell {
2256 line_number: take_next_line_number(&mut cursor.left_line),
2257 marker: None,
2258 text: context_text.to_string(),
2259 }),
2260 right: Some(DiffCell {
2261 line_number: take_next_line_number(&mut cursor.right_line),
2262 marker: None,
2263 text: context_text.to_string(),
2264 }),
2265 kind: SideBySideDiffKind::Context,
2266 });
2267 }
2268
2269 if raw.starts_with('-') && !raw.starts_with("---") {
2270 if let Some(next) = lines.peek()
2271 && next.starts_with('+')
2272 && !next.starts_with("+++")
2273 {
2274 let added = lines.next().unwrap_or_default().to_string();
2275 let removed_text = raw.strip_prefix('-').unwrap_or(raw);
2276 let added_text = added.strip_prefix('+').unwrap_or(&added);
2277 return Some(SideBySideDiffRow {
2278 left: Some(DiffCell {
2279 line_number: take_next_line_number(&mut cursor.left_line),
2280 marker: Some('-'),
2281 text: removed_text.to_string(),
2282 }),
2283 right: Some(DiffCell {
2284 line_number: take_next_line_number(&mut cursor.right_line),
2285 marker: Some('+'),
2286 text: added_text.to_string(),
2287 }),
2288 kind: SideBySideDiffKind::Changed,
2289 });
2290 }
2291
2292 let removed_text = raw.strip_prefix('-').unwrap_or(raw);
2293
2294 return Some(SideBySideDiffRow {
2295 left: Some(DiffCell {
2296 line_number: take_next_line_number(&mut cursor.left_line),
2297 marker: Some('-'),
2298 text: removed_text.to_string(),
2299 }),
2300 right: None,
2301 kind: SideBySideDiffKind::Removed,
2302 });
2303 }
2304
2305 if raw.starts_with('+') && !raw.starts_with("+++") {
2306 let added_text = raw.strip_prefix('+').unwrap_or(raw);
2307 return Some(SideBySideDiffRow {
2308 left: None,
2309 right: Some(DiffCell {
2310 line_number: take_next_line_number(&mut cursor.right_line),
2311 marker: Some('+'),
2312 text: added_text.to_string(),
2313 }),
2314 kind: SideBySideDiffKind::Added,
2315 });
2316 }
2317
2318 Some(SideBySideDiffRow {
2319 left: Some(DiffCell {
2320 line_number: None,
2321 marker: None,
2322 text: raw.to_string(),
2323 }),
2324 right: Some(DiffCell {
2325 line_number: None,
2326 marker: None,
2327 text: raw.to_string(),
2328 }),
2329 kind: SideBySideDiffKind::Context,
2330 })
2331}
2332
2333fn parse_hunk_line_numbers(raw: &str) -> Option<(usize, usize)> {
2334 if !raw.starts_with("@@") {
2335 return None;
2336 }
2337
2338 let mut parts = raw.split_whitespace();
2339 let _ = parts.next()?;
2340 let left = parts.next()?;
2341 let right = parts.next()?;
2342
2343 let left_start = left
2344 .strip_prefix('-')?
2345 .split(',')
2346 .next()?
2347 .parse::<usize>()
2348 .ok()?;
2349 let right_start = right
2350 .strip_prefix('+')?
2351 .split(',')
2352 .next()?
2353 .parse::<usize>()
2354 .ok()?;
2355
2356 Some((left_start, right_start))
2357}
2358
2359fn take_next_line_number(line_number: &mut Option<usize>) -> Option<usize> {
2360 match line_number {
2361 Some(current) => {
2362 let value = *current;
2363 *current = current.saturating_add(1);
2364 Some(value)
2365 }
2366 None => None,
2367 }
2368}
2369
2370fn render_side_by_side_diff_row(
2371 lines: &mut Vec<Line<'static>>,
2372 row: &SideBySideDiffRow,
2373 left_width: usize,
2374 right_width: usize,
2375 layout: UiLayout,
2376) {
2377 let left_text = render_diff_cell(row.left.as_ref(), left_width);
2378 let right_text = render_diff_cell(row.right.as_ref(), right_width);
2379
2380 let (left_style, right_style) = match row.kind {
2381 SideBySideDiffKind::Context => (
2382 Style::default().fg(TEXT_MUTED),
2383 Style::default().fg(TEXT_MUTED),
2384 ),
2385 SideBySideDiffKind::Removed => (
2386 Style::default().fg(DIFF_REMOVE_FG).bg(DIFF_REMOVE_BG),
2387 Style::default().fg(TEXT_MUTED),
2388 ),
2389 SideBySideDiffKind::Added => (
2390 Style::default().fg(TEXT_MUTED),
2391 Style::default().fg(DIFF_ADD_FG).bg(DIFF_ADD_BG),
2392 ),
2393 SideBySideDiffKind::Meta => (
2394 Style::default().fg(DIFF_META_FG),
2395 Style::default().fg(DIFF_META_FG),
2396 ),
2397 SideBySideDiffKind::Changed => (
2398 Style::default().fg(DIFF_REMOVE_FG).bg(DIFF_REMOVE_BG),
2399 Style::default().fg(DIFF_ADD_FG).bg(DIFF_ADD_BG),
2400 ),
2401 };
2402
2403 lines.push(Line::from(vec![
2404 Span::raw(layout.message_child_indent()),
2405 Span::styled(left_text, left_style),
2406 Span::styled(" | ", Style::default().fg(DIFF_META_FG)),
2407 Span::styled(right_text, right_style),
2408 ]));
2409}
2410
2411fn pad_for_column(text: &str, width: usize) -> String {
2412 if width == 0 {
2413 return String::new();
2414 }
2415
2416 let shown = truncate_for_column(text, width);
2417 let shown_len = shown.chars().count();
2418 if shown_len >= width {
2419 shown
2420 } else {
2421 format!("{shown}{}", " ".repeat(width - shown_len))
2422 }
2423}
2424
2425fn render_diff_cell(cell: Option<&DiffCell>, width: usize) -> String {
2426 if width == 0 {
2427 return String::new();
2428 }
2429
2430 let Some(cell) = cell else {
2431 return " ".repeat(width);
2432 };
2433
2434 if cell.marker.is_none() && cell.line_number.is_none() {
2435 return pad_for_column(&cell.text, width);
2436 }
2437
2438 let line_number = match cell.line_number {
2439 Some(n) => format!("{n:>width$}", width = DIFF_LINE_NUMBER_WIDTH),
2440 None => " ".repeat(DIFF_LINE_NUMBER_WIDTH),
2441 };
2442 let marker = cell.marker.unwrap_or(' ');
2443 let prefix = format!("{line_number} {marker} ");
2444 let prefix_width = prefix.chars().count();
2445
2446 let combined = if width <= prefix_width {
2447 truncate_for_column(&prefix, width)
2448 } else {
2449 let content = truncate_for_column(&cell.text, width - prefix_width);
2450 format!("{prefix}{content}")
2451 };
2452
2453 pad_for_column(&combined, width)
2454}
2455
2456fn truncate_for_column(input: &str, max_chars: usize) -> String {
2457 truncate_chars_impl(input, max_chars, TruncationMode::FixedWidth)
2458}
2459
2460fn tool_title(name: &str) -> &'static str {
2461 match name {
2462 "edit" => "Edit",
2463 "write" => "Write",
2464 _ => "Tool",
2465 }
2466}
2467
2468fn sidebar_prefixed(text: &str) -> String {
2469 format!("{SIDEBAR_INDENT}{text}")
2470}
2471
2472fn sidebar_label(text: &str) -> String {
2473 format!("{SIDEBAR_LABEL_INDENT}{text}")
2474}
2475
2476fn truncate_chars(input: &str, max_chars: usize) -> String {
2477 truncate_chars_impl(input, max_chars, TruncationMode::AppendEllipsis)
2478}
2479
2480#[derive(Clone, Copy)]
2481enum TruncationMode {
2482 FixedWidth,
2483 AppendEllipsis,
2484}
2485
2486fn truncate_chars_impl(input: &str, max_chars: usize, mode: TruncationMode) -> String {
2487 if max_chars == 0 {
2488 return String::new();
2489 }
2490
2491 let mut chars = input.chars();
2492 let taken: String = chars.by_ref().take(max_chars).collect();
2493 if chars.next().is_none() {
2494 return taken;
2495 }
2496
2497 match mode {
2498 TruncationMode::FixedWidth => {
2499 if max_chars <= 3 {
2500 ".".repeat(max_chars)
2501 } else {
2502 let visible: String = taken.chars().take(max_chars - 3).collect();
2503 format!("{visible}...")
2504 }
2505 }
2506 TruncationMode::AppendEllipsis => format!("{taken}..."),
2507 }
2508}