Skip to main content

hh_cli/cli/tui/
ui.rs

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),                 // Space above progress
176            Constraint::Length(1),                 // Global processing indicator
177            Constraint::Length(1),                 // Space above input
178            Constraint::Length(input_area_height), // Input area
179        ])
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    // Get cached lines and calculate scroll offset
434    let lines = app.get_lines(wrap_width);
435    let total_lines = lines.len();
436
437    // Calculate scroll offset: auto-scroll to bottom if enabled, otherwise use manual offset
438    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
549/// Build message lines (used for caching and scroll bounds)
550pub 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    // Get agent color for user message borders
556    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
655/// Parse markdown text into styled lines with wrapping
656fn 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    // Get agent color, default to TEXT_PRIMARY
750    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    // Checkmark or cross symbol with appropriate color
756    let (status_symbol, status_color) = if footer.interrupted {
757        ("✗", Color::Red)
758    } else {
759        ("✓", Color::Rgb(25, 110, 61))
760    };
761
762    // Build footer parts: symbol, agent name, provider, model, duration, interrupted
763    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    // Add "interrupted" text after duration if interrupted
788    if footer.interrupted {
789        footer_parts.push(Span::raw("  "));
790        footer_parts.push(Span::styled("interrupted", Style::default().fg(Color::Red)));
791    }
792
793    // Add blank line before footer
794    lines.push(Line::from(""));
795
796    // Render footer line with message indentation
797    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
804/// Wrap text to a given width, returning a vector of lines.
805fn 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    // Build status line with agent name, provider, and model
1285    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        // Parse agent color, default to TEXT_PRIMARY
1370        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        // No agent selected, show only provider and model
1385        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}