Skip to main content

pi/interactive/
view.rs

1use super::*;
2use unicode_width::UnicodeWidthChar;
3
4/// Ensure the view output fits within `term_height` terminal rows.
5///
6/// The output must contain at most `term_height - 1` newline characters so
7/// that the cursor never advances past the last visible row, which would
8/// trigger terminal scrolling in the alternate-screen buffer.
9pub(super) fn clamp_to_terminal_height(mut output: String, term_height: usize) -> String {
10    if term_height == 0 {
11        output.clear();
12        return output;
13    }
14    let max_newlines = term_height.saturating_sub(1);
15
16    // Single-pass: use memchr to jump directly to each newline position.
17    // Finds the (max_newlines+1)-th newline and truncates there, or returns
18    // early if the output has fewer newlines than the limit.
19    let bytes = output.as_bytes();
20    let mut pos = 0;
21    for _ in 0..max_newlines {
22        match memchr::memchr(b'\n', &bytes[pos..]) {
23            Some(offset) => pos += offset + 1,
24            None => return output, // Fewer newlines than limit — fits.
25        }
26    }
27    // `pos` is now past the max_newlines-th newline.  If there's another
28    // newline at or after `pos`, the output exceeds the limit — truncate
29    // just before that next newline.
30    if let Some(offset) = memchr::memchr(b'\n', &bytes[pos..]) {
31        output.truncate(pos + offset);
32    }
33    output
34}
35
36pub(super) fn normalize_raw_terminal_newlines(input: String) -> String {
37    if !input.contains('\n') {
38        return input;
39    }
40
41    let bytes = input.as_bytes();
42    let mut out = String::with_capacity(input.len() + 16);
43    let mut cursor = 0usize;
44
45    // Byte-scan with memchr avoids UTF-8 decode on the hot view() path.
46    for newline_idx in memchr::memchr_iter(b'\n', bytes) {
47        out.push_str(&input[cursor..newline_idx]);
48        if newline_idx == 0 || bytes[newline_idx - 1] != b'\r' {
49            out.push('\r');
50        }
51        out.push('\n');
52        cursor = newline_idx + 1;
53    }
54
55    out.push_str(&input[cursor..]);
56    out
57}
58
59/// Append one plain-text line with hard wrapping to `max_width` display cells.
60///
61/// We do explicit wrapping here instead of relying on terminal auto-wrap so the
62/// renderer's logical rows stay aligned with physical rows in alt-screen mode.
63fn wrapped_line_segments(line: &str, max_width: usize) -> Vec<&str> {
64    if max_width == 0 || line.is_empty() {
65        return vec![line];
66    }
67
68    let mut segments = Vec::new();
69    let mut segment_start = 0usize;
70    let mut segment_width = 0usize;
71
72    for (idx, ch) in line.char_indices() {
73        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
74        if segment_width + ch_width > max_width && idx > segment_start {
75            segments.push(&line[segment_start..idx]);
76            segment_start = idx;
77            segment_width = 0;
78        }
79        segment_width += ch_width;
80    }
81
82    segments.push(&line[segment_start..]);
83    segments
84}
85
86#[inline]
87fn starts_with_unordered_list_marker(trimmed: &str) -> bool {
88    let bytes = trimmed.as_bytes();
89    bytes.len() >= 2 && matches!(bytes[0], b'-' | b'+' | b'*') && bytes[1].is_ascii_whitespace()
90}
91
92#[inline]
93fn starts_with_ordered_list_marker(trimmed: &str) -> bool {
94    let bytes = trimmed.as_bytes();
95    let mut idx = 0usize;
96    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
97        idx += 1;
98    }
99
100    idx > 0
101        && idx <= 9
102        && (idx + 1) < bytes.len()
103        && matches!(bytes[idx], b'.' | b')')
104        && bytes[idx + 1].is_ascii_whitespace()
105}
106
107#[inline]
108fn is_repeated_marker_line(trimmed: &str, marker: u8) -> bool {
109    let mut marker_count = 0usize;
110    for byte in trimmed.bytes() {
111        if byte == marker {
112            marker_count += 1;
113        } else if !byte.is_ascii_whitespace() {
114            return false;
115        }
116    }
117    marker_count >= 3
118}
119
120#[inline]
121fn has_potential_underscore_emphasis(markdown: &str) -> bool {
122    let bytes = markdown.as_bytes();
123    for (idx, byte) in bytes.iter().enumerate() {
124        if *byte != b'_' {
125            continue;
126        }
127        let prev_alnum = idx
128            .checked_sub(1)
129            .and_then(|i| bytes.get(i))
130            .is_some_and(u8::is_ascii_alphanumeric);
131        let next_alnum = bytes.get(idx + 1).is_some_and(u8::is_ascii_alphanumeric);
132        if !(prev_alnum && next_alnum) {
133            return true;
134        }
135    }
136    false
137}
138
139fn streaming_needs_markdown_renderer(markdown: &str) -> bool {
140    // Inline syntax that can change visible formatting mid-stream.
141    if markdown.as_bytes().iter().any(|byte| {
142        matches!(
143            *byte,
144            b'`' | b'*' | b'[' | b']' | b'<' | b'>' | b'|' | b'!' | b'~' | b'\t'
145        )
146    }) {
147        return true;
148    }
149    if has_potential_underscore_emphasis(markdown) {
150        return true;
151    }
152
153    // Block-level syntax that only needs quick line-prefix checks.
154    for line in markdown.lines() {
155        if line.starts_with("    ") || parse_fence_line(line).is_some() {
156            return true;
157        }
158
159        let trimmed = line.trim_start_matches(' ');
160        let leading_spaces = line.len().saturating_sub(trimmed.len());
161        if leading_spaces > 3 || trimmed.is_empty() {
162            if leading_spaces > 3 {
163                return true;
164            }
165            continue;
166        }
167
168        let first = trimmed.as_bytes()[0];
169        if first == b'#'
170            || first == b'>'
171            || starts_with_unordered_list_marker(trimmed)
172            || starts_with_ordered_list_marker(trimmed)
173            || is_repeated_marker_line(trimmed, b'-')
174            || is_repeated_marker_line(trimmed, b'*')
175            || is_repeated_marker_line(trimmed, b'=')
176        {
177            return true;
178        }
179    }
180
181    false
182}
183
184fn append_wrapped_plain_line_to_output(output: &mut String, line: &str, max_width: usize) {
185    if max_width == 0 || line.is_empty() {
186        let _ = writeln!(output, "  {line}");
187        return;
188    }
189
190    let mut segment_start = 0usize;
191    let mut segment_width = 0usize;
192    for (idx, ch) in line.char_indices() {
193        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
194        if segment_width + ch_width > max_width && idx > segment_start {
195            let _ = writeln!(output, "  {}", &line[segment_start..idx]);
196            segment_start = idx;
197            segment_width = 0;
198        }
199        segment_width += ch_width;
200    }
201
202    let _ = writeln!(output, "  {}", &line[segment_start..]);
203}
204
205fn append_streaming_plaintext_to_output(output: &mut String, markdown: &str, max_width: usize) {
206    for line in markdown.split_terminator('\n') {
207        append_wrapped_plain_line_to_output(output, line, max_width);
208    }
209}
210
211fn render_streaming_markdown_with_glamour(
212    markdown: &str,
213    markdown_style: &GlamourStyleConfig,
214    max_width: usize,
215) -> String {
216    let stabilized_markdown = stabilize_streaming_markdown(markdown);
217    glamour::Renderer::new()
218        .with_style_config(markdown_style.clone())
219        .with_word_wrap(max_width)
220        .render(stabilized_markdown.as_ref())
221}
222
223fn parse_fence_line(line: &str) -> Option<(char, usize, &str)> {
224    let trimmed_line = line.trim_end_matches(['\r', '\n']);
225    let leading_spaces = trimmed_line.chars().take_while(|ch| *ch == ' ').count();
226    if leading_spaces > 3 {
227        return None;
228    }
229
230    let trimmed = trimmed_line.get(leading_spaces..)?;
231    let marker = trimmed.chars().next()?;
232    if marker != '`' && marker != '~' {
233        return None;
234    }
235
236    let mut marker_len = 0usize;
237    for ch in trimmed.chars() {
238        if ch == marker {
239            marker_len += 1;
240        } else {
241            break;
242        }
243    }
244
245    if marker_len >= 3 {
246        Some((
247            marker,
248            marker_len,
249            trimmed.get(marker_len..).unwrap_or_default(),
250        ))
251    } else {
252        None
253    }
254}
255
256fn streaming_unclosed_fence(markdown: &str) -> Option<(char, usize)> {
257    let mut open_fence: Option<(char, usize)> = None;
258
259    for line in markdown.lines() {
260        let Some((marker, marker_len, tail)) = parse_fence_line(line) else {
261            continue;
262        };
263
264        if let Some((open_marker, open_len)) = open_fence {
265            if marker == open_marker && marker_len >= open_len && tail.trim().is_empty() {
266                open_fence = None;
267            }
268        } else {
269            // CommonMark: backtick fence info strings may not contain backticks.
270            if marker == '`' && tail.contains('`') {
271                continue;
272            }
273            open_fence = Some((marker, marker_len));
274        }
275    }
276
277    open_fence
278}
279
280fn stabilize_streaming_markdown(markdown: &str) -> std::borrow::Cow<'_, str> {
281    let Some((marker, marker_len)) = streaming_unclosed_fence(markdown) else {
282        return std::borrow::Cow::Borrowed(markdown);
283    };
284
285    // Close an unterminated fence in the transient streaming view so partial
286    // markdown renders predictably while tokens are still arriving.
287    let mut stabilized = String::with_capacity(markdown.len() + marker_len + 1);
288    stabilized.push_str(markdown);
289    if !stabilized.ends_with('\n') {
290        stabilized.push('\n');
291    }
292    for _ in 0..marker_len {
293        stabilized.push(marker);
294    }
295    std::borrow::Cow::Owned(stabilized)
296}
297
298fn format_persistence_footer_segment(
299    mode: crate::session::AutosaveDurabilityMode,
300    metrics: crate::session::AutosaveQueueMetrics,
301) -> String {
302    let mut details = Vec::new();
303    if metrics.pending_mutations > 0 {
304        details.push(format!(
305            "pending {}/{}",
306            metrics.pending_mutations, metrics.max_pending_mutations
307        ));
308    }
309    if metrics.flush_failed > 0 {
310        details.push(format!("flush-fail {}", metrics.flush_failed));
311    }
312    if metrics.max_pending_mutations > 0
313        && metrics.pending_mutations >= metrics.max_pending_mutations
314    {
315        details.push("backpressure".to_string());
316    }
317
318    if details.is_empty() {
319        format!("Persist: {}", mode.as_str())
320    } else {
321        format!("Persist: {} ({})", mode.as_str(), details.join(", "))
322    }
323}
324
325impl PiApp {
326    fn header_binding_hint(&self, action: AppAction, fallback: &str) -> String {
327        self.keybindings
328            .get_bindings(action)
329            .first()
330            .map_or_else(|| fallback.to_string(), std::string::ToString::to_string)
331    }
332
333    /// Render the view.
334    #[allow(clippy::too_many_lines)]
335    pub(super) fn view(&self) -> String {
336        let view_start = if self.frame_timing.enabled {
337            Some(std::time::Instant::now())
338        } else {
339            None
340        };
341
342        // PERF-7: Pre-allocate view output with capacity from the previous
343        // frame, avoiding incremental String grows during assembly.
344        let mut output = String::with_capacity(self.render_buffers.view_capacity_hint());
345
346        // Header — PERF-7: render directly into output, no intermediate String.
347        self.render_header_into(&mut output);
348        output.push('\n');
349
350        // Modal overlays (e.g. /tree) take over the main view.
351        if let Some(tree_ui) = &self.tree_ui {
352            output.push_str(&view_tree_ui(tree_ui, &self.styles));
353            self.render_footer_into(&mut output);
354            return output;
355        }
356
357        // Build conversation content for viewport.
358        // Trim trailing whitespace so the viewport line count matches
359        // what refresh_conversation_viewport() stored — this keeps the
360        // y_offset from goto_bottom() aligned with the visible lines.
361        let conversation_content = {
362            let content_start = if self.frame_timing.enabled {
363                Some(std::time::Instant::now())
364            } else {
365                None
366            };
367            let mut raw = self.build_conversation_content();
368            if let Some(start) = content_start {
369                self.frame_timing
370                    .record_content_build(micros_as_u64(start.elapsed().as_micros()));
371            }
372            // PERF-7: Truncate in place instead of trim_end().to_string()
373            // which would allocate a second copy of the entire content.
374            let trimmed_len = raw.trim_end().len();
375            raw.truncate(trimmed_len);
376            raw
377        };
378
379        // Render conversation area (scrollable).
380        // Use the per-frame effective height so that conditional chrome
381        // (scroll indicator, tool status, status message, …) is accounted
382        // for and the total output never exceeds term_height rows.
383        let effective_vp = self.view_effective_conversation_height();
384        {
385            // PERF-7: Use Cow to avoid consuming conversation_content so
386            // the reusable buffer is always returned regardless of path.
387            use std::borrow::Cow;
388            let viewport_content: Cow<'_, str> = if conversation_content.is_empty() {
389                Cow::Owned(self.styles.muted_italic.render(&self.startup_welcome))
390            } else {
391                Cow::Borrowed(&conversation_content)
392            };
393
394            // PERF: Count total lines with memchr (O(n) byte scan, no alloc)
395            // instead of collecting all lines into a Vec.  For a 10K-line
396            // conversation this avoids a ~80KB Vec<&str> allocation per frame.
397            let total_lines = memchr::memchr_iter(b'\n', viewport_content.as_bytes()).count() + 1;
398            let start = if self.follow_stream_tail {
399                total_lines.saturating_sub(effective_vp)
400            } else {
401                self.conversation_viewport
402                    .y_offset()
403                    .min(total_lines.saturating_sub(1))
404            };
405            let end = (start + effective_vp).min(total_lines);
406
407            // Skip `start` lines, then take `end - start` lines — no Vec
408            // allocation needed.
409            let mut first = true;
410            for line in viewport_content.lines().skip(start).take(end - start) {
411                if first {
412                    first = false;
413                } else {
414                    output.push('\n');
415                }
416                output.push_str(line);
417            }
418            output.push('\n');
419
420            // Scroll indicator
421            if total_lines > effective_vp {
422                let total = total_lines.saturating_sub(effective_vp);
423                let percent = (start * 100).checked_div(total).map_or(100, |p| p.min(100));
424                let indicator = format!("  [{percent}%] ↑/↓ PgUp/PgDn to scroll");
425                output.push_str(&self.styles.muted.render(&indicator));
426                output.push('\n');
427            }
428        }
429        // PERF-7: Return the conversation buffer for reuse next frame.
430        // Always returned (even when empty) to preserve heap capacity.
431        self.render_buffers
432            .return_conversation_buffer(conversation_content);
433
434        // Tool status
435        if let Some(tool) = &self.current_tool {
436            let progress_str = self.tool_progress.as_ref().map_or_else(String::new, |p| {
437                let secs = p.elapsed_ms / 1000;
438                if secs < 1 {
439                    return String::new();
440                }
441                let mut parts = vec![format!("{secs}s")];
442                if p.line_count > 0 {
443                    parts.push(format!("{} lines", format_count(p.line_count)));
444                } else if p.byte_count > 0 {
445                    parts.push(format!("{} bytes", format_count(p.byte_count)));
446                }
447                if let Some(timeout_ms) = p.timeout_ms {
448                    let timeout_s = timeout_ms / 1000;
449                    if timeout_s > 0 {
450                        parts.push(format!("timeout {timeout_s}s"));
451                    }
452                }
453                format!(" ({})", parts.join(" \u{2022} "))
454            });
455            let _ = write!(
456                output,
457                "\n  {} {}{} ...\n",
458                self.spinner.view(),
459                self.styles.warning_bold.render(&format!("Running {tool}")),
460                self.styles.muted.render(&progress_str),
461            );
462        }
463
464        // Status message (slash command feedback)
465        if let Some(status) = &self.status_message {
466            let status_style = self.styles.accent.clone().italic();
467            let _ = write!(output, "\n  {}\n", status_style.render(status));
468        }
469
470        // Session picker overlay (if open)
471        if let Some(ref picker) = self.session_picker {
472            output.push_str(&self.render_session_picker(picker));
473        }
474
475        // Settings overlay (if open)
476        if let Some(ref settings_ui) = self.settings_ui {
477            output.push_str(&self.render_settings_ui(settings_ui));
478        }
479
480        // Theme picker overlay (if open)
481        if let Some(ref picker) = self.theme_picker {
482            output.push_str(&self.render_theme_picker(picker));
483        }
484
485        // Capability prompt overlay (if open)
486        if let Some(ref prompt) = self.capability_prompt {
487            output.push_str(&self.render_capability_prompt(prompt));
488        }
489
490        // Branch picker overlay (if open)
491        if let Some(ref picker) = self.branch_picker {
492            output.push_str(&self.render_branch_picker(picker));
493        }
494
495        // Model selector overlay (if open)
496        if let Some(ref selector) = self.model_selector {
497            output.push_str(&self.render_model_selector(selector));
498        }
499
500        // Input area (only when idle and no overlay open)
501        if self.agent_state == AgentState::Idle
502            && self.session_picker.is_none()
503            && self.settings_ui.is_none()
504            && self.theme_picker.is_none()
505            && self.capability_prompt.is_none()
506            && self.branch_picker.is_none()
507            && self.model_selector.is_none()
508        {
509            output.push_str(&self.render_input());
510
511            // Autocomplete dropdown (if open)
512            if self.autocomplete.open && !self.autocomplete.items.is_empty() {
513                output.push_str(&self.render_autocomplete_dropdown());
514            }
515        } else if self.agent_state != AgentState::Idle {
516            if self.show_processing_status_spinner() {
517                // Show spinner while waiting on provider/tool activity, before
518                // we have visible streaming deltas.
519                let _ = write!(
520                    output,
521                    "\n  {} {}\n",
522                    self.spinner.view(),
523                    self.styles.accent.render("Processing...")
524                );
525            }
526
527            if let Some(pending_queue) = self.render_pending_message_queue() {
528                output.push_str(&pending_queue);
529            }
530        }
531
532        // Footer with usage stats — PERF-7: render directly into output.
533        self.render_footer_into(&mut output);
534
535        // Clamp the output to `term_height` rows so the terminal never
536        // scrolls in the alternate-screen buffer.
537        let output = clamp_to_terminal_height(output, self.term_height);
538        let output = normalize_raw_terminal_newlines(output);
539
540        // PERF-7: Remember this frame's output size so the next frame can
541        // pre-allocate with the right capacity.
542        self.render_buffers.set_view_capacity_hint(output.len());
543
544        if let Some(start) = view_start {
545            self.frame_timing
546                .record_frame(micros_as_u64(start.elapsed().as_micros()));
547        }
548
549        output
550    }
551
552    /// PERF-7: Render the header directly into `output`, avoiding an
553    /// intermediate `String` allocation on the hot path.
554    fn render_header_into(&self, output: &mut String) {
555        let model_label = format!("({})", self.model);
556
557        // Branch indicator: show "Branch N/M" when session has multiple leaves.
558        let branch_indicator = self
559            .session
560            .try_lock()
561            .ok()
562            .and_then(|guard| {
563                let info = guard.branch_summary();
564                if info.leaf_count <= 1 {
565                    return None;
566                }
567                let current_idx = info
568                    .current_leaf
569                    .as_ref()
570                    .and_then(|leaf| info.leaves.iter().position(|l| l == leaf))
571                    .map_or(1, |i| i + 1);
572                Some(format!(" [branch {current_idx}/{}]", info.leaf_count))
573            })
574            .unwrap_or_default();
575
576        let model_key = self.header_binding_hint(AppAction::SelectModel, "ctrl+l");
577        let next_model_key = self.header_binding_hint(AppAction::CycleModelForward, "ctrl+p");
578        let prev_model_key =
579            self.header_binding_hint(AppAction::CycleModelBackward, "ctrl+shift+p");
580        let tools_key = self.header_binding_hint(AppAction::ExpandTools, "ctrl+o");
581        let thinking_key = self.header_binding_hint(AppAction::ToggleThinking, "ctrl+t");
582        let max_width = self.term_width.saturating_sub(2);
583
584        let hints_line = truncate(
585            &format!(
586                "{model_key}: model  {next_model_key}: next  {prev_model_key}: prev  \
587                 {tools_key}: tools  {thinking_key}: thinking"
588            ),
589            max_width,
590        );
591
592        let resources_line = truncate(
593            &format!(
594                "resources: {} skills, {} prompts, {} themes, {} extensions",
595                self.resources.skills().len(),
596                self.resources.prompts().len(),
597                self.resources.themes().len(),
598                self.resources.extensions().len()
599            ),
600            max_width,
601        );
602
603        let _ = write!(
604            output,
605            "  {} {}{}\n  {}\n  {}\n",
606            self.styles.title.render("Pi"),
607            self.styles.muted.render(&model_label),
608            self.styles.accent.render(&branch_indicator),
609            self.styles.muted.render(&hints_line),
610            self.styles.muted.render(&resources_line),
611        );
612    }
613
614    pub(super) fn render_header(&self) -> String {
615        let mut buf = String::new();
616        self.render_header_into(&mut buf);
617        buf
618    }
619
620    pub(super) fn render_input(&self) -> String {
621        let mut output = String::new();
622
623        let thinking_level = self
624            .session
625            .try_lock()
626            .ok()
627            .and_then(|guard| guard.header.thinking_level.clone())
628            .and_then(|level| level.parse::<ThinkingLevel>().ok())
629            .or_else(|| {
630                self.config
631                    .default_thinking_level
632                    .as_deref()
633                    .and_then(|level| level.parse::<ThinkingLevel>().ok())
634            })
635            .unwrap_or(ThinkingLevel::Off);
636
637        let input_text = self.input.value();
638        let is_bash_mode = parse_bash_command(&input_text).is_some();
639
640        let (thinking_label, thinking_style, thinking_border_style) = match thinking_level {
641            ThinkingLevel::Off => (
642                "off",
643                self.styles.muted_bold.clone(),
644                self.styles.border.clone(),
645            ),
646            ThinkingLevel::Minimal => (
647                "minimal",
648                self.styles.accent.clone(),
649                self.styles.accent.clone(),
650            ),
651            ThinkingLevel::Low => (
652                "low",
653                self.styles.accent.clone(),
654                self.styles.accent.clone(),
655            ),
656            ThinkingLevel::Medium => (
657                "medium",
658                self.styles.accent_bold.clone(),
659                self.styles.accent.clone(),
660            ),
661            ThinkingLevel::High => (
662                "high",
663                self.styles.warning_bold.clone(),
664                self.styles.warning.clone(),
665            ),
666            ThinkingLevel::XHigh => (
667                "xhigh",
668                self.styles.error_bold.clone(),
669                self.styles.error_bold.clone(),
670            ),
671        };
672
673        let thinking_plain = format!("[thinking: {thinking_label}]");
674        let thinking_badge = thinking_style.render(&thinking_plain);
675        let bash_badge = is_bash_mode.then(|| self.styles.warning_bold.render("[bash]"));
676
677        let max_width = self.term_width.saturating_sub(2);
678        let reserved = 2
679            + thinking_plain.chars().count()
680            + if is_bash_mode {
681                2 + "[bash]".chars().count()
682            } else {
683                0
684            };
685        let available_for_mode = max_width.saturating_sub(reserved);
686        let mut mode_text = match self.input_mode {
687            InputMode::SingleLine => "Enter: send  Shift+Enter: newline  Alt+Enter: multi-line",
688            InputMode::MultiLine => "Alt+Enter: send  Enter: newline  Esc: single-line",
689        }
690        .to_string();
691        if mode_text.chars().count() > available_for_mode {
692            mode_text = truncate(&mode_text, available_for_mode);
693        }
694        let mut header_line = String::new();
695        header_line.push_str(&self.styles.muted.render(&mode_text));
696        header_line.push_str("  ");
697        header_line.push_str(&thinking_badge);
698        if let Some(bash_badge) = bash_badge {
699            header_line.push_str("  ");
700            header_line.push_str(&bash_badge);
701        }
702        let _ = writeln!(output, "\n  {header_line}");
703
704        let padding = " ".repeat(self.editor_padding_x);
705        let line_prefix = format!("  {padding}");
706        let border_style = if is_bash_mode {
707            self.styles.warning_bold.clone()
708        } else {
709            thinking_border_style
710        };
711        let border = border_style.render("│");
712        for line in self.input.view().lines() {
713            output.push_str(&line_prefix);
714            output.push_str(&border);
715            output.push(' ');
716            output.push_str(line);
717            output.push('\n');
718        }
719
720        output
721    }
722
723    /// PERF-7: Render the footer directly into `output`, avoiding an
724    /// intermediate `String` allocation on the hot path.
725    fn render_footer_into(&self, output: &mut String) {
726        let total_cost = self.total_usage.cost.total;
727        let cost_str = if total_cost > 0.0 {
728            format!(" (${total_cost:.4})")
729        } else {
730            String::new()
731        };
732
733        let input = self.total_usage.input;
734        let output_tokens = self.total_usage.output;
735        let persistence_str = self.session.try_lock().ok().map_or_else(
736            || "Persist: unavailable".to_string(),
737            |session| {
738                format_persistence_footer_segment(
739                    session.autosave_durability_mode(),
740                    session.autosave_metrics(),
741                )
742            },
743        );
744        let branch_str = self
745            .git_branch
746            .as_ref()
747            .map_or_else(String::new, |b| format!("  |  {b}"));
748        let mode_hint = match self.input_mode {
749            InputMode::SingleLine => "Shift+Enter: newline  |  Alt+Enter: multi-line",
750            InputMode::MultiLine => "Enter: newline  |  Alt+Enter: send  |  Esc: single-line",
751        };
752        let footer_long = format!(
753            "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str}  |  {persistence_str}  |  {mode_hint}  |  /help  |  Ctrl+C: quit"
754        );
755        let footer_short = format!(
756            "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str}  |  {persistence_str}  |  /help  |  Ctrl+C: quit"
757        );
758        let max_width = self.term_width.saturating_sub(2);
759        let mut footer = if footer_long.chars().count() <= max_width {
760            footer_long
761        } else {
762            footer_short
763        };
764        if footer.chars().count() > max_width {
765            footer = truncate(&footer, max_width);
766        }
767        let _ = write!(output, "\n  {}\n", self.styles.muted.render(&footer));
768    }
769
770    pub(super) fn render_footer(&self) -> String {
771        let mut buf = String::new();
772        self.render_footer_into(&mut buf);
773        buf
774    }
775
776    /// Render a single conversation message to a string (uncached path).
777    fn render_single_message(&self, msg: &ConversationMessage) -> String {
778        let mut output = String::new();
779        match msg.role {
780            MessageRole::User => {
781                let _ = write!(
782                    output,
783                    "\n  {} {}\n",
784                    self.styles.accent_bold.render("You:"),
785                    msg.content
786                );
787            }
788            MessageRole::Assistant => {
789                let _ = write!(
790                    output,
791                    "\n  {}\n",
792                    self.styles.success_bold.render("Assistant:")
793                );
794
795                // Render thinking if present
796                if self.thinking_visible {
797                    if let Some(thinking) = &msg.thinking {
798                        let truncated = truncate(thinking, 100);
799                        let _ = writeln!(
800                            output,
801                            "  {}",
802                            self.styles
803                                .muted_italic
804                                .render(&format!("Thinking: {truncated}"))
805                        );
806                    }
807                }
808
809                // Render markdown content
810                let rendered = glamour::Renderer::new()
811                    .with_style_config(self.markdown_style.clone())
812                    .with_word_wrap(self.term_width.saturating_sub(6).max(40))
813                    .render(&msg.content);
814                for line in rendered.lines() {
815                    let _ = writeln!(output, "  {line}");
816                }
817            }
818            MessageRole::Tool => {
819                // Per-message collapse: global toggle overrides, then per-message.
820                let show_expanded = self.tools_expanded && !msg.collapsed;
821                if show_expanded {
822                    let rendered = render_tool_message(&msg.content, &self.styles);
823                    let _ = write!(output, "\n  {rendered}\n");
824                } else {
825                    let header = msg.content.lines().next().unwrap_or("Tool output");
826                    let line_count = memchr::memchr_iter(b'\n', msg.content.as_bytes()).count() + 1;
827                    let summary = format!(
828                        "\u{25b6} {} ({line_count} lines, collapsed)",
829                        header.trim_end()
830                    );
831                    let _ = write!(
832                        output,
833                        "\n  {}\n",
834                        self.styles.muted_italic.render(&summary)
835                    );
836                    // Show preview when per-message collapsed (not global).
837                    if self.tools_expanded && msg.collapsed {
838                        for (i, line) in msg.content.lines().skip(1).enumerate() {
839                            if i >= TOOL_COLLAPSE_PREVIEW_LINES {
840                                let remaining = line_count
841                                    .saturating_sub(1)
842                                    .saturating_sub(TOOL_COLLAPSE_PREVIEW_LINES);
843                                let _ = writeln!(
844                                    output,
845                                    "  {}",
846                                    self.styles
847                                        .muted
848                                        .render(&format!("  ... {remaining} more lines"))
849                                );
850                                break;
851                            }
852                            let _ = writeln!(
853                                output,
854                                "  {}",
855                                self.styles.muted.render(&format!("  {line}"))
856                            );
857                        }
858                    }
859                }
860            }
861            MessageRole::System => {
862                let _ = write!(output, "\n  {}\n", self.styles.warning.render(&msg.content));
863            }
864        }
865        output
866    }
867
868    /// Build the conversation content string for the viewport.
869    ///
870    /// Uses `MessageRenderCache` (PERF-1) to avoid re-rendering unchanged
871    /// messages and a conversation prefix cache (PERF-2) to skip iterating
872    /// all messages during streaming. Streaming content (current_response)
873    /// always renders fresh.
874    pub fn build_conversation_content(&self) -> String {
875        let has_streaming_state =
876            !self.current_response.is_empty() || !self.current_thinking.is_empty();
877        let has_visible_streaming_tail = !self.current_response.is_empty()
878            || (self.thinking_visible && !self.current_thinking.is_empty());
879
880        // PERF-7: Reuse the pre-allocated conversation buffer from the
881        // previous frame. `take_conversation_buffer()` clears the buffer
882        // but preserves its heap capacity, avoiding a fresh allocation.
883        let mut output = self.render_buffers.take_conversation_buffer();
884
885        // PERF-2 fast path: during streaming, reuse the cached prefix
886        // (all finalized messages) and only rebuild the streaming tail.
887        if has_streaming_state && self.message_render_cache.prefix_valid(self.messages.len()) {
888            // PERF-7: Append prefix directly into the reusable buffer
889            // instead of cloning via prefix_get().
890            self.message_render_cache.prefix_append_to(&mut output);
891            if has_visible_streaming_tail {
892                self.append_streaming_tail(&mut output);
893            }
894            return output;
895        }
896
897        // Full rebuild: iterate all messages with per-message cache (PERF-1).
898        for (index, msg) in self.messages.iter().enumerate() {
899            let key =
900                MessageRenderCache::compute_key(msg, self.thinking_visible, self.tools_expanded);
901
902            if self
903                .message_render_cache
904                .append_cached(&mut output, index, &key)
905            {
906                continue;
907            }
908            let rendered = self.render_single_message(msg);
909            // PERF: push_str first, then move into cache — avoids cloning
910            // the rendered String (which can be several KB for tool output).
911            output.push_str(&rendered);
912            self.message_render_cache.put(index, key, rendered);
913        }
914
915        // Snapshot the prefix for future streaming frames (PERF-2).
916        self.message_render_cache
917            .prefix_set(&output, self.messages.len());
918
919        // Append streaming content if active.
920        if has_visible_streaming_tail {
921            self.append_streaming_tail(&mut output);
922        }
923
924        output
925    }
926
927    /// Render the current streaming response / thinking into `output`.
928    /// Always renders fresh — never cached.
929    fn append_streaming_tail(&self, output: &mut String) {
930        let _ = write!(
931            output,
932            "\n  {}\n",
933            self.styles.success_bold.render("Assistant:")
934        );
935
936        let content_width = self.term_width.saturating_sub(4).max(1);
937
938        // Show thinking if present
939        if self.thinking_visible && !self.current_thinking.is_empty() {
940            let truncated = truncate(&self.current_thinking, 100);
941            let thinking_line = format!("Thinking: {truncated}");
942            for segment in wrapped_line_segments(&thinking_line, content_width) {
943                let _ = writeln!(output, "  {}", self.styles.muted_italic.render(segment));
944            }
945        }
946
947        // Render partial markdown on every stream update so headings/lists/code
948        // format as they arrive instead of showing raw markers.
949        if !self.current_response.is_empty() {
950            let markdown_width = self.term_width.saturating_sub(6).max(40);
951            if streaming_needs_markdown_renderer(&self.current_response) {
952                let rendered = render_streaming_markdown_with_glamour(
953                    &self.current_response,
954                    &self.markdown_style,
955                    markdown_width,
956                );
957                for line in rendered.lines() {
958                    let _ = writeln!(output, "  {line}");
959                }
960            } else {
961                append_streaming_plaintext_to_output(
962                    output,
963                    &self.current_response,
964                    markdown_width,
965                );
966            }
967        }
968    }
969
970    pub(super) fn render_pending_message_queue(&self) -> Option<String> {
971        if self.agent_state == AgentState::Idle {
972            return None;
973        }
974
975        let Ok(queue) = self.message_queue.lock() else {
976            return None;
977        };
978
979        let steering_len = queue.steering_len();
980        let follow_len = queue.follow_up_len();
981        if steering_len == 0 && follow_len == 0 {
982            return None;
983        }
984
985        let max_preview = self.term_width.saturating_sub(24).max(20);
986
987        let mut out = String::new();
988        out.push_str("\n  ");
989        out.push_str(&self.styles.muted_bold.render("Pending:"));
990        out.push(' ');
991        out.push_str(
992            &self
993                .styles
994                .accent_bold
995                .render(&format!("{steering_len} steering")),
996        );
997        out.push_str(&self.styles.muted.render(", "));
998        out.push_str(&self.styles.muted.render(&format!("{follow_len} follow-up")));
999        out.push('\n');
1000
1001        if let Some(text) = queue.steering_front() {
1002            let preview = queued_message_preview(text, max_preview);
1003            out.push_str("  ");
1004            out.push_str(&self.styles.accent_bold.render("steering →"));
1005            out.push(' ');
1006            out.push_str(&preview);
1007            out.push('\n');
1008        }
1009
1010        if let Some(text) = queue.follow_up_front() {
1011            let preview = queued_message_preview(text, max_preview);
1012            out.push_str("  ");
1013            out.push_str(&self.styles.muted_bold.render("follow-up →"));
1014            out.push(' ');
1015            out.push_str(&self.styles.muted.render(&preview));
1016            out.push('\n');
1017        }
1018
1019        Some(out)
1020    }
1021
1022    #[allow(clippy::too_many_lines)]
1023    pub(super) fn render_autocomplete_dropdown(&self) -> String {
1024        let mut output = String::new();
1025
1026        let offset = self.autocomplete.scroll_offset();
1027        let visible_count = self
1028            .autocomplete
1029            .max_visible
1030            .min(self.autocomplete.items.len());
1031        let end = (offset + visible_count).min(self.autocomplete.items.len());
1032
1033        // Styles
1034        let border_style = &self.styles.border;
1035        let selected_style = &self.styles.selection;
1036        let kind_style = &self.styles.warning;
1037        let desc_style = &self.styles.muted_italic;
1038
1039        // Top border
1040        let width = 60;
1041        let _ = write!(
1042            output,
1043            "\n  {}",
1044            border_style.render(&format!("┌{:─<width$}┐", ""))
1045        );
1046
1047        for (idx, item) in self.autocomplete.items[offset..end].iter().enumerate() {
1048            let global_idx = offset + idx;
1049            let is_selected = global_idx == self.autocomplete.selected;
1050
1051            let kind_icon = match item.kind {
1052                AutocompleteItemKind::SlashCommand => "⚡",
1053                AutocompleteItemKind::ExtensionCommand => "🧩",
1054                AutocompleteItemKind::PromptTemplate => "📄",
1055                AutocompleteItemKind::Skill => "🔧",
1056                AutocompleteItemKind::Model => "🤖",
1057                AutocompleteItemKind::File => "📁",
1058                AutocompleteItemKind::Path => "📂",
1059            };
1060
1061            let max_label_len = width.saturating_sub(6);
1062            let label = if item.label.chars().count() > max_label_len {
1063                let mut out = item
1064                    .label
1065                    .chars()
1066                    .take(max_label_len.saturating_sub(1))
1067                    .collect::<String>();
1068                out.push('…');
1069                out
1070            } else {
1071                item.label.clone()
1072            };
1073
1074            let line_content = format!("{kind_icon} {label:<max_label_len$}");
1075            let styled_line = if is_selected {
1076                selected_style.render(&line_content)
1077            } else {
1078                format!("{} {label:<max_label_len$}", kind_style.render(kind_icon))
1079            };
1080
1081            let _ = write!(
1082                output,
1083                "\n  {}{}{}",
1084                border_style.render("│"),
1085                styled_line,
1086                border_style.render("│")
1087            );
1088
1089            if is_selected {
1090                if let Some(desc) = &item.description {
1091                    let truncated_desc = if desc.chars().count() > width.saturating_sub(4) {
1092                        let mut out = desc
1093                            .chars()
1094                            .take(width.saturating_sub(5))
1095                            .collect::<String>();
1096                        out.push('…');
1097                        out
1098                    } else {
1099                        desc.clone()
1100                    };
1101
1102                    let _ = write!(
1103                        output,
1104                        "\n  {}  {}{}",
1105                        border_style.render("│"),
1106                        desc_style.render(&truncated_desc),
1107                        border_style.render(&format!(
1108                            "{:>pad$}│",
1109                            "",
1110                            pad = width.saturating_sub(2).saturating_sub(truncated_desc.len())
1111                        ))
1112                    );
1113                }
1114            }
1115        }
1116
1117        if self.autocomplete.items.len() > visible_count {
1118            let shown = format!(
1119                "{}-{} of {}",
1120                offset + 1,
1121                end,
1122                self.autocomplete.items.len()
1123            );
1124            let _ = write!(
1125                output,
1126                "\n  {}",
1127                border_style.render(&format!("│{shown:^width$}│"))
1128            );
1129        }
1130
1131        let _ = write!(
1132            output,
1133            "\n  {}",
1134            border_style.render(&format!("└{:─<width$}┘", ""))
1135        );
1136
1137        let _ = write!(
1138            output,
1139            "\n  {}",
1140            self.styles
1141                .muted_italic
1142                .render("↑/↓ navigate  Enter/Tab accept  Esc cancel")
1143        );
1144
1145        output
1146    }
1147
1148    #[allow(clippy::too_many_lines)]
1149    pub(super) fn render_session_picker(&self, picker: &SessionPickerOverlay) -> String {
1150        let mut output = String::new();
1151
1152        let _ = writeln!(
1153            output,
1154            "\n  {}\n",
1155            self.styles.title.render("Select a session to resume")
1156        );
1157
1158        let query = picker.query();
1159        let search_line = if query.is_empty() {
1160            "  > (type to filter sessions)".to_string()
1161        } else {
1162            format!("  > {query}")
1163        };
1164        let _ = writeln!(output, "{}", self.styles.muted.render(&search_line));
1165        let _ = writeln!(
1166            output,
1167            "  {}",
1168            self.styles.muted.render("─".repeat(50).as_str())
1169        );
1170        output.push('\n');
1171
1172        if picker.sessions.is_empty() {
1173            let message = if picker.has_query() {
1174                "No sessions match the current filter."
1175            } else {
1176                "No sessions found for this project."
1177            };
1178            let _ = writeln!(output, "  {}", self.styles.muted.render(message));
1179        } else {
1180            let _ = writeln!(
1181                output,
1182                "  {:<20}  {:<30}  {:<8}  {}",
1183                self.styles.muted_bold.render("Time"),
1184                self.styles.muted_bold.render("Name"),
1185                self.styles.muted_bold.render("Messages"),
1186                self.styles.muted_bold.render("Session ID")
1187            );
1188            output.push_str("  ");
1189            output.push_str(&"-".repeat(78));
1190            output.push('\n');
1191
1192            let offset = picker.scroll_offset();
1193            let visible_count = picker.max_visible.min(picker.sessions.len());
1194            let end = (offset + visible_count).min(picker.sessions.len());
1195
1196            for (idx, session) in picker.sessions[offset..end].iter().enumerate() {
1197                let global_idx = offset + idx;
1198                let is_selected = global_idx == picker.selected;
1199
1200                let prefix = if is_selected { ">" } else { " " };
1201                let time = crate::session_picker::format_time(&session.timestamp);
1202                let name = session
1203                    .name
1204                    .as_deref()
1205                    .unwrap_or("-")
1206                    .chars()
1207                    .take(28)
1208                    .collect::<String>();
1209                let messages = session.message_count.to_string();
1210                let id = crate::session_picker::truncate_session_id(&session.id, 8);
1211
1212                let row = format!(" {time:<20}  {name:<30}  {messages:<8}  {id}");
1213                let rendered = if is_selected {
1214                    self.styles.selection.render(&row)
1215                } else {
1216                    row
1217                };
1218
1219                let _ = writeln!(output, "{prefix} {rendered}");
1220            }
1221
1222            if picker.sessions.len() > visible_count {
1223                let _ = writeln!(
1224                    output,
1225                    "  {}",
1226                    self.styles.muted.render(&format!(
1227                        "({}-{} of {})",
1228                        offset + 1,
1229                        end,
1230                        picker.sessions.len()
1231                    ))
1232                );
1233            }
1234        }
1235
1236        output.push('\n');
1237        if picker.confirm_delete {
1238            let _ = writeln!(
1239                output,
1240                "  {}",
1241                self.styles.warning_bold.render(
1242                    picker
1243                        .status_message
1244                        .as_deref()
1245                        .unwrap_or("Delete session? Press y/n to confirm."),
1246                )
1247            );
1248        } else {
1249            let _ = writeln!(
1250                output,
1251                "  {}",
1252                self.styles.muted_italic.render(
1253                    "Type: filter  Backspace: clear  ↑/↓/j/k: navigate  Enter: select  Ctrl+D: delete  Esc/q: cancel",
1254                )
1255            );
1256            if let Some(message) = &picker.status_message {
1257                let _ = writeln!(output, "  {}", self.styles.warning_bold.render(message));
1258            }
1259        }
1260
1261        output
1262    }
1263
1264    pub(super) fn render_settings_ui(&self, settings_ui: &SettingsUiState) -> String {
1265        let mut output = String::new();
1266
1267        let _ = writeln!(output, "\n  {}\n", self.styles.title.render("Settings"));
1268
1269        if settings_ui.entries.is_empty() {
1270            let _ = writeln!(
1271                output,
1272                "  {}",
1273                self.styles.muted.render("No settings available.")
1274            );
1275        } else {
1276            let offset = settings_ui.scroll_offset();
1277            let visible_count = settings_ui.max_visible.min(settings_ui.entries.len());
1278            let end = (offset + visible_count).min(settings_ui.entries.len());
1279
1280            for (idx, entry) in settings_ui.entries[offset..end].iter().enumerate() {
1281                let global_idx = offset + idx;
1282                let is_selected = global_idx == settings_ui.selected;
1283
1284                let prefix = if is_selected { ">" } else { " " };
1285                let label = match *entry {
1286                    SettingsUiEntry::Summary => "Summary".to_string(),
1287                    SettingsUiEntry::Theme => "Theme".to_string(),
1288                    SettingsUiEntry::SteeringMode => format!(
1289                        "steeringMode: {}",
1290                        self.config.steering_queue_mode().as_str()
1291                    ),
1292                    SettingsUiEntry::FollowUpMode => format!(
1293                        "followUpMode: {}",
1294                        self.config.follow_up_queue_mode().as_str()
1295                    ),
1296                    SettingsUiEntry::QuietStartup => format!(
1297                        "quietStartup: {}",
1298                        bool_label(self.config.quiet_startup.unwrap_or(false))
1299                    ),
1300                    SettingsUiEntry::CollapseChangelog => format!(
1301                        "collapseChangelog: {}",
1302                        bool_label(self.config.collapse_changelog.unwrap_or(false))
1303                    ),
1304                    SettingsUiEntry::HideThinkingBlock => format!(
1305                        "hideThinkingBlock: {}",
1306                        bool_label(self.config.hide_thinking_block.unwrap_or(false))
1307                    ),
1308                    SettingsUiEntry::ShowHardwareCursor => format!(
1309                        "showHardwareCursor: {}",
1310                        bool_label(self.effective_show_hardware_cursor())
1311                    ),
1312                    SettingsUiEntry::DoubleEscapeAction => format!(
1313                        "doubleEscapeAction: {}",
1314                        self.config
1315                            .double_escape_action
1316                            .as_deref()
1317                            .unwrap_or("tree")
1318                    ),
1319                    SettingsUiEntry::EditorPaddingX => {
1320                        format!("editorPaddingX: {}", self.editor_padding_x)
1321                    }
1322                    SettingsUiEntry::AutocompleteMaxVisible => {
1323                        format!("autocompleteMaxVisible: {}", self.autocomplete.max_visible)
1324                    }
1325                };
1326                let row = format!(" {label}");
1327                let rendered = if is_selected {
1328                    self.styles.selection.render(&row)
1329                } else {
1330                    row
1331                };
1332
1333                let _ = writeln!(output, "{prefix} {rendered}");
1334            }
1335
1336            if settings_ui.entries.len() > visible_count {
1337                let _ = writeln!(
1338                    output,
1339                    "  {}",
1340                    self.styles.muted.render(&format!(
1341                        "({}-{} of {})",
1342                        offset + 1,
1343                        end,
1344                        settings_ui.entries.len()
1345                    ))
1346                );
1347            }
1348        }
1349
1350        output.push('\n');
1351        let _ = writeln!(
1352            output,
1353            "  {}",
1354            self.styles
1355                .muted_italic
1356                .render("↑/↓/j/k: navigate  Enter: select  Esc/q: cancel")
1357        );
1358
1359        output
1360    }
1361
1362    pub(super) fn render_theme_picker(&self, picker: &ThemePickerOverlay) -> String {
1363        let mut output = String::new();
1364
1365        let _ = writeln!(output, "\n  {}\n", self.styles.title.render("Select Theme"));
1366
1367        if picker.items.is_empty() {
1368            let _ = writeln!(output, "  {}", self.styles.muted.render("No themes found."));
1369        } else {
1370            let offset = picker.scroll_offset();
1371            let visible_count = picker.max_visible.min(picker.items.len());
1372            let end = (offset + visible_count).min(picker.items.len());
1373
1374            for (idx, item) in picker.items[offset..end].iter().enumerate() {
1375                let global_idx = offset + idx;
1376                let is_selected = global_idx == picker.selected;
1377
1378                let prefix = if is_selected { ">" } else { " " };
1379                let (name, label) = match item {
1380                    ThemePickerItem::BuiltIn(name) => {
1381                        (name.to_string(), format!("{name} (built-in)"))
1382                    }
1383                    ThemePickerItem::File(path) => {
1384                        // Load theme to get name, or fallback to file stem.
1385                        // Performance note: repetitive load, but themes are small JSON files.
1386                        let name = Theme::load(path).map_or_else(
1387                            |_| {
1388                                path.file_stem().map_or_else(
1389                                    || "unknown".to_string(),
1390                                    |s| s.to_string_lossy().to_string(),
1391                                )
1392                            },
1393                            |t| t.name,
1394                        );
1395                        (name.clone(), format!("{name} (custom)"))
1396                    }
1397                };
1398
1399                let active = name.eq_ignore_ascii_case(&self.theme.name);
1400                let marker = if active { " *" } else { "" };
1401
1402                let row = format!(" {label}{marker}");
1403                let rendered = if is_selected {
1404                    self.styles.selection.render(&row)
1405                } else {
1406                    row
1407                };
1408
1409                let _ = writeln!(output, "{prefix} {rendered}");
1410            }
1411
1412            if picker.items.len() > visible_count {
1413                let _ = writeln!(
1414                    output,
1415                    "  {}",
1416                    self.styles.muted.render(&format!(
1417                        "({}-{} of {})",
1418                        offset + 1,
1419                        end,
1420                        picker.items.len()
1421                    ))
1422                );
1423            }
1424        }
1425
1426        output.push('\n');
1427        let _ = writeln!(
1428            output,
1429            "  {}",
1430            self.styles
1431                .muted_italic
1432                .render("↑/↓/j/k: navigate  Enter: select  Esc/q: back")
1433        );
1434
1435        output
1436    }
1437
1438    pub(super) fn render_capability_prompt(&self, prompt: &CapabilityPromptOverlay) -> String {
1439        let mut output = String::new();
1440
1441        // Title line.
1442        let _ = writeln!(
1443            output,
1444            "\n  {}",
1445            self.styles.title.render("Extension Permission Request")
1446        );
1447
1448        // Extension and capability info.
1449        let _ = writeln!(
1450            output,
1451            "  {} requests {}",
1452            self.styles.accent_bold.render(&prompt.extension_id),
1453            self.styles.warning_bold.render(&prompt.capability),
1454        );
1455
1456        // Description.
1457        if !prompt.description.is_empty() {
1458            let _ = writeln!(
1459                output,
1460                "\n  {}",
1461                self.styles.muted.render(&prompt.description),
1462            );
1463        }
1464
1465        // Button row.
1466        output.push('\n');
1467        output.push_str("  ");
1468        for (idx, action) in CapabilityAction::ALL.iter().enumerate() {
1469            let label = action.label();
1470            let rendered = if idx == prompt.focused {
1471                self.styles.selection.render(&format!("[{label}]"))
1472            } else {
1473                self.styles.muted.render(&format!(" {label} "))
1474            };
1475            output.push_str(&rendered);
1476            output.push_str("  ");
1477        }
1478        output.push('\n');
1479
1480        // Auto-deny timer.
1481        if let Some(secs) = prompt.auto_deny_secs {
1482            let _ = writeln!(
1483                output,
1484                "  {}",
1485                self.styles
1486                    .muted_italic
1487                    .render(&format!("Auto-deny in {secs}s")),
1488            );
1489        }
1490
1491        // Help text.
1492        let _ = writeln!(
1493            output,
1494            "  {}",
1495            self.styles
1496                .muted_italic
1497                .render("←/→/Tab: navigate  Enter: confirm  Esc: deny")
1498        );
1499
1500        output
1501    }
1502
1503    pub(super) fn render_branch_picker(&self, picker: &BranchPickerOverlay) -> String {
1504        let mut output = String::new();
1505
1506        let _ = writeln!(
1507            output,
1508            "\n  {}",
1509            self.styles.title.render("Select a branch")
1510        );
1511        let _ = writeln!(
1512            output,
1513            "  {}",
1514            self.styles
1515                .muted
1516                .render("-------------------------------------------")
1517        );
1518
1519        if picker.branches.is_empty() {
1520            let _ = writeln!(
1521                output,
1522                "  {}",
1523                self.styles.muted_italic.render("No branches found.")
1524            );
1525        } else {
1526            let offset = picker.scroll_offset();
1527            let visible_count = picker.max_visible.min(picker.branches.len());
1528            let end = (offset + visible_count).min(picker.branches.len());
1529
1530            for (idx, branch) in picker.branches[offset..end].iter().enumerate() {
1531                let global_idx = offset + idx;
1532                let is_selected = global_idx == picker.selected;
1533                let prefix = if is_selected { ">" } else { " " };
1534
1535                let current_marker = if branch.is_current { " *" } else { "" };
1536                let msg_count = format!("({} msgs)", branch.message_count);
1537                let preview = if branch.preview.chars().count() > 40 {
1538                    let truncated: String = branch.preview.chars().take(37).collect();
1539                    format!("{truncated}...")
1540                } else {
1541                    branch.preview.clone()
1542                };
1543
1544                let row = format!("{prefix} {preview:<42} {msg_count:>10}{current_marker}");
1545                let rendered = if is_selected {
1546                    self.styles.accent_bold.render(&row)
1547                } else if branch.is_current {
1548                    self.styles.accent.render(&row)
1549                } else {
1550                    self.styles.muted.render(&row)
1551                };
1552                let _ = writeln!(output, "  {rendered}");
1553            }
1554        }
1555
1556        let _ = writeln!(
1557            output,
1558            "\n  {}",
1559            self.styles
1560                .muted_italic
1561                .render("↑/↓/j/k: navigate  Enter: switch  Esc: cancel  * = current")
1562        );
1563        output
1564    }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569    use super::*;
1570    use crate::session::{AutosaveDurabilityMode, AutosaveQueueMetrics};
1571
1572    #[test]
1573    fn normalize_raw_terminal_newlines_inserts_crlf() {
1574        let normalized = normalize_raw_terminal_newlines("hello\nworld\n".to_string());
1575        assert_eq!(normalized, "hello\r\nworld\r\n");
1576    }
1577
1578    #[test]
1579    fn normalize_raw_terminal_newlines_preserves_existing_crlf() {
1580        let normalized = normalize_raw_terminal_newlines("hello\r\nworld\r\n".to_string());
1581        assert_eq!(normalized, "hello\r\nworld\r\n");
1582    }
1583
1584    #[test]
1585    fn normalize_raw_terminal_newlines_handles_mixed_newlines() {
1586        let normalized = normalize_raw_terminal_newlines("a\r\nb\nc\r\nd\n".to_string());
1587        assert_eq!(normalized, "a\r\nb\r\nc\r\nd\r\n");
1588    }
1589
1590    #[test]
1591    fn normalize_raw_terminal_newlines_preserves_utf8_content() {
1592        let normalized = normalize_raw_terminal_newlines("αβ\nγ\r\nδ\n".to_string());
1593        assert_eq!(normalized, "αβ\r\nγ\r\nδ\r\n");
1594    }
1595
1596    #[test]
1597    fn clamp_to_terminal_height_noop_when_fits() {
1598        let input = "line1\nline2\nline3".to_string();
1599        // 2 newlines => 3 rows; term_height=4 allows 3 newlines => fits.
1600        assert_eq!(clamp_to_terminal_height(input.clone(), 4), input);
1601    }
1602
1603    #[test]
1604    fn clamp_to_terminal_height_truncates_excess() {
1605        let input = "a\nb\nc\nd\ne\n".to_string(); // 5 newlines = 6 rows
1606        // term_height=4 => max 3 newlines => keeps "a\nb\nc\nd"
1607        let clamped = clamp_to_terminal_height(input, 4);
1608        assert_eq!(clamped, "a\nb\nc\nd");
1609    }
1610
1611    #[test]
1612    fn clamp_to_terminal_height_zero_height() {
1613        let clamped = clamp_to_terminal_height("hello\nworld".to_string(), 0);
1614        assert_eq!(clamped, "");
1615    }
1616
1617    #[test]
1618    fn clamp_to_terminal_height_exact_fit() {
1619        // term_height=3 => max 2 newlines. Input has exactly 2 => fits.
1620        let input = "a\nb\nc".to_string();
1621        assert_eq!(clamp_to_terminal_height(input.clone(), 3), input);
1622    }
1623
1624    #[test]
1625    fn clamp_to_terminal_height_trailing_newline() {
1626        // "a\nb\n" = 2 newlines, 3 rows (last row empty).
1627        // term_height=2 => max 1 newline => "a\nb"
1628        let clamped = clamp_to_terminal_height("a\nb\n".to_string(), 2);
1629        assert_eq!(clamped, "a\nb");
1630    }
1631
1632    #[test]
1633    fn persistence_footer_segment_healthy() {
1634        let metrics = AutosaveQueueMetrics {
1635            pending_mutations: 0,
1636            max_pending_mutations: 256,
1637            coalesced_mutations: 0,
1638            backpressure_events: 0,
1639            flush_started: 0,
1640            flush_succeeded: 0,
1641            flush_failed: 0,
1642            last_flush_batch_size: 0,
1643            last_flush_duration_ms: None,
1644            last_flush_trigger: None,
1645        };
1646        assert_eq!(
1647            format_persistence_footer_segment(AutosaveDurabilityMode::Balanced, metrics),
1648            "Persist: balanced"
1649        );
1650    }
1651
1652    #[test]
1653    fn persistence_footer_segment_includes_backlog_and_failures() {
1654        let metrics = AutosaveQueueMetrics {
1655            pending_mutations: 256,
1656            max_pending_mutations: 256,
1657            coalesced_mutations: 99,
1658            backpressure_events: 4,
1659            flush_started: 5,
1660            flush_succeeded: 3,
1661            flush_failed: 2,
1662            last_flush_batch_size: 64,
1663            last_flush_duration_ms: Some(42),
1664            last_flush_trigger: Some(crate::session::AutosaveFlushTrigger::Periodic),
1665        };
1666        let rendered =
1667            format_persistence_footer_segment(AutosaveDurabilityMode::Throughput, metrics);
1668        assert!(rendered.contains("Persist: throughput"));
1669        assert!(rendered.contains("pending 256/256"));
1670        assert!(rendered.contains("flush-fail 2"));
1671        assert!(rendered.contains("backpressure"));
1672    }
1673
1674    #[test]
1675    fn wrapped_plain_line_no_wrap_when_under_width() {
1676        let segments = wrapped_line_segments("hello", 10);
1677        assert_eq!(segments, vec!["hello"]);
1678    }
1679
1680    #[test]
1681    fn wrapped_plain_line_wraps_when_over_width() {
1682        let segments = wrapped_line_segments("abcdef", 4);
1683        assert_eq!(segments, vec!["abcd", "ef"]);
1684    }
1685
1686    #[test]
1687    fn wrapped_plain_line_preserves_empty_line() {
1688        let segments = wrapped_line_segments("", 8);
1689        assert_eq!(segments, vec![""]);
1690    }
1691
1692    #[test]
1693    fn parse_fence_line_detects_backtick_and_tilde_fences() {
1694        assert_eq!(parse_fence_line("```rust"), Some(('`', 3, "rust")));
1695        assert_eq!(parse_fence_line("   ~~~~~"), Some(('~', 5, "")));
1696        assert_eq!(parse_fence_line("`not-a-fence"), None);
1697    }
1698
1699    #[test]
1700    fn parse_fence_line_rejects_four_space_indent() {
1701        assert_eq!(parse_fence_line("    ```rust"), None);
1702    }
1703
1704    #[test]
1705    fn streaming_unclosed_fence_none_when_balanced() {
1706        let markdown = "```rust\nfn main() {}\n```\n";
1707        assert_eq!(streaming_unclosed_fence(markdown), None);
1708    }
1709
1710    #[test]
1711    fn streaming_unclosed_fence_detects_open_backtick_block() {
1712        let markdown = "Heading\n\n```rust\nfn main() {\n    println!(\"hi\");";
1713        assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1714    }
1715
1716    #[test]
1717    fn streaming_unclosed_fence_does_not_close_on_trailing_text() {
1718        let markdown = "```rust\nfn main() {}\n``` trailing";
1719        assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1720    }
1721
1722    #[test]
1723    fn streaming_unclosed_fence_closes_on_whitespace_only_suffix() {
1724        let markdown = "```rust\nfn main() {}\n```   \n";
1725        assert_eq!(streaming_unclosed_fence(markdown), None);
1726    }
1727
1728    #[test]
1729    fn streaming_unclosed_fence_ignores_invalid_backtick_info() {
1730        let markdown = "```a`b\ncontent\n";
1731        assert_eq!(streaming_unclosed_fence(markdown), None);
1732    }
1733
1734    #[test]
1735    fn stabilize_streaming_markdown_closes_unterminated_fence() {
1736        let markdown = "```python\nprint('hello')";
1737        let stabilized = stabilize_streaming_markdown(markdown);
1738        assert_eq!(stabilized.as_ref(), "```python\nprint('hello')\n```");
1739    }
1740
1741    #[test]
1742    fn stabilize_streaming_markdown_preserves_balanced_input() {
1743        let markdown = "# Title\n\n- item\n";
1744        let stabilized = stabilize_streaming_markdown(markdown);
1745        assert_eq!(stabilized.as_ref(), markdown);
1746    }
1747
1748    #[test]
1749    fn streaming_needs_markdown_renderer_false_for_plain_text() {
1750        let markdown = "Starting response... token_1 token_2";
1751        assert!(!streaming_needs_markdown_renderer(markdown));
1752    }
1753
1754    #[test]
1755    fn streaming_needs_markdown_renderer_true_for_heading() {
1756        let markdown = "# Heading";
1757        assert!(streaming_needs_markdown_renderer(markdown));
1758    }
1759
1760    #[test]
1761    fn streaming_needs_markdown_renderer_true_for_underscore_emphasis() {
1762        let markdown = "This is _important_.";
1763        assert!(streaming_needs_markdown_renderer(markdown));
1764    }
1765
1766    #[test]
1767    fn append_streaming_plaintext_to_output_wraps_without_trailing_blank() {
1768        let mut out = String::new();
1769        append_streaming_plaintext_to_output(&mut out, "abcdef\n", 4);
1770        assert_eq!(out, "  abcd\n  ef\n");
1771    }
1772}