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    fn render_startup_placeholder(&self) -> String {
334        let mut output = String::new();
335        let plain_width = self.term_width.saturating_sub(4).max(1);
336
337        if !self.startup_welcome.trim().is_empty() {
338            for line in self.startup_welcome.lines() {
339                if line.trim().is_empty() {
340                    output.push('\n');
341                    continue;
342                }
343                for segment in wrapped_line_segments(line, plain_width) {
344                    let _ = writeln!(output, "{}", self.styles.muted_italic.render(segment));
345                }
346            }
347        }
348
349        match &self.startup_changelog {
350            Some(StartupChangelog::Condensed { latest_version }) => {
351                if !output.is_empty() && !output.ends_with('\n') {
352                    output.push('\n');
353                }
354                let message =
355                    format!("Updated to v{latest_version}. Use /changelog to view full changelog.");
356                for segment in wrapped_line_segments(&message, plain_width) {
357                    let _ = writeln!(output, "  {}", self.styles.warning.render(segment));
358                }
359            }
360            Some(StartupChangelog::Full { markdown }) => {
361                if !output.is_empty() && !output.ends_with('\n') {
362                    output.push('\n');
363                }
364                let _ = writeln!(output, "  {}", self.styles.accent_bold.render("What's New"));
365                output.push('\n');
366                let rendered = glamour::Renderer::new()
367                    .with_style_config(self.markdown_style.clone())
368                    .with_word_wrap(self.term_width.saturating_sub(6).max(40))
369                    .render(markdown);
370                for line in rendered.lines() {
371                    let _ = writeln!(output, "  {line}");
372                }
373            }
374            None => {}
375        }
376
377        output.trim_end().to_string()
378    }
379
380    /// Render the view.
381    #[allow(clippy::too_many_lines)]
382    pub(super) fn view(&self) -> String {
383        let view_start = if self.frame_timing.enabled {
384            Some(std::time::Instant::now())
385        } else {
386            None
387        };
388
389        // PERF-7: Pre-allocate view output with capacity from the previous
390        // frame, avoiding incremental String grows during assembly.
391        let mut output = String::with_capacity(self.render_buffers.view_capacity_hint());
392
393        // Header — PERF-7: render directly into output, no intermediate String.
394        self.render_header_into(&mut output);
395        output.push('\n');
396
397        // Modal overlays (e.g. /tree) take over the main view.
398        if let Some(tree_ui) = &self.tree_ui {
399            output.push_str(&view_tree_ui(tree_ui, &self.styles));
400            self.render_footer_into(&mut output);
401            return output;
402        }
403
404        // Build conversation content for viewport.
405        // Trim trailing whitespace so the viewport line count matches
406        // what refresh_conversation_viewport() stored — this keeps the
407        // y_offset from goto_bottom() aligned with the visible lines.
408        let conversation_content = {
409            let content_start = if self.frame_timing.enabled {
410                Some(std::time::Instant::now())
411            } else {
412                None
413            };
414            let mut raw = self.build_conversation_content();
415            if let Some(start) = content_start {
416                self.frame_timing
417                    .record_content_build(micros_as_u64(start.elapsed().as_micros()));
418            }
419            // PERF-7: Truncate in place instead of trim_end().to_string()
420            // which would allocate a second copy of the entire content.
421            let trimmed_len = raw.trim_end().len();
422            raw.truncate(trimmed_len);
423            raw
424        };
425
426        // Render conversation area (scrollable).
427        // Use the per-frame effective height so that conditional chrome
428        // (scroll indicator, tool status, status message, …) is accounted
429        // for and the total output never exceeds term_height rows.
430        let effective_vp = self.view_effective_conversation_height();
431        {
432            // PERF-7: Use Cow to avoid consuming conversation_content so
433            // the reusable buffer is always returned regardless of path.
434            use std::borrow::Cow;
435            let viewport_content: Cow<'_, str> = if conversation_content.is_empty() {
436                Cow::Owned(self.render_startup_placeholder())
437            } else {
438                Cow::Borrowed(&conversation_content)
439            };
440
441            // PERF: Count total lines with memchr (O(n) byte scan, no alloc)
442            // instead of collecting all lines into a Vec.  For a 10K-line
443            // conversation this avoids a ~80KB Vec<&str> allocation per frame.
444            let total_lines = memchr::memchr_iter(b'\n', viewport_content.as_bytes()).count() + 1;
445            let start = if self.follow_stream_tail {
446                total_lines.saturating_sub(effective_vp)
447            } else {
448                self.conversation_viewport
449                    .y_offset()
450                    .min(total_lines.saturating_sub(1))
451            };
452            let end = (start + effective_vp).min(total_lines);
453
454            // Skip `start` lines, then take `end - start` lines — no Vec
455            // allocation needed.
456            let mut first = true;
457            for line in viewport_content.lines().skip(start).take(end - start) {
458                if first {
459                    first = false;
460                } else {
461                    output.push('\n');
462                }
463                output.push_str(line);
464            }
465            output.push('\n');
466
467            // Scroll indicator
468            if total_lines > effective_vp {
469                let total = total_lines.saturating_sub(effective_vp);
470                let percent = (start * 100).checked_div(total).map_or(100, |p| p.min(100));
471                let indicator = format!("  [{percent}%] ↑/↓ PgUp/PgDn Shift+Up/Down to scroll");
472                output.push_str(&self.styles.muted.render(&indicator));
473                output.push('\n');
474            }
475        }
476        // PERF-7: Return the conversation buffer for reuse next frame.
477        // Always returned (even when empty) to preserve heap capacity.
478        self.render_buffers
479            .return_conversation_buffer(conversation_content);
480
481        // Tool status
482        if let Some(tool) = &self.current_tool {
483            let progress_str = self.tool_progress.as_ref().map_or_else(String::new, |p| {
484                let secs = p.elapsed_ms / 1000;
485                if secs < 1 {
486                    return String::new();
487                }
488                let mut parts = vec![format!("{secs}s")];
489                if p.line_count > 0 {
490                    parts.push(format!("{} lines", format_count(p.line_count)));
491                } else if p.byte_count > 0 {
492                    parts.push(format!("{} bytes", format_count(p.byte_count)));
493                }
494                if let Some(timeout_ms) = p.timeout_ms {
495                    let timeout_s = timeout_ms / 1000;
496                    if timeout_s > 0 {
497                        parts.push(format!("timeout {timeout_s}s"));
498                    }
499                }
500                format!(" ({})", parts.join(" \u{2022} "))
501            });
502            let _ = write!(
503                output,
504                "\n  {} {}{} ...\n",
505                self.spinner.view(),
506                self.styles.warning_bold.render(&format!("Running {tool}")),
507                self.styles.muted.render(&progress_str),
508            );
509        }
510
511        // Status message (slash command feedback)
512        if let Some(status) = &self.status_message {
513            let status_style = self.styles.accent.clone().italic();
514            let _ = write!(output, "\n  {}\n", status_style.render(status));
515        }
516
517        // Session picker overlay (if open)
518        if let Some(ref picker) = self.session_picker {
519            output.push_str(&self.render_session_picker(picker));
520        }
521
522        // Settings overlay (if open)
523        if let Some(ref settings_ui) = self.settings_ui {
524            output.push_str(&self.render_settings_ui(settings_ui));
525        }
526
527        // Theme picker overlay (if open)
528        if let Some(ref picker) = self.theme_picker {
529            output.push_str(&self.render_theme_picker(picker));
530        }
531
532        // Capability prompt overlay (if open)
533        if let Some(ref prompt) = self.capability_prompt {
534            output.push_str(&self.render_capability_prompt(prompt));
535        }
536
537        // Extension custom overlay (if open)
538        if let Some(ref overlay) = self.extension_custom_overlay {
539            output.push_str(&self.render_extension_custom_overlay(overlay));
540        }
541
542        // Branch picker overlay (if open)
543        if let Some(ref picker) = self.branch_picker {
544            output.push_str(&self.render_branch_picker(picker));
545        }
546
547        // Model selector overlay (if open)
548        if let Some(ref selector) = self.model_selector {
549            output.push_str(&self.render_model_selector(selector));
550        }
551
552        // Input area (only when idle and no overlay open)
553        if self.editor_input_is_available() {
554            output.push_str(&self.render_input());
555
556            // Autocomplete dropdown (if open)
557            if self.autocomplete.open && !self.autocomplete.items.is_empty() {
558                output.push_str(&self.render_autocomplete_dropdown());
559            }
560        } else if self.agent_state != AgentState::Idle {
561            if self.show_processing_status_spinner() {
562                // Show spinner while waiting on provider/tool activity, before
563                // we have visible streaming deltas.
564                let _ = write!(
565                    output,
566                    "\n  {} {}\n",
567                    self.spinner.view(),
568                    self.styles.accent.render("Processing...")
569                );
570            }
571
572            if let Some(pending_queue) = self.render_pending_message_queue() {
573                output.push_str(&pending_queue);
574            }
575        }
576
577        // Footer with usage stats — PERF-7: render directly into output.
578        self.render_footer_into(&mut output);
579
580        // Clamp the output to `term_height` rows so the terminal never
581        // scrolls in the alternate-screen buffer.
582        let output = clamp_to_terminal_height(output, self.term_height);
583        let output = normalize_raw_terminal_newlines(output);
584
585        // PERF-7: Remember this frame's output size so the next frame can
586        // pre-allocate with the right capacity.
587        self.render_buffers.set_view_capacity_hint(output.len());
588
589        if let Some(start) = view_start {
590            self.frame_timing
591                .record_frame(micros_as_u64(start.elapsed().as_micros()));
592        }
593
594        output
595    }
596
597    /// PERF-7: Render the header directly into `output`, avoiding an
598    /// intermediate `String` allocation on the hot path.
599    fn render_header_into(&self, output: &mut String) {
600        let model_label = format!("({})", self.model);
601
602        // Branch indicator: show "Branch N/M" when session has multiple leaves.
603        let branch_indicator = self
604            .session
605            .try_lock()
606            .ok()
607            .and_then(|guard| {
608                let info = guard.branch_summary();
609                if info.leaf_count <= 1 {
610                    return None;
611                }
612                let current_idx = info
613                    .current_leaf
614                    .as_ref()
615                    .and_then(|leaf| info.leaves.iter().position(|l| l == leaf))
616                    .map_or(1, |i| i + 1);
617                Some(format!(" [branch {current_idx}/{}]", info.leaf_count))
618            })
619            .unwrap_or_default();
620
621        let model_key = self.header_binding_hint(AppAction::SelectModel, "ctrl+l");
622        let next_model_key = self.header_binding_hint(AppAction::CycleModelForward, "ctrl+p");
623        let prev_model_key =
624            self.header_binding_hint(AppAction::CycleModelBackward, "ctrl+shift+p");
625        let tools_key = self.header_binding_hint(AppAction::ExpandTools, "ctrl+o");
626        let thinking_key = self.header_binding_hint(AppAction::CycleThinkingLevel, "shift+tab");
627        let max_width = self.term_width.saturating_sub(2);
628
629        let hints_line = truncate(
630            &format!(
631                "{model_key}: model  {next_model_key}: next  {prev_model_key}: prev  \
632                 {tools_key}: tools  {thinking_key}: thinking"
633            ),
634            max_width,
635        );
636
637        let resources_line = truncate(
638            &format!(
639                "resources: {} skills, {} prompts, {} themes, {} extensions",
640                self.resources.skills().len(),
641                self.resources.prompts().len(),
642                self.resources.themes().len(),
643                self.resources.extensions().len()
644            ),
645            max_width,
646        );
647
648        let _ = write!(
649            output,
650            "  {} {}{}\n  {}\n  {}\n",
651            self.styles.title.render("Pi"),
652            self.styles.muted.render(&model_label),
653            self.styles.accent.render(&branch_indicator),
654            self.styles.muted.render(&hints_line),
655            self.styles.muted.render(&resources_line),
656        );
657    }
658
659    pub(super) fn render_header(&self) -> String {
660        let mut buf = String::new();
661        self.render_header_into(&mut buf);
662        buf
663    }
664
665    pub(super) fn render_input(&self) -> String {
666        let mut output = String::new();
667
668        let thinking_level = self
669            .session
670            .try_lock()
671            .ok()
672            .and_then(|guard| guard.header.thinking_level.clone())
673            .and_then(|level| level.parse::<ThinkingLevel>().ok())
674            .or_else(|| {
675                self.config
676                    .default_thinking_level
677                    .as_deref()
678                    .and_then(|level| level.parse::<ThinkingLevel>().ok())
679            })
680            .unwrap_or(ThinkingLevel::Off);
681
682        let input_text = self.input.value();
683        let is_bash_mode = parse_bash_command(&input_text).is_some();
684
685        let (thinking_label, thinking_style, thinking_border_style) = match thinking_level {
686            ThinkingLevel::Off => (
687                "off",
688                self.styles.muted_bold.clone(),
689                self.styles.border.clone(),
690            ),
691            ThinkingLevel::Minimal => (
692                "minimal",
693                self.styles.accent.clone(),
694                self.styles.accent.clone(),
695            ),
696            ThinkingLevel::Low => (
697                "low",
698                self.styles.accent.clone(),
699                self.styles.accent.clone(),
700            ),
701            ThinkingLevel::Medium => (
702                "medium",
703                self.styles.accent_bold.clone(),
704                self.styles.accent.clone(),
705            ),
706            ThinkingLevel::High => (
707                "high",
708                self.styles.warning_bold.clone(),
709                self.styles.warning.clone(),
710            ),
711            ThinkingLevel::XHigh => (
712                "xhigh",
713                self.styles.error_bold.clone(),
714                self.styles.error_bold.clone(),
715            ),
716        };
717
718        let thinking_plain = format!("[thinking: {thinking_label}]");
719        let thinking_badge = thinking_style.render(&thinking_plain);
720        let bash_badge = is_bash_mode.then(|| self.styles.warning_bold.render("[bash]"));
721
722        let max_width = self.term_width.saturating_sub(2);
723        let reserved = 2
724            + thinking_plain.chars().count()
725            + if is_bash_mode {
726                2 + "[bash]".chars().count()
727            } else {
728                0
729            };
730        let available_for_mode = max_width.saturating_sub(reserved);
731        let mut mode_text = match self.input_mode {
732            InputMode::SingleLine => "Enter: send  Shift+Enter: newline  Alt+Enter: multi-line",
733            InputMode::MultiLine => "Alt+Enter: send  Enter: newline  Esc: single-line",
734        }
735        .to_string();
736        if mode_text.chars().count() > available_for_mode {
737            mode_text = truncate(&mode_text, available_for_mode);
738        }
739        let mut header_line = String::new();
740        header_line.push_str(&self.styles.muted.render(&mode_text));
741        header_line.push_str("  ");
742        header_line.push_str(&thinking_badge);
743        if let Some(bash_badge) = bash_badge {
744            header_line.push_str("  ");
745            header_line.push_str(&bash_badge);
746        }
747        let _ = writeln!(output, "\n  {header_line}");
748
749        let padding = " ".repeat(self.editor_padding_x);
750        let line_prefix = format!("  {padding}");
751        let border_style = if is_bash_mode {
752            self.styles.warning_bold.clone()
753        } else {
754            thinking_border_style
755        };
756        let border = border_style.render("│");
757        for line in self.input.view().lines() {
758            output.push_str(&line_prefix);
759            output.push_str(&border);
760            output.push(' ');
761            output.push_str(line);
762            output.push('\n');
763        }
764
765        output
766    }
767
768    /// PERF-7: Render the footer directly into `output`, avoiding an
769    /// intermediate `String` allocation on the hot path.
770    fn render_footer_into(&self, output: &mut String) {
771        let total_cost = self.total_usage.cost.total;
772        let cost_str = if total_cost > 0.0 {
773            format!(" (${total_cost:.4})")
774        } else {
775            String::new()
776        };
777
778        let input = self.total_usage.input;
779        let output_tokens = self.total_usage.output;
780        let persistence_str = self.session.try_lock().ok().map_or_else(
781            || "Persist: unavailable".to_string(),
782            |session| {
783                format_persistence_footer_segment(
784                    session.autosave_durability_mode(),
785                    session.autosave_metrics(),
786                )
787            },
788        );
789        let branch_str = self
790            .vcs_info
791            .as_ref()
792            .map_or_else(String::new, |b| format!("  |  {b}"));
793        let mode_hint = match self.input_mode {
794            InputMode::SingleLine => "Shift+Enter: newline  |  Alt+Enter: multi-line",
795            InputMode::MultiLine => "Enter: newline  |  Alt+Enter: send  |  Esc: single-line",
796        };
797        let footer_long = format!(
798            "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str}  |  {persistence_str}  |  {mode_hint}  |  /help  |  Ctrl+C: quit"
799        );
800        let footer_short = format!(
801            "Tokens: {input} in / {output_tokens} out{cost_str}{branch_str}  |  {persistence_str}  |  /help  |  Ctrl+C: quit"
802        );
803        let max_width = self.term_width.saturating_sub(2);
804        let mut footer = if footer_long.chars().count() <= max_width {
805            footer_long
806        } else {
807            footer_short
808        };
809        if footer.chars().count() > max_width {
810            footer = truncate(&footer, max_width);
811        }
812        let _ = write!(output, "\n  {}\n", self.styles.muted.render(&footer));
813    }
814
815    pub(super) fn render_footer(&self) -> String {
816        let mut buf = String::new();
817        self.render_footer_into(&mut buf);
818        buf
819    }
820
821    /// Render a single conversation message to a string (uncached path).
822    fn render_single_message(&self, msg: &ConversationMessage) -> String {
823        let mut output = String::new();
824        match msg.role {
825            MessageRole::User => {
826                let _ = write!(
827                    output,
828                    "\n  {} {}\n",
829                    self.styles.accent_bold.render("You:"),
830                    msg.content
831                );
832            }
833            MessageRole::Assistant => {
834                let _ = write!(
835                    output,
836                    "\n  {}\n",
837                    self.styles.success_bold.render("Assistant:")
838                );
839
840                // Render thinking if present
841                if self.thinking_visible {
842                    if let Some(thinking) = &msg.thinking {
843                        let truncated = truncate(thinking, 100);
844                        let _ = writeln!(
845                            output,
846                            "  {}",
847                            self.styles
848                                .muted_italic
849                                .render(&format!("Thinking: {truncated}"))
850                        );
851                    }
852                }
853
854                // Render markdown content
855                let rendered = glamour::Renderer::new()
856                    .with_style_config(self.markdown_style.clone())
857                    .with_word_wrap(self.term_width.saturating_sub(6).max(40))
858                    .render(&msg.content);
859                for line in rendered.lines() {
860                    let _ = writeln!(output, "  {line}");
861                }
862            }
863            MessageRole::Tool => {
864                // Per-message collapse: global toggle overrides, then per-message.
865                let show_expanded = self.tools_expanded && !msg.collapsed;
866                if show_expanded {
867                    let rendered = render_tool_message(&msg.content, &self.styles);
868                    let _ = write!(output, "\n  {rendered}\n");
869                } else {
870                    let header = msg.content.lines().next().unwrap_or("Tool output");
871                    let line_count = memchr::memchr_iter(b'\n', msg.content.as_bytes()).count() + 1;
872                    let summary = format!(
873                        "\u{25b6} {} ({line_count} lines, collapsed)",
874                        header.trim_end()
875                    );
876                    let _ = write!(
877                        output,
878                        "\n  {}\n",
879                        self.styles.muted_italic.render(&summary)
880                    );
881                    // Show preview when per-message collapsed (not global).
882                    if self.tools_expanded && msg.collapsed {
883                        for (i, line) in msg.content.lines().skip(1).enumerate() {
884                            if i >= TOOL_COLLAPSE_PREVIEW_LINES {
885                                let remaining = line_count
886                                    .saturating_sub(1)
887                                    .saturating_sub(TOOL_COLLAPSE_PREVIEW_LINES);
888                                let _ = writeln!(
889                                    output,
890                                    "  {}",
891                                    self.styles
892                                        .muted
893                                        .render(&format!("  ... {remaining} more lines"))
894                                );
895                                break;
896                            }
897                            let _ = writeln!(
898                                output,
899                                "  {}",
900                                self.styles.muted.render(&format!("  {line}"))
901                            );
902                        }
903                    }
904                }
905            }
906            MessageRole::System => {
907                let _ = write!(output, "\n  {}\n", self.styles.warning.render(&msg.content));
908            }
909        }
910        output
911    }
912
913    /// Build the conversation content string for the viewport.
914    ///
915    /// Uses `MessageRenderCache` (PERF-1) to avoid re-rendering unchanged
916    /// messages and a conversation prefix cache (PERF-2) to skip iterating
917    /// all messages during streaming. Streaming content (current_response)
918    /// always renders fresh.
919    pub fn build_conversation_content(&self) -> String {
920        let has_streaming_state =
921            !self.current_response.is_empty() || !self.current_thinking.is_empty();
922        let has_visible_streaming_tail = !self.current_response.is_empty()
923            || (self.thinking_visible && !self.current_thinking.is_empty());
924
925        // PERF-7: Reuse the pre-allocated conversation buffer from the
926        // previous frame. `take_conversation_buffer()` clears the buffer
927        // but preserves its heap capacity, avoiding a fresh allocation.
928        let mut output = self.render_buffers.take_conversation_buffer();
929
930        // PERF-2 fast path: during streaming, reuse the cached prefix
931        // (all finalized messages) and only rebuild the streaming tail.
932        if has_streaming_state && self.message_render_cache.prefix_valid(self.messages.len()) {
933            // PERF-7: Append prefix directly into the reusable buffer
934            // instead of cloning via prefix_get().
935            self.message_render_cache.prefix_append_to(&mut output);
936            if has_visible_streaming_tail {
937                self.append_streaming_tail(&mut output);
938            }
939            return output;
940        }
941
942        // Full rebuild: iterate all messages with per-message cache (PERF-1).
943        for (index, msg) in self.messages.iter().enumerate() {
944            let key =
945                MessageRenderCache::compute_key(msg, self.thinking_visible, self.tools_expanded);
946
947            if self
948                .message_render_cache
949                .append_cached(&mut output, index, &key)
950            {
951                continue;
952            }
953            let rendered = self.render_single_message(msg);
954            // PERF: push_str first, then move into cache — avoids cloning
955            // the rendered String (which can be several KB for tool output).
956            output.push_str(&rendered);
957            self.message_render_cache.put(index, key, rendered);
958        }
959
960        // Snapshot the prefix for future streaming frames (PERF-2).
961        self.message_render_cache
962            .prefix_set(&output, self.messages.len());
963
964        // Append streaming content if active.
965        if has_visible_streaming_tail {
966            self.append_streaming_tail(&mut output);
967        }
968
969        output
970    }
971
972    /// Render the current streaming response / thinking into `output`.
973    /// Always renders fresh — never cached.
974    fn append_streaming_tail(&self, output: &mut String) {
975        let _ = write!(
976            output,
977            "\n  {}\n",
978            self.styles.success_bold.render("Assistant:")
979        );
980
981        let content_width = self.term_width.saturating_sub(4).max(1);
982
983        // Show thinking if present
984        if self.thinking_visible && !self.current_thinking.is_empty() {
985            let truncated = truncate(&self.current_thinking, 100);
986            let thinking_line = format!("Thinking: {truncated}");
987            for segment in wrapped_line_segments(&thinking_line, content_width) {
988                let _ = writeln!(output, "  {}", self.styles.muted_italic.render(segment));
989            }
990        }
991
992        // Render partial markdown on every stream update so headings/lists/code
993        // format as they arrive instead of showing raw markers.
994        if !self.current_response.is_empty() {
995            let markdown_width = self.term_width.saturating_sub(6).max(40);
996            if streaming_needs_markdown_renderer(&self.current_response) {
997                let rendered = render_streaming_markdown_with_glamour(
998                    &self.current_response,
999                    &self.markdown_style,
1000                    markdown_width,
1001                );
1002                for line in rendered.lines() {
1003                    let _ = writeln!(output, "  {line}");
1004                }
1005            } else {
1006                append_streaming_plaintext_to_output(
1007                    output,
1008                    &self.current_response,
1009                    markdown_width,
1010                );
1011            }
1012        }
1013    }
1014
1015    pub(super) fn render_pending_message_queue(&self) -> Option<String> {
1016        if self.agent_state == AgentState::Idle {
1017            return None;
1018        }
1019
1020        let Ok(queue) = self.message_queue.lock() else {
1021            return None;
1022        };
1023
1024        let steering_len = queue.steering_len();
1025        let follow_len = queue.follow_up_len();
1026        if steering_len == 0 && follow_len == 0 {
1027            return None;
1028        }
1029
1030        let max_preview = self.term_width.saturating_sub(24).max(20);
1031
1032        let mut out = String::new();
1033        out.push_str("\n  ");
1034        out.push_str(&self.styles.muted_bold.render("Pending:"));
1035        out.push(' ');
1036        out.push_str(
1037            &self
1038                .styles
1039                .accent_bold
1040                .render(&format!("{steering_len} steering")),
1041        );
1042        out.push_str(&self.styles.muted.render(", "));
1043        out.push_str(&self.styles.muted.render(&format!("{follow_len} follow-up")));
1044        out.push('\n');
1045
1046        if let Some(text) = queue.steering_front() {
1047            let preview = queued_message_preview(text, max_preview);
1048            out.push_str("  ");
1049            out.push_str(&self.styles.accent_bold.render("steering →"));
1050            out.push(' ');
1051            out.push_str(&preview);
1052            out.push('\n');
1053        }
1054
1055        if let Some(text) = queue.follow_up_front() {
1056            let preview = queued_message_preview(text, max_preview);
1057            out.push_str("  ");
1058            out.push_str(&self.styles.muted_bold.render("follow-up →"));
1059            out.push(' ');
1060            out.push_str(&self.styles.muted.render(&preview));
1061            out.push('\n');
1062        }
1063
1064        Some(out)
1065    }
1066
1067    #[allow(clippy::too_many_lines)]
1068    pub(super) fn render_autocomplete_dropdown(&self) -> String {
1069        let mut output = String::new();
1070
1071        let offset = self.autocomplete.scroll_offset();
1072        // Constrain visible items to available terminal space.
1073        // Dropdown chrome uses ~5 rows (borders, help, pagination, description).
1074        let max_dropdown_rows = self.term_height.saturating_sub(
1075            // header(4) + min conversation(1) + scroll indicator(1)
1076            // + input(2 + height) + footer(2) + dropdown chrome(5)
1077            4 + 1 + 1 + 2 + self.input.height() + 2 + 5,
1078        );
1079        let visible_count = self
1080            .autocomplete
1081            .max_visible
1082            .min(self.autocomplete.items.len())
1083            .min(max_dropdown_rows.max(1));
1084        let end = (offset + visible_count).min(self.autocomplete.items.len());
1085
1086        // Styles
1087        let border_style = &self.styles.border;
1088        let selected_style = &self.styles.selection;
1089        let kind_style = &self.styles.warning;
1090        let desc_style = &self.styles.muted_italic;
1091
1092        // Top border
1093        let width = 60;
1094        let _ = write!(
1095            output,
1096            "\n  {}",
1097            border_style.render(&format!("┌{:─<width$}┐", ""))
1098        );
1099
1100        for (idx, item) in self.autocomplete.items[offset..end].iter().enumerate() {
1101            use unicode_width::UnicodeWidthStr;
1102            let global_idx = offset + idx;
1103            let is_selected = self.autocomplete.selected == Some(global_idx);
1104
1105            let kind_icon = match item.kind {
1106                AutocompleteItemKind::SlashCommand => "⚡",
1107                AutocompleteItemKind::ExtensionCommand => "🧩",
1108                AutocompleteItemKind::PromptTemplate => "📄",
1109                AutocompleteItemKind::Skill => "🔧",
1110                AutocompleteItemKind::Model => "🤖",
1111                AutocompleteItemKind::File => "📁",
1112                AutocompleteItemKind::Path => "📂",
1113            };
1114
1115            let max_label_len = width.saturating_sub(6);
1116            let label_width = item.label.width();
1117            let label = if label_width > max_label_len {
1118                let mut out = String::with_capacity(max_label_len);
1119                let mut current_width = 0;
1120                for c in item.label.chars() {
1121                    let w = c.width().unwrap_or(0);
1122                    if current_width + w > max_label_len {
1123                        while current_width > max_label_len.saturating_sub(1) {
1124                            if let Some(last) = out.pop() {
1125                                current_width -= last.width().unwrap_or(0);
1126                            } else {
1127                                break;
1128                            }
1129                        }
1130                        out.push('…');
1131                        break;
1132                    }
1133                    out.push(c);
1134                    current_width += w;
1135                }
1136                out
1137            } else {
1138                item.label.clone()
1139            };
1140
1141            let actual_label_width = label.width();
1142            let padding = " ".repeat(max_label_len.saturating_sub(actual_label_width));
1143            let line_content = format!("{kind_icon} {label}{padding}");
1144            let styled_line = if is_selected {
1145                selected_style.render(&line_content)
1146            } else {
1147                format!("{} {label}{padding}", kind_style.render(kind_icon))
1148            };
1149
1150            let _ = write!(
1151                output,
1152                "\n  {}{}{}",
1153                border_style.render("│"),
1154                styled_line,
1155                border_style.render("│")
1156            );
1157
1158            if is_selected {
1159                if let Some(desc) = &item.description {
1160                    let max_desc_len = width.saturating_sub(4);
1161                    let desc_width = desc.width();
1162                    let truncated_desc = if desc_width > max_desc_len {
1163                        let mut out = String::with_capacity(max_desc_len);
1164                        let mut current_width = 0;
1165                        for c in desc.chars() {
1166                            let c = if c == '\n' { ' ' } else { c };
1167                            let w = c.width().unwrap_or(0);
1168                            if current_width + w > max_desc_len {
1169                                while current_width > max_desc_len.saturating_sub(1) {
1170                                    if let Some(last) = out.pop() {
1171                                        current_width -= last.width().unwrap_or(0);
1172                                    } else {
1173                                        break;
1174                                    }
1175                                }
1176                                out.push('…');
1177                                break;
1178                            }
1179                            out.push(c);
1180                            current_width += w;
1181                        }
1182                        out
1183                    } else {
1184                        desc.replace('\n', " ")
1185                    };
1186
1187                    let _ = write!(
1188                        output,
1189                        "\n  {}  {}{}",
1190                        border_style.render("│"),
1191                        desc_style.render(&truncated_desc),
1192                        border_style.render(&format!(
1193                            "{:>pad$}│",
1194                            "",
1195                            pad = width
1196                                .saturating_sub(2)
1197                                .saturating_sub(truncated_desc.width())
1198                        ))
1199                    );
1200                }
1201            }
1202        }
1203
1204        if self.autocomplete.items.len() > visible_count {
1205            let shown = format!(
1206                "{}-{} of {}",
1207                offset + 1,
1208                end,
1209                self.autocomplete.items.len()
1210            );
1211            let _ = write!(
1212                output,
1213                "\n  {}",
1214                border_style.render(&format!("│{shown:^width$}│"))
1215            );
1216        }
1217
1218        let _ = write!(
1219            output,
1220            "\n  {}",
1221            border_style.render(&format!("└{:─<width$}┘", ""))
1222        );
1223
1224        let _ = write!(
1225            output,
1226            "\n  {}",
1227            self.styles
1228                .muted_italic
1229                .render("↑/↓ navigate  Enter/Tab accept  Esc cancel")
1230        );
1231
1232        output
1233    }
1234
1235    #[allow(clippy::too_many_lines)]
1236    pub(super) fn render_session_picker(&self, picker: &SessionPickerOverlay) -> String {
1237        let mut output = String::new();
1238
1239        let _ = writeln!(
1240            output,
1241            "\n  {}\n",
1242            self.styles.title.render("Select a session to resume")
1243        );
1244
1245        let query = picker.query();
1246        let search_line = if query.is_empty() {
1247            "  > (type to filter sessions)".to_string()
1248        } else {
1249            format!("  > {query}")
1250        };
1251        let _ = writeln!(output, "{}", self.styles.muted.render(&search_line));
1252        let _ = writeln!(
1253            output,
1254            "  {}",
1255            self.styles.muted.render("─".repeat(50).as_str())
1256        );
1257        output.push('\n');
1258
1259        if picker.sessions.is_empty() {
1260            let message = if picker.has_query() {
1261                "No sessions match the current filter."
1262            } else {
1263                "No sessions found for this project."
1264            };
1265            let _ = writeln!(output, "  {}", self.styles.muted.render(message));
1266        } else {
1267            let _ = writeln!(
1268                output,
1269                "  {:<20}  {:<30}  {:<8}  {}",
1270                self.styles.muted_bold.render("Time"),
1271                self.styles.muted_bold.render("Name"),
1272                self.styles.muted_bold.render("Messages"),
1273                self.styles.muted_bold.render("Session ID")
1274            );
1275            output.push_str("  ");
1276            output.push_str(&"-".repeat(78));
1277            output.push('\n');
1278
1279            let offset = picker.scroll_offset();
1280            let visible_count = picker.max_visible.min(picker.sessions.len());
1281            let end = (offset + visible_count).min(picker.sessions.len());
1282
1283            for (idx, session) in picker.sessions[offset..end].iter().enumerate() {
1284                let global_idx = offset + idx;
1285                let is_selected = global_idx == picker.selected;
1286
1287                let prefix = if is_selected { ">" } else { " " };
1288                let time = crate::session_picker::format_time(&session.timestamp);
1289                let name = session
1290                    .name
1291                    .as_deref()
1292                    .unwrap_or("-")
1293                    .chars()
1294                    .take(28)
1295                    .collect::<String>();
1296                let messages = session.message_count.to_string();
1297                let id = crate::session_picker::truncate_session_id(&session.id, 8);
1298
1299                let row = format!(" {time:<20}  {name:<30}  {messages:<8}  {id}");
1300                let rendered = if is_selected {
1301                    self.styles.selection.render(&row)
1302                } else {
1303                    row
1304                };
1305
1306                let _ = writeln!(output, "{prefix} {rendered}");
1307            }
1308
1309            if picker.sessions.len() > visible_count {
1310                let _ = writeln!(
1311                    output,
1312                    "  {}",
1313                    self.styles.muted.render(&format!(
1314                        "({}-{} of {})",
1315                        offset + 1,
1316                        end,
1317                        picker.sessions.len()
1318                    ))
1319                );
1320            }
1321        }
1322
1323        output.push('\n');
1324        if picker.confirm_delete {
1325            let _ = writeln!(
1326                output,
1327                "  {}",
1328                self.styles.warning_bold.render(
1329                    picker
1330                        .status_message
1331                        .as_deref()
1332                        .unwrap_or("Delete session? Press y/n to confirm."),
1333                )
1334            );
1335        } else {
1336            let _ = writeln!(
1337                output,
1338                "  {}",
1339                self.styles.muted_italic.render(
1340                    "Type: filter  Backspace: clear  ↑/↓/j/k/PgUp/PgDn: navigate  Enter: select  Ctrl+D: delete  Esc/q: cancel",
1341                )
1342            );
1343            if let Some(message) = &picker.status_message {
1344                let _ = writeln!(output, "  {}", self.styles.warning_bold.render(message));
1345            }
1346        }
1347
1348        output
1349    }
1350
1351    pub(super) fn render_settings_ui(&self, settings_ui: &SettingsUiState) -> String {
1352        let mut output = String::new();
1353
1354        let _ = writeln!(output, "\n  {}\n", self.styles.title.render("Settings"));
1355
1356        if settings_ui.entries.is_empty() {
1357            let _ = writeln!(
1358                output,
1359                "  {}",
1360                self.styles.muted.render("No settings available.")
1361            );
1362        } else {
1363            let offset = settings_ui.scroll_offset();
1364            let visible_count = settings_ui.max_visible.min(settings_ui.entries.len());
1365            let end = (offset + visible_count).min(settings_ui.entries.len());
1366
1367            for (idx, entry) in settings_ui.entries[offset..end].iter().enumerate() {
1368                let global_idx = offset + idx;
1369                let is_selected = global_idx == settings_ui.selected;
1370
1371                let prefix = if is_selected { ">" } else { " " };
1372                let label = match *entry {
1373                    SettingsUiEntry::Summary => "Summary".to_string(),
1374                    SettingsUiEntry::Theme => "Theme".to_string(),
1375                    SettingsUiEntry::SteeringMode => format!(
1376                        "steeringMode: {}",
1377                        self.config.steering_queue_mode().as_str()
1378                    ),
1379                    SettingsUiEntry::FollowUpMode => format!(
1380                        "followUpMode: {}",
1381                        self.config.follow_up_queue_mode().as_str()
1382                    ),
1383                    SettingsUiEntry::DefaultPermissive => format!(
1384                        "extensionPolicy.defaultPermissive: {}{}",
1385                        bool_label(self.effective_default_permissive()),
1386                        if self.default_permissive_changes_require_extension_restart() {
1387                            " (restart required)"
1388                        } else {
1389                            ""
1390                        }
1391                    ),
1392                    SettingsUiEntry::QuietStartup => format!(
1393                        "quietStartup: {}",
1394                        bool_label(self.config.quiet_startup.unwrap_or(false))
1395                    ),
1396                    SettingsUiEntry::CollapseChangelog => format!(
1397                        "collapseChangelog: {}",
1398                        bool_label(self.config.collapse_changelog.unwrap_or(false))
1399                    ),
1400                    SettingsUiEntry::HideThinkingBlock => format!(
1401                        "hideThinkingBlock: {}",
1402                        bool_label(self.config.hide_thinking_block.unwrap_or(false))
1403                    ),
1404                    SettingsUiEntry::ShowHardwareCursor => format!(
1405                        "showHardwareCursor: {}",
1406                        bool_label(self.effective_show_hardware_cursor())
1407                    ),
1408                    SettingsUiEntry::DoubleEscapeAction => format!(
1409                        "doubleEscapeAction: {}",
1410                        self.config
1411                            .double_escape_action
1412                            .as_deref()
1413                            .unwrap_or("tree")
1414                    ),
1415                    SettingsUiEntry::EditorPaddingX => {
1416                        format!("editorPaddingX: {}", self.editor_padding_x)
1417                    }
1418                    SettingsUiEntry::AutocompleteMaxVisible => {
1419                        format!("autocompleteMaxVisible: {}", self.autocomplete.max_visible)
1420                    }
1421                };
1422                let row = format!(" {label}");
1423                let rendered = if is_selected {
1424                    self.styles.selection.render(&row)
1425                } else {
1426                    row
1427                };
1428
1429                let _ = writeln!(output, "{prefix} {rendered}");
1430            }
1431
1432            if settings_ui.entries.len() > visible_count {
1433                let _ = writeln!(
1434                    output,
1435                    "  {}",
1436                    self.styles.muted.render(&format!(
1437                        "({}-{} of {})",
1438                        offset + 1,
1439                        end,
1440                        settings_ui.entries.len()
1441                    ))
1442                );
1443            }
1444        }
1445
1446        output.push('\n');
1447        let _ = writeln!(
1448            output,
1449            "  {}",
1450            self.styles
1451                .muted_italic
1452                .render("↑/↓/j/k/PgUp/PgDn: navigate  Enter: select  Esc/q: cancel")
1453        );
1454
1455        output
1456    }
1457
1458    pub(super) fn render_theme_picker(&self, picker: &ThemePickerOverlay) -> String {
1459        let mut output = String::new();
1460
1461        let _ = writeln!(output, "\n  {}\n", self.styles.title.render("Select Theme"));
1462
1463        if picker.items.is_empty() {
1464            let _ = writeln!(output, "  {}", self.styles.muted.render("No themes found."));
1465        } else {
1466            let offset = picker.scroll_offset();
1467            let visible_count = picker.max_visible.min(picker.items.len());
1468            let end = (offset + visible_count).min(picker.items.len());
1469
1470            for (idx, item) in picker.items[offset..end].iter().enumerate() {
1471                let global_idx = offset + idx;
1472                let is_selected = global_idx == picker.selected;
1473
1474                let prefix = if is_selected { ">" } else { " " };
1475                let (name, label) = match item {
1476                    ThemePickerItem::BuiltIn(name) => {
1477                        (name.to_string(), format!("{name} (built-in)"))
1478                    }
1479                    ThemePickerItem::File { name, .. } => {
1480                        (name.clone(), format!("{name} (custom)"))
1481                    }
1482                };
1483
1484                let active = name.eq_ignore_ascii_case(&self.theme.name);
1485                let marker = if active { " *" } else { "" };
1486
1487                let row = format!(" {label}{marker}");
1488                let rendered = if is_selected {
1489                    self.styles.selection.render(&row)
1490                } else {
1491                    row
1492                };
1493
1494                let _ = writeln!(output, "{prefix} {rendered}");
1495            }
1496
1497            if picker.items.len() > visible_count {
1498                let _ = writeln!(
1499                    output,
1500                    "  {}",
1501                    self.styles.muted.render(&format!(
1502                        "({}-{} of {})",
1503                        offset + 1,
1504                        end,
1505                        picker.items.len()
1506                    ))
1507                );
1508            }
1509        }
1510
1511        output.push('\n');
1512        let _ = writeln!(
1513            output,
1514            "  {}",
1515            self.styles
1516                .muted_italic
1517                .render("↑/↓/j/k/PgUp/PgDn: navigate  Enter: select  Esc/q: back")
1518        );
1519
1520        output
1521    }
1522
1523    pub(super) fn render_capability_prompt(&self, prompt: &CapabilityPromptOverlay) -> String {
1524        let mut output = String::new();
1525
1526        // Title line.
1527        let _ = writeln!(
1528            output,
1529            "\n  {}",
1530            self.styles.title.render("Extension Permission Request")
1531        );
1532
1533        // Extension and capability info.
1534        let _ = writeln!(
1535            output,
1536            "  {} requests {}",
1537            self.styles.accent_bold.render(&prompt.extension_id),
1538            self.styles.warning_bold.render(&prompt.capability),
1539        );
1540
1541        // Description.
1542        if !prompt.description.is_empty() {
1543            let _ = writeln!(
1544                output,
1545                "\n  {}",
1546                self.styles.muted.render(&prompt.description),
1547            );
1548        }
1549
1550        // Button row.
1551        output.push('\n');
1552        output.push_str("  ");
1553        for (idx, action) in CapabilityAction::ALL.iter().enumerate() {
1554            let label = action.label();
1555            let rendered = if idx == prompt.focused {
1556                self.styles.selection.render(&format!("[{label}]"))
1557            } else {
1558                self.styles.muted.render(&format!(" {label} "))
1559            };
1560            output.push_str(&rendered);
1561            output.push_str("  ");
1562        }
1563        output.push('\n');
1564
1565        // Auto-deny timer.
1566        if let Some(secs) = prompt.auto_deny_secs {
1567            let _ = writeln!(
1568                output,
1569                "  {}",
1570                self.styles
1571                    .muted_italic
1572                    .render(&format!("Auto-deny in {secs}s")),
1573            );
1574        }
1575
1576        // Help text.
1577        let _ = writeln!(
1578            output,
1579            "  {}",
1580            self.styles
1581                .muted_italic
1582                .render("←/→/Tab: navigate  Enter: confirm  Esc: deny")
1583        );
1584
1585        output
1586    }
1587
1588    pub(super) fn render_extension_custom_overlay(
1589        &self,
1590        overlay: &ExtensionCustomOverlay,
1591    ) -> String {
1592        let mut output = String::new();
1593        let title = overlay.title.as_deref().unwrap_or("Extension Overlay");
1594        let source = overlay.extension_id.as_deref().unwrap_or("extension");
1595
1596        let _ = writeln!(output, "\n  {}", self.styles.title.render(title));
1597        let _ = writeln!(
1598            output,
1599            "  {}",
1600            self.styles
1601                .muted
1602                .render(&format!("[{source}] custom UI active"))
1603        );
1604
1605        let max_lines = self.term_height.saturating_sub(12).max(4);
1606        if overlay.lines.is_empty() {
1607            let _ = writeln!(
1608                output,
1609                "  {}",
1610                self.styles
1611                    .muted_italic
1612                    .render("Waiting for extension frame...")
1613            );
1614        } else {
1615            for line in overlay
1616                .lines
1617                .iter()
1618                .skip(overlay.lines.len().saturating_sub(max_lines))
1619            {
1620                let _ = writeln!(output, "  {line}");
1621            }
1622        }
1623        let _ = writeln!(
1624            output,
1625            "  {}",
1626            self.styles
1627                .muted_italic
1628                .render("Press q to exit extension overlays that support quit")
1629        );
1630
1631        output
1632    }
1633
1634    pub(super) fn render_branch_picker(&self, picker: &BranchPickerOverlay) -> String {
1635        let mut output = String::new();
1636
1637        let _ = writeln!(
1638            output,
1639            "\n  {}",
1640            self.styles.title.render("Select a branch")
1641        );
1642        let _ = writeln!(
1643            output,
1644            "  {}",
1645            self.styles
1646                .muted
1647                .render("-------------------------------------------")
1648        );
1649
1650        if picker.branches.is_empty() {
1651            let _ = writeln!(
1652                output,
1653                "  {}",
1654                self.styles.muted_italic.render("No branches found.")
1655            );
1656        } else {
1657            let offset = picker.scroll_offset();
1658            let visible_count = picker.max_visible.min(picker.branches.len());
1659            let end = (offset + visible_count).min(picker.branches.len());
1660
1661            for (idx, branch) in picker.branches[offset..end].iter().enumerate() {
1662                let global_idx = offset + idx;
1663                let is_selected = global_idx == picker.selected;
1664                let prefix = if is_selected { ">" } else { " " };
1665
1666                let current_marker = if branch.is_current { " *" } else { "" };
1667                let msg_count = format!("({} msgs)", branch.message_count);
1668                let preview = if branch.preview.chars().count() > 40 {
1669                    let truncated: String = branch.preview.chars().take(37).collect();
1670                    format!("{truncated}...")
1671                } else {
1672                    branch.preview.clone()
1673                };
1674
1675                let row = format!("{prefix} {preview:<42} {msg_count:>10}{current_marker}");
1676                let rendered = if is_selected {
1677                    self.styles.accent_bold.render(&row)
1678                } else if branch.is_current {
1679                    self.styles.accent.render(&row)
1680                } else {
1681                    self.styles.muted.render(&row)
1682                };
1683                let _ = writeln!(output, "  {rendered}");
1684            }
1685        }
1686
1687        let _ = writeln!(
1688            output,
1689            "\n  {}",
1690            self.styles
1691                .muted_italic
1692                .render("↑/↓/j/k/PgUp/PgDn: navigate  Enter: switch  Esc: cancel  * = current")
1693        );
1694        output
1695    }
1696}
1697
1698#[cfg(test)]
1699mod tests {
1700    use super::*;
1701    use crate::session::{AutosaveDurabilityMode, AutosaveQueueMetrics};
1702
1703    #[test]
1704    fn normalize_raw_terminal_newlines_inserts_crlf() {
1705        let normalized = normalize_raw_terminal_newlines("hello\nworld\n".to_string());
1706        assert_eq!(normalized, "hello\r\nworld\r\n");
1707    }
1708
1709    #[test]
1710    fn normalize_raw_terminal_newlines_preserves_existing_crlf() {
1711        let normalized = normalize_raw_terminal_newlines("hello\r\nworld\r\n".to_string());
1712        assert_eq!(normalized, "hello\r\nworld\r\n");
1713    }
1714
1715    #[test]
1716    fn normalize_raw_terminal_newlines_handles_mixed_newlines() {
1717        let normalized = normalize_raw_terminal_newlines("a\r\nb\nc\r\nd\n".to_string());
1718        assert_eq!(normalized, "a\r\nb\r\nc\r\nd\r\n");
1719    }
1720
1721    #[test]
1722    fn normalize_raw_terminal_newlines_preserves_utf8_content() {
1723        let normalized = normalize_raw_terminal_newlines("αβ\nγ\r\nδ\n".to_string());
1724        assert_eq!(normalized, "αβ\r\nγ\r\nδ\r\n");
1725    }
1726
1727    #[test]
1728    fn clamp_to_terminal_height_noop_when_fits() {
1729        let input = "line1\nline2\nline3".to_string();
1730        // 2 newlines => 3 rows; term_height=4 allows 3 newlines => fits.
1731        assert_eq!(clamp_to_terminal_height(input.clone(), 4), input);
1732    }
1733
1734    #[test]
1735    fn clamp_to_terminal_height_truncates_excess() {
1736        let input = "a\nb\nc\nd\ne\n".to_string(); // 5 newlines = 6 rows
1737        // term_height=4 => max 3 newlines => keeps "a\nb\nc\nd"
1738        let clamped = clamp_to_terminal_height(input, 4);
1739        assert_eq!(clamped, "a\nb\nc\nd");
1740    }
1741
1742    #[test]
1743    fn clamp_to_terminal_height_zero_height() {
1744        let clamped = clamp_to_terminal_height("hello\nworld".to_string(), 0);
1745        assert_eq!(clamped, "");
1746    }
1747
1748    #[test]
1749    fn clamp_to_terminal_height_exact_fit() {
1750        // term_height=3 => max 2 newlines. Input has exactly 2 => fits.
1751        let input = "a\nb\nc".to_string();
1752        assert_eq!(clamp_to_terminal_height(input.clone(), 3), input);
1753    }
1754
1755    #[test]
1756    fn clamp_to_terminal_height_trailing_newline() {
1757        // "a\nb\n" = 2 newlines, 3 rows (last row empty).
1758        // term_height=2 => max 1 newline => "a\nb"
1759        let clamped = clamp_to_terminal_height("a\nb\n".to_string(), 2);
1760        assert_eq!(clamped, "a\nb");
1761    }
1762
1763    #[test]
1764    fn persistence_footer_segment_healthy() {
1765        let metrics = AutosaveQueueMetrics {
1766            pending_mutations: 0,
1767            max_pending_mutations: 256,
1768            coalesced_mutations: 0,
1769            backpressure_events: 0,
1770            flush_started: 0,
1771            flush_succeeded: 0,
1772            flush_failed: 0,
1773            last_flush_batch_size: 0,
1774            last_flush_duration_ms: None,
1775            last_flush_trigger: None,
1776        };
1777        assert_eq!(
1778            format_persistence_footer_segment(AutosaveDurabilityMode::Balanced, metrics),
1779            "Persist: balanced"
1780        );
1781    }
1782
1783    #[test]
1784    fn persistence_footer_segment_includes_backlog_and_failures() {
1785        let metrics = AutosaveQueueMetrics {
1786            pending_mutations: 256,
1787            max_pending_mutations: 256,
1788            coalesced_mutations: 99,
1789            backpressure_events: 4,
1790            flush_started: 5,
1791            flush_succeeded: 3,
1792            flush_failed: 2,
1793            last_flush_batch_size: 64,
1794            last_flush_duration_ms: Some(42),
1795            last_flush_trigger: Some(crate::session::AutosaveFlushTrigger::Periodic),
1796        };
1797        let rendered =
1798            format_persistence_footer_segment(AutosaveDurabilityMode::Throughput, metrics);
1799        assert!(rendered.contains("Persist: throughput"));
1800        assert!(rendered.contains("pending 256/256"));
1801        assert!(rendered.contains("flush-fail 2"));
1802        assert!(rendered.contains("backpressure"));
1803    }
1804
1805    #[test]
1806    fn wrapped_plain_line_no_wrap_when_under_width() {
1807        let segments = wrapped_line_segments("hello", 10);
1808        assert_eq!(segments, vec!["hello"]);
1809    }
1810
1811    #[test]
1812    fn wrapped_plain_line_wraps_when_over_width() {
1813        let segments = wrapped_line_segments("abcdef", 4);
1814        assert_eq!(segments, vec!["abcd", "ef"]);
1815    }
1816
1817    #[test]
1818    fn wrapped_plain_line_preserves_empty_line() {
1819        let segments = wrapped_line_segments("", 8);
1820        assert_eq!(segments, vec![""]);
1821    }
1822
1823    #[test]
1824    fn parse_fence_line_detects_backtick_and_tilde_fences() {
1825        assert_eq!(parse_fence_line("```rust"), Some(('`', 3, "rust")));
1826        assert_eq!(parse_fence_line("   ~~~~~"), Some(('~', 5, "")));
1827        assert_eq!(parse_fence_line("`not-a-fence"), None);
1828    }
1829
1830    #[test]
1831    fn parse_fence_line_rejects_four_space_indent() {
1832        assert_eq!(parse_fence_line("    ```rust"), None);
1833    }
1834
1835    #[test]
1836    fn streaming_unclosed_fence_none_when_balanced() {
1837        let markdown = "```rust\nfn main() {}\n```\n";
1838        assert_eq!(streaming_unclosed_fence(markdown), None);
1839    }
1840
1841    #[test]
1842    fn streaming_unclosed_fence_detects_open_backtick_block() {
1843        let markdown = "Heading\n\n```rust\nfn main() {\n    println!(\"hi\");";
1844        assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1845    }
1846
1847    #[test]
1848    fn streaming_unclosed_fence_does_not_close_on_trailing_text() {
1849        let markdown = "```rust\nfn main() {}\n``` trailing";
1850        assert_eq!(streaming_unclosed_fence(markdown), Some(('`', 3)));
1851    }
1852
1853    #[test]
1854    fn streaming_unclosed_fence_closes_on_whitespace_only_suffix() {
1855        let markdown = "```rust\nfn main() {}\n```   \n";
1856        assert_eq!(streaming_unclosed_fence(markdown), None);
1857    }
1858
1859    #[test]
1860    fn streaming_unclosed_fence_ignores_invalid_backtick_info() {
1861        let markdown = "```a`b\ncontent\n";
1862        assert_eq!(streaming_unclosed_fence(markdown), None);
1863    }
1864
1865    #[test]
1866    fn stabilize_streaming_markdown_closes_unterminated_fence() {
1867        let markdown = "```python\nprint('hello')";
1868        let stabilized = stabilize_streaming_markdown(markdown);
1869        assert_eq!(stabilized.as_ref(), "```python\nprint('hello')\n```");
1870    }
1871
1872    #[test]
1873    fn stabilize_streaming_markdown_preserves_balanced_input() {
1874        let markdown = "# Title\n\n- item\n";
1875        let stabilized = stabilize_streaming_markdown(markdown);
1876        assert_eq!(stabilized.as_ref(), markdown);
1877    }
1878
1879    #[test]
1880    fn streaming_needs_markdown_renderer_false_for_plain_text() {
1881        let markdown = "Starting response... token_1 token_2";
1882        assert!(!streaming_needs_markdown_renderer(markdown));
1883    }
1884
1885    #[test]
1886    fn streaming_needs_markdown_renderer_true_for_heading() {
1887        let markdown = "# Heading";
1888        assert!(streaming_needs_markdown_renderer(markdown));
1889    }
1890
1891    #[test]
1892    fn streaming_needs_markdown_renderer_true_for_underscore_emphasis() {
1893        let markdown = "This is _important_.";
1894        assert!(streaming_needs_markdown_renderer(markdown));
1895    }
1896
1897    #[test]
1898    fn append_streaming_plaintext_to_output_wraps_without_trailing_blank() {
1899        let mut out = String::new();
1900        append_streaming_plaintext_to_output(&mut out, "abcdef\n", 4);
1901        assert_eq!(out, "  abcd\n  ef\n");
1902    }
1903}