Skip to main content

atomcode_tuix/render/
alt_screen.rs

1// crates/atomcode-tuix/src/render/alt_screen.rs
2//
3// Alt-screen renderer (Phase 1: skeleton).
4//
5// AltScreenRenderer takes over the terminal's alternate screen buffer
6// (`\x1b[?1049h`) and paints into it with absolute cursor positioning,
7// bypassing DECSTBM scroll regions entirely. This is the strategy
8// vim / htop / less / Claude Code / opencode all use, and is the
9// answer for terminals (JetBrains JediTerm, legacy Windows conhost)
10// that don't fully implement DECSTBM but DO support alt-screen.
11//
12// Trade-off: the host terminal's native scrollback is unavailable
13// while the app is running — Cmd+Up / Page Up in the host terminal
14// won't reach above the alt-screen. The app provides its own internal
15// scrollback navigation (Phase 2) instead. On exit, the alt-screen is
16// popped and the host terminal returns to its pre-app state.
17//
18// See `docs/superpowers/specs/2026-04-29-alt-screen-renderer-design.md`
19// for the full design and phasing.
20//
21// PHASE 1 SCOPE (this file): skeleton only.
22//   * Renderer trait stubbed — most arms are no-op
23//   * Welcome banner rendered at fixed rows (no body buffer yet)
24//   * Alt-screen enter on construct, pop on Drop
25//   * Routes from `lib.rs` only via `ATOMCODE_ALT=1` user opt-in
26//
27// Later phases bring in the body_lines buffer, scrollback navigation,
28// pinned input box / status bar / spinner, resize handling, and
29// auto-detection. See spec §Phasing.
30
31use std::io::{self, BufWriter, Stdout, Write};
32
33use super::{MenuPayload, Renderer, StatusLine, UiLine};
34use crate::i18n::{t, Msg};
35use crate::sanitize::scrub_controls;
36use crate::terminal::TerminalCaps;
37use crate::width::{display_width, truncate_to_width};
38use unicode_width::UnicodeWidthChar;
39
40/// Truncate `s` to `max_cols` display columns, treating ANSI CSI
41/// escape sequences (`\x1b[...{letter}`) as zero-width spans so SGR
42/// styling doesn't eat budget that should belong to visible text.
43///
44/// `truncate_to_width` from `crate::width` counts each character of an
45/// SGR sequence (`[`, digits, `m`) as width 1, which under-budgets the
46/// visible content — a 79-display-col line decorated with one SGR pair
47/// would lose 5+ trailing visible chars even though the line fits the
48/// terminal exactly. This helper skips the entire CSI sequence in one
49/// go, matching how the terminal interprets it.
50///
51/// Final SGR reset (`\x1b[0m`) preservation: if truncation cut into an
52/// open span, the caller still appends a reset; this fn just guarantees
53/// the visible-text count is right.
54fn truncate_to_width_sgr_aware(s: &str, max_cols: usize) -> String {
55    if max_cols == 0 {
56        return String::new();
57    }
58    let mut acc = String::with_capacity(s.len());
59    let mut cols = 0usize;
60    let mut iter = s.chars().peekable();
61    while let Some(c) = iter.next() {
62        // CSI sequence: ESC `[` {params} {final letter A-Z/a-z}.
63        // Append the whole span verbatim (zero visible cost).
64        if c == '\x1b' && iter.peek() == Some(&'[') {
65            acc.push(c);
66            acc.push(iter.next().unwrap()); // consume `[`
67            for nc in iter.by_ref() {
68                acc.push(nc);
69                if nc.is_ascii_alphabetic() {
70                    break; // final byte ends the CSI sequence
71                }
72            }
73            continue;
74        }
75        let w = UnicodeWidthChar::width(c).unwrap_or(0);
76        if cols + w > max_cols {
77            break;
78        }
79        acc.push(c);
80        cols += w;
81    }
82    acc
83}
84
85/// Soft-wrap `s` into chunks each ≤ `max_cols` display columns, using
86/// the same CSI-aware parser as `truncate_to_width_sgr_aware`. Used by
87/// `push_command_output` so long single-line content (notably the OAuth
88/// URL printed during `/login`) survives `paint_body`'s width-truncation
89/// step instead of being clipped at the right edge — clipped lines can't
90/// be selected for copy in alt-screen mode.
91///
92/// Wraps at character boundaries (no word-break logic): URLs are the
93/// motivating case, and they have no whitespace anyway. SGR spans that
94/// straddle a wrap point are not re-emitted on the next chunk; for the
95/// uncoloured content this fn is currently fed (URLs, plain log lines)
96/// that's a non-issue, and `paint_body` writes a trailing `\x1b[0m` per
97/// row so dangling spans don't bleed into adjacent rows.
98///
99/// Empty input returns `vec![String::new()]` so callers preserve blank
100/// lines (the previous `for line in safe.split('\n')` invariant).
101fn wrap_to_width_sgr_aware(s: &str, max_cols: usize) -> Vec<String> {
102    if max_cols == 0 {
103        return vec![String::new()];
104    }
105    let mut chunks: Vec<String> = Vec::new();
106    let mut acc = String::new();
107    let mut cols = 0usize;
108    let mut iter = s.chars().peekable();
109    while let Some(c) = iter.next() {
110        if c == '\x1b' && iter.peek() == Some(&'[') {
111            // CSI: zero visible width, copy verbatim into current chunk.
112            acc.push(c);
113            acc.push(iter.next().unwrap());
114            for nc in iter.by_ref() {
115                acc.push(nc);
116                if nc.is_ascii_alphabetic() {
117                    break;
118                }
119            }
120            continue;
121        }
122        let w = UnicodeWidthChar::width(c).unwrap_or(0);
123        if w > 0 && cols + w > max_cols {
124            chunks.push(std::mem::take(&mut acc));
125            cols = 0;
126        }
127        acc.push(c);
128        cols += w;
129    }
130    chunks.push(acc);
131    chunks
132}
133
134/// Walk `s` and return the visible-text display width, treating CSI
135/// escape sequences as zero-width spans (same parser as
136/// `truncate_to_width_sgr_aware`). Used to clamp selection columns
137/// against the actual painted content of a body line — clicks past the
138/// end of the visible row should select nothing in the gap, not extend
139/// to the column the user happened to drop on.
140fn line_display_width_sgr_aware(s: &str) -> usize {
141    let mut cols = 0usize;
142    let mut iter = s.chars().peekable();
143    while let Some(c) = iter.next() {
144        if c == '\x1b' && iter.peek() == Some(&'[') {
145            iter.next(); // consume `[`
146            for nc in iter.by_ref() {
147                if nc.is_ascii_alphabetic() {
148                    break;
149                }
150            }
151            continue;
152        }
153        cols += UnicodeWidthChar::width(c).unwrap_or(0);
154    }
155    cols
156}
157
158/// Walk `line` and emit it clipped to `max_cols` display columns, with
159/// chars whose display column falls in `[sel_start, sel_end)` wrapped
160/// in reverse-video (`\x1b[7m` … `\x1b[0m`). CSI escapes outside the
161/// selection pass through verbatim so existing colours render; CSI
162/// escapes INSIDE the selection are dropped so reverse-video stays
163/// solid (otherwise an inline `\x1b[0m` from markdown styling would
164/// reset the highlight mid-span).
165///
166/// Wide chars (CJK, emoji): a single char that straddles `sel_start`
167/// or `sel_end` is treated as fully inside if its first column is in
168/// range — matches what the user expects when they click on the left
169/// half of a wide char.
170fn render_line_with_selection(
171    line: &str,
172    max_cols: usize,
173    sel_start: usize,
174    sel_end: usize,
175) -> String {
176    if max_cols == 0 || sel_end <= sel_start {
177        return truncate_to_width_sgr_aware(line, max_cols);
178    }
179    let mut out = String::with_capacity(line.len() + 16);
180    let mut cols = 0usize;
181    let mut in_sel = false;
182    let mut iter = line.chars().peekable();
183    while let Some(c) = iter.next() {
184        if c == '\x1b' && iter.peek() == Some(&'[') {
185            // Capture the full CSI span first so we can decide whether
186            // to drop it (inside selection) or keep it (outside).
187            let mut csi = String::with_capacity(8);
188            csi.push(c);
189            csi.push(iter.next().unwrap());
190            for nc in iter.by_ref() {
191                csi.push(nc);
192                if nc.is_ascii_alphabetic() {
193                    break;
194                }
195            }
196            if !in_sel {
197                out.push_str(&csi);
198            }
199            continue;
200        }
201        let w = UnicodeWidthChar::width(c).unwrap_or(0);
202        if cols >= max_cols {
203            break;
204        }
205        let want_in_sel = cols >= sel_start && cols < sel_end;
206        if want_in_sel && !in_sel {
207            // Reset existing colours then enable reverse video so the
208            // selection highlight is visually consistent regardless of
209            // the underlying line styling.
210            out.push_str("\x1b[0m\x1b[7m");
211            in_sel = true;
212        } else if !want_in_sel && in_sel {
213            out.push_str("\x1b[0m");
214            in_sel = false;
215        }
216        if cols + w > max_cols {
217            break;
218        }
219        out.push(c);
220        cols += w;
221    }
222    if in_sel {
223        out.push_str("\x1b[0m");
224    }
225    out
226}
227
228/// Extract the plain-text characters of `line` whose display column
229/// falls in `[sel_start, sel_end)`, dropping all CSI escapes. Used by
230/// `extract_selection_text` to assemble what gets written to the
231/// clipboard. Wide-char rule matches `render_line_with_selection`.
232fn extract_line_selection_text(
233    line: &str,
234    sel_start: usize,
235    sel_end: usize,
236) -> String {
237    if sel_end <= sel_start {
238        return String::new();
239    }
240    let mut out = String::new();
241    let mut cols = 0usize;
242    let mut iter = line.chars().peekable();
243    while let Some(c) = iter.next() {
244        if c == '\x1b' && iter.peek() == Some(&'[') {
245            iter.next(); // `[`
246            for nc in iter.by_ref() {
247                if nc.is_ascii_alphabetic() {
248                    break;
249                }
250            }
251            continue;
252        }
253        let w = UnicodeWidthChar::width(c).unwrap_or(0);
254        if cols >= sel_end {
255            break;
256        }
257        if cols >= sel_start {
258            out.push(c);
259        }
260        cols += w;
261    }
262    out
263}
264
265/// Standard-alphabet base64 encoder. Inline implementation (~30 lines)
266/// instead of pulling in the `base64` crate just for OSC 52: the
267/// payload is one user-selected text blob per drag-release, kilobytes
268/// at most, and the alphabet is fixed.
269fn base64_encode(input: &[u8]) -> String {
270    const ALPHA: &[u8; 64] =
271        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
272    let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
273    let mut chunks = input.chunks_exact(3);
274    for chunk in &mut chunks {
275        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
276        out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
277        out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
278        out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
279        out.push(ALPHA[(n & 0x3f) as usize] as char);
280    }
281    let rem = chunks.remainder();
282    match rem.len() {
283        0 => {}
284        1 => {
285            let n = (rem[0] as u32) << 16;
286            out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
287            out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
288            out.push('=');
289            out.push('=');
290        }
291        2 => {
292            let n = ((rem[0] as u32) << 16) | ((rem[1] as u32) << 8);
293            out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
294            out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
295            out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
296            out.push('=');
297        }
298        _ => unreachable!(),
299    }
300    out
301}
302
303// SGR sequences used inline in body strings. Same set PlainRenderer
304// already uses; keeping them duplicated rather than re-exported because
305// alt_screen will diverge from plain on more dimensions in later phases
306// and shared constants would create a noisy upstream-change footprint.
307const SGR_RESET: &str = "\x1b[0m";
308const SGR_RED: &str = "\x1b[91m";
309const SGR_GREEN: &str = "\x1b[92m";
310const SGR_MAGENTA: &str = "\x1b[95m"; // Role::Brand — see render/theme.rs
311const SGR_CYAN: &str = "\x1b[96m"; // Role::Border / Accent — bright variant; the
312                                   // dim 36m form rendered the input-box rule
313                                   // as visibly "dashed" on Windows Terminal
314                                   // because the muted cyan let font-glyph
315                                   // gaps in `─` show through. Bright cyan
316                                   // matches retained's `Palette::BORDER`
317                                   // (Color::Cyan ≡ SGR 96 in crossterm) and
318                                   // closes the cross-renderer drift.
319const SGR_DIM: &str = "\x1b[2m";
320const SGR_GREY: &str = "\x1b[90m"; // Role::Muted — bright black / mid-gray.
321                                   // Prefer over SGR 2m on Windows conhost
322                                   // (< 1809 historically swallowed dim);
323                                   // matches retained's `Palette::MUTED`
324                                   // which crossterm emits as SGR 90.
325const SGR_BOLD: &str = "\x1b[1m";
326const SGR_YELLOW: &str = "\x1b[93m";
327/// Reverse video — swap fg/bg. Combined with a coloured fg this paints
328/// a coloured "chip" with the underlying default-bg as the chip's text
329/// colour. Used by the approval-prompt Y/A/N badges.
330const SGR_REVERSE: &str = "\x1b[7m";
331
332/// Default cap on `body_lines` length. ~5000 rows × ~200 bytes/row
333/// (rough average for SGR-decorated text) is ~1 MB per session — fine
334/// for our tier. Override via `ATOMCODE_SCROLLBACK_ROWS`.
335const DEFAULT_SCROLLBACK_ROWS: usize = 5000;
336
337fn scrollback_rows_from_env() -> usize {
338    std::env::var("ATOMCODE_SCROLLBACK_ROWS")
339        .ok()
340        .and_then(|v| v.parse::<usize>().ok())
341        .filter(|&n| n >= 100)
342        .unwrap_or(DEFAULT_SCROLLBACK_ROWS)
343}
344
345/// Alt-screen anchored renderer. See module-level doc.
346pub struct AltScreenRenderer<W: Write + Send> {
347    out: W,
348    caps: TerminalCaps,
349    /// True iff we successfully entered the alt-screen on construction.
350    /// Drop pops only when this is true so a failed enter doesn't try
351    /// to pop a buffer we never owned.
352    alt_screen_active: bool,
353    /// Saved Win32 console-input mode captured when we flipped the
354    /// mouse-capture bits. `Drop` / `leave_alt_screen` write this back
355    /// so the parent shell gets its quick-edit / line-input state
356    /// returned exactly as it was, not approximated. `None` means we
357    /// never successfully read the original (e.g. stdin not a console)
358    /// — in that case we don't try to restore. Windows-only because
359    /// other platforms route mouse capture through VT escape codes.
360    #[cfg(windows)]
361    prior_console_in_mode: Option<u32>,
362    /// Cached width / height. Updated by resize in Phase 4.
363    width: u16,
364    height: u16,
365    /// All body rows ever pushed, oldest-first. Each row is a single
366    /// physical line of text (with embedded SGR colour escapes).
367    /// `paint_body` paints a slice of this against the current viewport;
368    /// no terminal-side scrollback is involved (alt-screen owns the
369    /// whole viewport, host terminal's scrollback is unreachable).
370    body_lines: Vec<String>,
371    /// Raw (unwrapped) body rows — mirrors `body_lines` but stores each
372    /// logical line *before* soft-wrapping. Used by `reflow_body_lines`
373    /// on resize so that widening the terminal re-merges previously
374    /// split short rows back into their original long form. Each entry
375    /// corresponds 1:1 with one call to `push_body_row_raw`; rows that
376    /// were already short enough to not need wrapping appear identically
377    /// in both `raw_body_lines` and `body_lines`.
378    raw_body_lines: Vec<String>,
379    /// Index into `body_lines` for the FIRST visible body row. Auto-
380    /// tracks the tail when `sticky_bottom` is true (most common case);
381    /// only diverges from "tail" when the user is actively scrolled up
382    /// via PageUp / Home / scroll_body.
383    viewport_top: usize,
384    /// True iff the user is at the bottom of body_lines. New content
385    /// auto-scrolls when true; held position when false. Toggled by
386    /// scroll_body / scroll_body_to_top / scroll_body_to_bottom.
387    sticky_bottom: bool,
388    /// Bound on body_lines length. Front rows drop when exceeded so
389    /// memory stays flat for very long sessions.
390    max_scrollback_rows: usize,
391    /// Line-buffer for streaming assistant text. Chunks accumulate
392    /// here until `\n` or `AssistantLineBreak`; the completed line
393    /// is then run through the markdown renderer and pushed to
394    /// `body_lines` as one entry.
395    assistant_line_buf: String,
396    /// Markdown parser state (code-block tracking, table buffering)
397    /// shared across consecutive assistant lines so a fenced code
398    /// block opened on one chunk stays open on the next. Reset on
399    /// every new `UiLine::User` (new turn) so a previous turn's
400    /// stuck-open fence doesn't bleed into the user's prompt.
401    md_state: crate::markdown::MdState,
402    /// True when widget state has changed since the last body paint.
403    /// Set on every `push_body_row`; cleared by `paint_body`. Reduces
404    /// redundant repaints when one render() call pushes multiple rows
405    /// (e.g. TurnSeparator's three rows or DiffBlock's many).
406    body_dirty: bool,
407    // ── Phase 3+: footer ──
408    /// Most-recent input prompt state — `(buf, cursor_byte)`. Kept so
409    /// `paint_footer` can re-render the input row even when triggered
410    /// by a non-InputPrompt event (e.g. a body push during streaming
411    /// would otherwise leave a stale input row from before).
412    pending_input: Option<(String, usize)>,
413    /// Most-recent status line. Pulled from `UiLine::InputPrompt` /
414    /// `UiLine::StreamingBox`. Default-initialised so paint_footer can
415    /// always render *something* (empty string) before the first
416    /// InputPrompt arrives.
417    pending_status: StatusLine,
418    /// Active spinner state — `(frame, label)`. `Some` during streaming
419    /// (paint shows it ABOVE the input row); `None` resumes the plain
420    /// input prompt. Toggled by `Spinner` / `StreamingBox` /
421    /// `ClearTransient`.
422    pending_spinner: Option<(&'static str, String)>,
423    /// Slash-command palette items + selected index. Carried through
424    /// from `UiLine::InputPrompt` / `UiLine::StreamingBox`'s `menu`
425    /// field. None → no menu paint. Up to 4 items shown at once;
426    /// pagination around `selected` when there are more.
427    pending_menu: Option<MenuPayload>,
428    /// Image-attachment marker numbers currently visible inside the
429    /// input buffer (intersection of typed `[Image #N]` literals with
430    /// real pending bytes — see `event_loop::compute_input_attachments`).
431    /// Each gets a `└ [Image #N]` preview row rendered between the
432    /// bot_rule and the menu, mirroring the retained renderer.
433    pending_attachments: Vec<usize>,
434    /// True when footer state changed since the last paint. Same role
435    /// as `body_dirty` but for the footer strip.
436    footer_dirty: bool,
437    /// Active mouse-drag selection, or completed selection still
438    /// visible until the next interaction. `anchor` is the press
439    /// point, `head` is the current drag (or release) point. Both
440    /// reference `body_lines` directly: `(line_idx, display_col)` —
441    /// so a viewport scroll doesn't desync the selection from its
442    /// underlying text. None means no selection rendered. Cleared
443    /// on `reset` / `clear_screen` / `on_resize` since each can
444    /// invalidate either the line indices (reset) or the display
445    /// columns (resize → re-flow at paint time).
446    selection: Option<Selection>,
447    /// True only between `begin_selection` and `end_selection`. Used
448    /// to gate `update_selection` so a stray drag event after the
449    /// user already released doesn't move a stale selection. Some
450    /// terminals (notably JediTerm) emit a final coalesced motion
451    /// event right after Up; without this flag that event would
452    /// shift `head` to wherever the cursor was when the buffered
453    /// frame arrived.
454    selection_active: bool,
455    /// Tracks whether the terminal cursor is currently shown (`?25h`
456    /// last emitted) or hidden (`?25l`). Used to dedupe visibility
457    /// toggles per frame: re-emitting `?25h` at streaming framerate
458    /// restarts the host terminal's hardware cursor blink animation,
459    /// which on macOS Terminal.app reads as constant flicker even
460    /// after `?12l` disabled hardware blink (the show pulse itself is
461    /// the visible flash). Initialised to `true` because terminals
462    /// default to a visible cursor.
463    cursor_shown: bool,
464    /// True on terminals that process CUP sequences synchronously
465    /// (JediTerm, legacy conhost) — paint_body's per-row CUPs would
466    /// otherwise visibly trail the cursor through every body row.
467    /// On those we hide cursor before paint_body and re-show in
468    /// paint_footer's tail. False on fast terminals (macOS Terminal.app,
469    /// iTerm2, modern xterm, WezTerm, Kitty), where paint completes in
470    /// well under a frame and the per-frame hide/show toggle reads
471    /// instead as flicker — we leave cursor visible the whole time
472    /// and only reposition it via a CUP at frame end.
473    slow_paint_terminal: bool,
474}
475
476/// Mouse-drag selection range. See `AltScreenRenderer::selection` for
477/// semantics.
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479struct Selection {
480    /// (body_line_idx, display_col) anchor — where the press landed.
481    anchor: (usize, usize),
482    /// (body_line_idx, display_col) head — current drag point.
483    /// Equal to anchor immediately after `begin_selection` (zero-
484    /// width selection); diverges as drag events extend the range.
485    head: (usize, usize),
486}
487
488impl Selection {
489    /// Return (low, high) where `low <= high` lexicographically. Used
490    /// when computing per-line column ranges so paint and copy don't
491    /// have to care which way the user dragged.
492    fn ordered(&self) -> ((usize, usize), (usize, usize)) {
493        if self.anchor <= self.head {
494            (self.anchor, self.head)
495        } else {
496            (self.head, self.anchor)
497        }
498    }
499}
500
501impl AltScreenRenderer<BufWriter<Stdout>> {
502    pub fn new(caps: TerminalCaps, slow_paint_terminal: bool) -> Self {
503        let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
504        let mut r = Self::with_writer(BufWriter::new(io::stdout()), caps, w, h);
505        r.slow_paint_terminal = slow_paint_terminal;
506        r
507    }
508}
509
510/// Read STD_INPUT_HANDLE's current console mode, OR-in the bits required
511/// for mouse-event delivery on conhost, AND-out `ENABLE_QUICK_EDIT_MODE`,
512/// and write the result back. Returns the original mode on success so
513/// `leave_alt_screen` can restore it byte-for-byte; returns `None` if
514/// either GetConsoleMode or SetConsoleMode fails (typically: stdin was
515/// redirected and isn't a console handle, e.g. running under a pipe).
516///
517/// All results are mirrored to `tuix_trace!` (gated on
518/// `ATOMCODE_TUIX_LOG`) so a "wheel still doesn't work" report shows
519/// exactly which syscall returned what mask.
520#[cfg(windows)]
521fn enable_conhost_mouse_capture() -> Option<u32> {
522    use windows_sys::Win32::System::Console::{
523        GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_EXTENDED_FLAGS,
524        ENABLE_MOUSE_INPUT, ENABLE_QUICK_EDIT_MODE, ENABLE_WINDOW_INPUT, STD_INPUT_HANDLE,
525    };
526    unsafe {
527        let h = GetStdHandle(STD_INPUT_HANDLE);
528        // GetStdHandle returns INVALID_HANDLE_VALUE (`!0 as HANDLE`) on
529        // failure; on Windows that's `-1isize as *mut c_void`. Treat
530        // null and "all bits set" as failure shapes.
531        if h.is_null() || h as isize == -1 {
532            crate::tuix_trace!("REN", "conhost-mouse: GetStdHandle returned invalid");
533            return None;
534        }
535        let mut original: u32 = 0;
536        if GetConsoleMode(h, &mut original) == 0 {
537            let err = std::io::Error::last_os_error();
538            crate::tuix_trace!("REN", "conhost-mouse: GetConsoleMode failed: {}", err);
539            return None;
540        }
541        let new_mode = (original | ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT)
542            & !ENABLE_QUICK_EDIT_MODE;
543        if SetConsoleMode(h, new_mode) == 0 {
544            let err = std::io::Error::last_os_error();
545            crate::tuix_trace!(
546                "REN",
547                "conhost-mouse: SetConsoleMode(0x{:08x}) failed: {}",
548                new_mode,
549                err
550            );
551            return None;
552        }
553        crate::tuix_trace!(
554            "REN",
555            "conhost-mouse: ok prev=0x{:08x} new=0x{:08x}",
556            original,
557            new_mode
558        );
559        Some(original)
560    }
561}
562
563/// Restore STD_INPUT_HANDLE's console mode to the value `enable_conhost_
564/// mouse_capture` returned. Best-effort — failure here just means the
565/// shell mode bits drift slightly on exit; better than aborting.
566#[cfg(windows)]
567fn restore_conhost_console_in_mode(prior: u32) {
568    use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE};
569    unsafe {
570        let h = GetStdHandle(STD_INPUT_HANDLE);
571        if h.is_null() || h as isize == -1 {
572            return;
573        }
574        let _ = SetConsoleMode(h, prior);
575    }
576}
577
578impl<W: Write + Send> AltScreenRenderer<W> {
579    pub fn with_writer(out: W, caps: TerminalCaps, w: u16, h: u16) -> Self {
580        let mut r = Self {
581            out,
582            caps,
583            alt_screen_active: false,
584            #[cfg(windows)]
585            prior_console_in_mode: None,
586            width: w,
587            height: h,
588            body_lines: Vec::new(),
589            raw_body_lines: Vec::new(),
590            viewport_top: 0,
591            sticky_bottom: true,
592            max_scrollback_rows: scrollback_rows_from_env(),
593            assistant_line_buf: String::new(),
594            md_state: crate::markdown::MdState::new(),
595            body_dirty: false,
596            pending_input: None,
597            pending_status: StatusLine::default(),
598            pending_spinner: None,
599            pending_menu: None,
600            pending_attachments: Vec::new(),
601            footer_dirty: true,
602            selection: None,
603            selection_active: false,
604            cursor_shown: true,
605            slow_paint_terminal: false,
606        };
607        r.enter_alt_screen();
608        r
609    }
610
611    /// Number of menu rows to paint. Capped at 4 (matches retained's
612    /// pagination) so a 50-command match list doesn't squeeze body
613    /// content off the screen.
614    fn menu_paint_rows(&self) -> u16 {
615        self.pending_menu
616            .as_ref()
617            .map(|m| m.items.len().min(4) as u16)
618            .unwrap_or(0)
619    }
620
621    /// Total rows reserved for the footer. Variable because the
622    /// slash-menu palette + attachment preview rows grow / shrink the
623    /// footer dynamically:
624    ///   spinner (1) + top_rule (1) + input (1) + bot_rule (1)
625    ///   + attachments (0..N) + menu (0..4) + status (1) = 5..N+9
626    fn footer_rows(&self) -> u16 {
627        // spinner + top_rule + input + bot_rule + status = 5 base
628        5 + self.menu_paint_rows() + self.pending_attachments.len() as u16
629    }
630
631    /// Body region height = total rows − footer rows. Always at least 1
632    /// so `paint_body` never tries to write to row 0 / row N+ on tiny
633    /// terminals. When the terminal is so short the footer wouldn't fit,
634    /// we degrade to body_height=1 and the footer overflows the bottom —
635    /// visually broken but not crashing.
636    fn body_height(&self) -> u16 {
637        self.height.saturating_sub(self.footer_rows()).max(1)
638    }
639
640    /// Switch to alt-screen, home cursor, clear it, enable mouse
641    /// capture. Sequences:
642    ///   * `\x1b[?1049h` — save main screen + switch to alt
643    ///   * `\x1b[H\x1b[2J` — home cursor + clear screen
644    ///   * `\x1b[?1002h` — button-event tracking: report button
645    ///     presses, releases, AND motion-while-button-held. This is
646    ///     a strict superset of `?1000h` (which only reports presses)
647    ///     and is what we need so drag-selection sees per-cell motion
648    ///     instead of just the down + up endpoints. Scroll-wheel
649    ///     events (buttons 4/5) ride the same channel and are
650    ///     unaffected by the upgrade.
651    ///   * `\x1b[?1006h` — SGR-extended coordinates (replaces the
652    ///     legacy fixed-byte format that breaks past col 223)
653    ///   * `\x1b[?12l` — disable cursor blinking. macOS Terminal.app's
654    ///     hardware blink restarts on every show-cursor (`\x1b[?25h`),
655    ///     so paint_frame's hide→repaint→show cycle (one per keystroke)
656    ///     looked like a non-stop flicker. Restored to `?12h` on leave.
657    ///
658    /// Best-effort: if the writer fails, `alt_screen_active` stays
659    /// false and Drop won't try to pop.
660    fn enter_alt_screen(&mut self) {
661        let seq = "\x1b[?1049h\x1b[H\x1b[2J\x1b[?1002h\x1b[?1006h\x1b[?12l";
662        if self.out.write_all(seq.as_bytes()).is_ok() && self.out.flush().is_ok() {
663            self.alt_screen_active = true;
664            // Legacy Windows conhost (Win10 PowerShell 5/7, cmd.exe)
665            // does NOT implement the VT mouse-mode toggles above —
666            // `?1002h` / `?1006h` parse as no-ops. Mouse events only
667            // flow when `ENABLE_MOUSE_INPUT` is set on the console
668            // input handle via `SetConsoleMode`, AND when
669            // `ENABLE_QUICK_EDIT_MODE` is cleared (otherwise conhost
670            // intercepts mouse for text-selection and never delivers
671            // events to the program — wheel ticks included on some
672            // versions).
673            //
674            // We previously routed this through crossterm's
675            // `EnableMouseCapture`. Field reports (Win10 PS7) showed
676            // wheel still didn't work even after that fix shipped.
677            // crossterm's Windows path calls `set_mode(ENABLE_MOUSE_
678            // INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT)` —
679            // an OVERWRITE of the entire mode. That:
680            //   1. drops `ENABLE_VIRTUAL_TERMINAL_INPUT` and any
681            //      other bits raw_mode set up,
682            //   2. doesn't surface SetConsoleMode failures (we
683            //      `let _ =` the result),
684            //   3. relies on `ENABLE_QUICK_EDIT_MODE` being clearable
685            //      via implicit-absent semantics in the new mask,
686            //      which works on most conhost builds but isn't the
687            //      shape Microsoft's own samples use.
688            //
689            // Switch to read-modify-write through windows-sys: read
690            // the current mode, OR in the mouse bits, AND-out
691            // `ENABLE_QUICK_EDIT_MODE` explicitly, write back. Save
692            // the original for `leave_alt_screen` so the parent shell
693            // gets its mode restored exactly. Surface the
694            // GetConsoleMode/SetConsoleMode return codes via the
695            // trace log so a "still doesn't work" report tells us
696            // immediately whether the syscalls even succeeded.
697            #[cfg(windows)]
698            {
699                self.prior_console_in_mode = enable_conhost_mouse_capture();
700            }
701        }
702    }
703
704    /// Pop the alt-screen + disable mouse capture, restoring whatever
705    /// was on the main screen before we entered. Called from
706    /// `shutdown()` on normal exit and from `Drop` as belt-and-
707    /// suspenders for panic paths. Sequences mirror the reverse of
708    /// the enter set.
709    fn leave_alt_screen(&mut self) {
710        if self.alt_screen_active {
711            // Disable mouse capture FIRST — if alt-screen pops while
712            // mouse mode is still on, some terminals leak `\x1b[<...M`
713            // events into the main screen until something resets them.
714            // On Windows we additionally restore the exact pre-enter
715            // SetConsoleMode bitmask we saved in `enter_alt_screen`,
716            // so the parent shell gets its quick-edit / line-input
717            // flags back as they were (not "approximated" by
718            // crossterm's saved-original snapshot).
719            #[cfg(windows)]
720            {
721                if let Some(prior) = self.prior_console_in_mode.take() {
722                    restore_conhost_console_in_mode(prior);
723                }
724            }
725            let _ = self.out.write_all(b"\x1b[?25h\x1b[?12h\x1b[?1006l\x1b[?1002l\x1b[?1049l");
726            let _ = self.out.flush();
727            self.alt_screen_active = false;
728        }
729    }
730
731    /// Append one row to body_lines, drop oldest if we'd exceed the
732    /// scrollback cap, mark body dirty for the next paint. The single
733    /// entry point so cap enforcement and dirty tracking can't be
734    /// forgotten by individual UiLine arms.
735    fn push_body_row(&mut self, row: String) {
736        self.body_lines.push(row);
737        // Bound the buffer. Drop from the front so the most-recent
738        // content is preserved (the typical case is the user scrolled
739        // to bottom; oldest content is least relevant).
740        while self.body_lines.len() > self.max_scrollback_rows {
741            self.body_lines.remove(0);
742        }
743        self.body_dirty = true;
744    }
745
746    /// Push a **raw** (not yet soft-wrapped) body row. The raw line is
747    /// stored in `raw_body_lines` for later re-flow on resize, while
748    /// the soft-wrapped chunks are pushed to `body_lines` for immediate
749    /// rendering. Callers that produce logical lines longer than the
750    /// terminal width should use this instead of `push_body_row` so
751    /// `on_resize` can re-wrap at the new width without losing content.
752    fn push_body_row_raw(&mut self, raw_line: String) {
753        // Store raw line for re-flow on resize.
754        self.raw_body_lines.push(raw_line.clone());
755        // Soft-wrap at the current terminal width and push each chunk.
756        let max_w = self.width as usize;
757        for chunk in wrap_to_width_sgr_aware(&raw_line, max_w) {
758            self.push_body_row(chunk);
759        }
760    }
761
762    /// Re-flow `body_lines` from `raw_body_lines` at the current
763    /// terminal width. Called by `on_resize` so that widening the
764    /// terminal re-merges previously split short rows back into fewer
765    /// longer rows, and narrowing splits long rows instead of
766    /// truncating them (issue #363).
767    fn reflow_body_lines(&mut self) {
768        self.body_lines.clear();
769        let max_w = self.width as usize;
770        for raw in &self.raw_body_lines {
771            for chunk in wrap_to_width_sgr_aware(raw, max_w) {
772                self.body_lines.push(chunk);
773            }
774        }
775        // Bound the buffer (same cap as push_body_row).
776        while self.body_lines.len() > self.max_scrollback_rows {
777            self.body_lines.remove(0);
778        }
779        // Also bound raw_body_lines to the same cap so the two buffers
780        // don't drift over time.
781        while self.raw_body_lines.len() > self.max_scrollback_rows {
782            self.raw_body_lines.remove(0);
783        }
784    }
785
786    /// Render the current state of body_lines into the viewport area.
787    /// Phase 2 paints all visible rows on every dirty frame (no
788    /// cell-diff against previous frame yet — full repaint per render
789    /// call is fine at our event cadence). Cell-diff is a Phase 5+
790    /// optimization for terminals where ANSI throughput matters.
791    ///
792    /// Visible window: `body_lines[viewport_start .. viewport_start + body_height]`
793    /// where `viewport_start` honours `sticky_bottom` (auto-tail) when
794    /// set, otherwise pins to `viewport_top` (Phase 3 keyboard
795    /// handlers).
796    ///
797    /// Empty rows below the body content (when body_lines is shorter
798    /// than the viewport, early in a session) are explicitly cleared
799    /// so a previous frame's content can't ghost.
800    fn paint_body(&mut self) {
801        if !self.body_dirty {
802            return;
803        }
804        // Phase 3: footer reserves bottom rows. body_height shrinks
805        // accordingly so the input box / status bar never get
806        // overwritten by body content.
807        let body_height = self.body_height() as usize;
808        let total = self.body_lines.len();
809
810        // sticky_bottom: viewport_start is "last body_height rows"; if
811        // body_lines is shorter than viewport, just start at 0 and
812        // leave the bottom blank.
813        let viewport_start = if self.sticky_bottom {
814            total.saturating_sub(body_height)
815        } else {
816            self.viewport_top.min(total.saturating_sub(body_height))
817        };
818
819        // Walk every row in the visible window. CUP each row, EL to
820        // wipe leftover glyphs from previous frames, then write the
821        // body content (trimmed to terminal width and SGR-terminated
822        // so long lines don't autowrap into the next body row's slot
823        // and stale colour spans don't bleed). For rows past the end
824        // of body_lines, just EL (clear). 1-indexed rows.
825        let max_cols = self.width as usize;
826        // Snapshot the ordered selection bounds once so the per-row
827        // loop doesn't re-borrow `self.selection` while we hold a
828        // reference to `self.body_lines[i]`. Cheap (Copy) and only
829        // computed when a selection exists.
830        let sel_bounds = self.selection.as_ref().map(|s| s.ordered());
831        for row_idx in 0..body_height {
832            let abs_row = (row_idx + 1) as u16;
833            let cup_el = format!("\x1b[{};1H\x1b[K", abs_row);
834            let _ = self.out.write_all(cup_el.as_bytes());
835            let body_idx = viewport_start + row_idx;
836            if body_idx < total {
837                let line = &self.body_lines[body_idx];
838                // SGR-aware: CSI escape sequences (`\x1b[...m`) take
839                // zero visible columns and are passed through verbatim.
840                // Without this, the `[`, digits, and final `m` of each
841                // SGR pair eat into the visible-content budget — a
842                // 80-col line with one colour span would lose 5+
843                // trailing visible chars.
844                let painted = match sel_bounds.and_then(|(lo, hi)| {
845                    selection_col_range_for_line(body_idx, lo, hi, line)
846                }) {
847                    Some((s, e)) => render_line_with_selection(line, max_cols, s, e),
848                    None => truncate_to_width_sgr_aware(line, max_cols),
849                };
850                let _ = self.out.write_all(painted.as_bytes());
851                // Trailing SGR reset: in case the row had an open SGR
852                // span at the truncation point (e.g. `\x1b[31mlong red
853                // text...` cut mid-span), reset so the next row's
854                // CUP+EL doesn't paint over already-coloured cells.
855                // Cheap belt-and-suspenders — 4 bytes per row.
856                let _ = self.out.write_all(b"\x1b[0m");
857            }
858        }
859        // No flush here: paint_frame batches body + footer +
860        // anchor_cursor_to_input into a single flush at the very
861        // end so the terminal renders only the final cursor
862        // position. Flushing mid-frame (after the per-row CUPs
863        // walked the cursor through every body row) gave macOS
864        // Terminal.app a vsync window to draw the cursor at
865        // intermediate body positions before anchor moved it
866        // back, which read as a cursor "blinking" mid-screen
867        // during streaming. Tests call `r.flush()` explicitly
868        // after `r.paint_body()`.
869        self.body_dirty = false;
870    }
871
872    /// Map a screen-cell `(col, row)` (0-indexed) to a body-line
873    /// position `(line_idx, display_col)`. Returns `None` when the
874    /// row falls past the last body line (footer area, or the empty
875    /// strip below content in early-session views) — used by
876    /// `begin_selection` to refuse to anchor a selection in the
877    /// footer. `update_selection` calls `screen_to_body_clamped`
878    /// instead so dragging past the body still extends the head.
879    fn screen_to_body(&self, col: u16, row: u16) -> Option<(usize, usize)> {
880        let body_height = self.body_height() as usize;
881        if (row as usize) >= body_height {
882            return None;
883        }
884        let total = self.body_lines.len();
885        if total == 0 {
886            return None;
887        }
888        let viewport_start = if self.sticky_bottom {
889            total.saturating_sub(body_height)
890        } else {
891            self.viewport_top.min(total.saturating_sub(body_height))
892        };
893        let line_idx = viewport_start + row as usize;
894        if line_idx >= total {
895            return None;
896        }
897        Some((line_idx, col as usize))
898    }
899
900    /// Same as `screen_to_body` but clamps `(col, row)` to the
901    /// nearest valid body cell instead of returning `None`. Used by
902    /// `update_selection` so a drag that overshoots into the footer
903    /// or past the last row still extends the head sensibly.
904    fn screen_to_body_clamped(&self, col: u16, row: u16) -> Option<(usize, usize)> {
905        let body_height = self.body_height() as usize;
906        let total = self.body_lines.len();
907        if total == 0 {
908            return None;
909        }
910        let viewport_start = if self.sticky_bottom {
911            total.saturating_sub(body_height)
912        } else {
913            self.viewport_top.min(total.saturating_sub(body_height))
914        };
915        let row_clamped = (row as usize).min(body_height.saturating_sub(1));
916        let line_idx = (viewport_start + row_clamped).min(total.saturating_sub(1));
917        Some((line_idx, col as usize))
918    }
919
920    /// Walk the active selection from `start.line` to `end.line` (both
921    /// inclusive) and return the concatenated plain text — CSI escapes
922    /// stripped, lines joined with `\n`. Returns an empty string when
923    /// no selection or when the selection covers no visible chars
924    /// (e.g. clicked past end-of-line on a single-line selection).
925    fn extract_selection_text(&self) -> String {
926        let Some(sel) = self.selection else {
927            return String::new();
928        };
929        let (lo, hi) = sel.ordered();
930        let total = self.body_lines.len();
931        if lo.0 >= total {
932            return String::new();
933        }
934        let mut parts = Vec::with_capacity(hi.0 - lo.0 + 1);
935        for line_idx in lo.0..=hi.0.min(total - 1) {
936            let line = &self.body_lines[line_idx];
937            let Some((s, e)) =
938                selection_col_range_for_line(line_idx, lo, hi, line)
939            else {
940                parts.push(String::new());
941                continue;
942            };
943            parts.push(extract_line_selection_text(line, s, e));
944        }
945        parts.join("\n")
946    }
947
948    /// Emit OSC 52 (`\x1b]52;c;<base64>\x07`) carrying `text` so the
949    /// host terminal copies it to the system clipboard. Empty text is
950    /// a no-op to avoid clearing whatever the user previously had.
951    /// Best-effort — terminals that don't honour OSC 52 (Terminal.app
952    /// without explicit opt-in) silently ignore the sequence.
953    fn write_osc52_clipboard(&mut self, text: &str) {
954        if text.is_empty() {
955            return;
956        }
957        let encoded = base64_encode(text.as_bytes());
958        let _ = write!(self.out, "\x1b]52;c;{}\x07", encoded);
959        let _ = self.out.flush();
960    }
961
962    /// Paint the footer strip. Layout (top to bottom, 1-indexed rows
963    /// computed from the bottom of the viewport):
964    ///   spinner       (1 row, blank when no streaming)
965    ///   top rule      (1 row, full-width cyan ─)
966    ///   input         (1 row, `❯ {buf}` flush-left)
967    ///   bot rule      (1 row, full-width cyan ─)
968    ///   menu items    (0..4 rows, when slash palette is active)
969    ///   status        (1 row, dim `model · cwd`)
970    ///
971    /// Mirrors retained's footer shape (see `RetainedRenderer::paint_footer`)
972    /// minus the wrapped multi-line input — alt-screen Phase 4 keeps
973    /// input single-line; multi-line input is a Phase 5+ enhancement.
974    fn paint_footer(&mut self) {
975        if !self.footer_dirty {
976            return;
977        }
978        let h = self.height;
979        let total_footer = self.footer_rows();
980        let footer_top = h.saturating_sub(total_footer) + 1; // 1-indexed
981        let menu_rows = self.menu_paint_rows();
982        let attachment_rows = self.pending_attachments.len() as u16;
983        let spinner_row = footer_top;
984        let top_rule_row = footer_top + 1;
985        let input_row = footer_top + 2;
986        let bot_rule_row = footer_top + 3;
987        // Attachment preview rows (`└ [Image #N]`) sit between the
988        // bot_rule and the menu — same slot the retained renderer uses
989        // (see `RetainedRenderer::paint_footer`). Count is variable so
990        // menu_first_row / status_row shift accordingly.
991        let attach_first_row = footer_top + 4;
992        let menu_first_row = footer_top + 4 + attachment_rows;
993        let status_row = footer_top + 4 + attachment_rows + menu_rows;
994
995        // Row 1 of footer: spinner during streaming, blank otherwise.
996        // Frame glyph in brand magenta (Role::Brand) supplies the
997        // visual anchor; label is bold + default-fg, mirroring
998        // retained's `style_bold(Role::Secondary)` in
999        // `build_spinner_body_row`. SGR_DIM was the prior choice but
1000        // rendered as hard-to-read mid-gray on Windows cmd (legacy
1001        // conhost <1809 swallowed the dim attribute, leaving the
1002        // label barely visible against the background).
1003        let cup = format!("\x1b[{};1H\x1b[K", spinner_row);
1004        let _ = self.out.write_all(cup.as_bytes());
1005        if let Some((frame, label)) = &self.pending_spinner {
1006            let cleaned = scrub_controls(label);
1007            let line = if self.caps.colors {
1008                format!(
1009                    "{}{}{} {}{}{}",
1010                    SGR_MAGENTA, frame, SGR_RESET, SGR_BOLD, cleaned, SGR_RESET
1011                )
1012            } else {
1013                format!("{} {}", frame, cleaned)
1014            };
1015            let _ = self.out.write_all(line.as_bytes());
1016        }
1017
1018        // Top rule: full-width cyan ━ above the input box. Mirrors
1019        // retained's `build_rule_row`.
1020        //
1021        // U+2501 (━ HEAVY HORIZONTAL) instead of U+2500 (─ LIGHT
1022        // HORIZONTAL): on legacy Windows conhost with the default
1023        // Consolas / Lucida Console fonts the light variant renders
1024        // with visible vertical gaps between cells (the glyph stroke
1025        // doesn't span the full cell width), so the rule reads as a
1026        // dashed line even at full brightness. The heavy variant has
1027        // a thicker stroke that fills the cell, eliminating the
1028        // dashed look while still living in the same Box Drawing
1029        // block (every modern terminal + conhost-with-unicode-font
1030        // supports it). Bright cyan alone was insufficient — the
1031        // gap between glyphs persisted regardless of colour. See
1032        // commit fcf6a7e for the prior dim→bright attempt.
1033        //
1034        // No ASCII fallback: U+2501 is in WGL4, present on every
1035        // Windows monospace font (Consolas, NSimSun, Cascadia,
1036        // Microsoft YaHei). Falling back to `-` here on legacy conhost
1037        // produced a literal hyphen-dotted line that users read as
1038        // "broken/dashed border" — exactly what the heavy variant was
1039        // chosen to avoid.
1040        let rule = "\u{2501}".repeat(self.width as usize);
1041        let cup = format!("\x1b[{};1H\x1b[K", top_rule_row);
1042        let _ = self.out.write_all(cup.as_bytes());
1043        if self.caps.colors {
1044            let _ = write!(self.out, "{}{}{}", SGR_CYAN, rule, SGR_RESET);
1045        } else {
1046            let _ = self.out.write_all(rule.as_bytes());
1047        }
1048
1049        // Session-name pill overlay: ` {name} ` painted in reverse +
1050        // cyan (cyan bg, terminal default fg) over the right end of
1051        // the top rule. Mirrors CC's per-conversation badge so the
1052        // user can tell which session they're typing into at a
1053        // glance. Only emitted when `session_name` is Some — populated
1054        // by build_status iff `Session::user_renamed`, so auto-named
1055        // sessions stay badge-less.
1056        //
1057        // Layout budget:
1058        //   right_margin   = 2 cells (don't hug the rightmost column —
1059        //                    legacy conhost wraps when writing into the
1060        //                    last cell of a row)
1061        //   pill_padding   = 2 cells (one space each side of the name)
1062        //   min_rule_left  = 8 cells (keep enough ━ on the left so the
1063        //                    box still reads as bordered; without this
1064        //                    a very long name eats the entire rule and
1065        //                    the input box loses its visual anchor)
1066        // Available for the name: width - right_margin - pill_padding
1067        //                         - min_rule_left = width - 12.
1068        // If the terminal is too narrow for even 1 cell of name + the
1069        // surrounding chrome, skip the badge entirely.
1070        if let Some(name_raw) = self.pending_status.session_name.as_ref() {
1071            let name_scrubbed = scrub_controls(name_raw);
1072            const RIGHT_MARGIN: usize = 2;
1073            const PILL_PADDING: usize = 2;
1074            const MIN_RULE_LEFT: usize = 8;
1075            let total_w = self.width as usize;
1076            let chrome = RIGHT_MARGIN + PILL_PADDING + MIN_RULE_LEFT;
1077            if total_w > chrome {
1078                let max_name_w = total_w - chrome;
1079                let name_w = crate::width::display_width(&name_scrubbed);
1080                // Truncate with `…` when over budget. `…` is one cell;
1081                // `truncate_to_width(name, max_name_w - 1) + "…"` keeps
1082                // the total under max_name_w. If max_name_w == 1, just
1083                // emit a single `…` (no room for any name char).
1084                let name_for_pill = if name_w <= max_name_w {
1085                    name_scrubbed
1086                } else if max_name_w <= 1 {
1087                    "…".to_string()
1088                } else {
1089                    let truncated = crate::width::truncate_to_width(&name_scrubbed, max_name_w - 1);
1090                    format!("{}…", truncated)
1091                };
1092                let pill_content_w = crate::width::display_width(&name_for_pill) + PILL_PADDING;
1093                // Start column (1-indexed) so the pill ends RIGHT_MARGIN
1094                // cells from the rightmost column. e.g. width=80,
1095                // pill_content_w=12, margin=2 → start col = 80-2-12+1 = 67.
1096                let start_col = total_w.saturating_sub(RIGHT_MARGIN + pill_content_w) + 1;
1097                let cup = format!("\x1b[{};{}H", top_rule_row, start_col);
1098                let _ = self.out.write_all(cup.as_bytes());
1099                if self.caps.colors {
1100                    // SGR for the pill differs by theme. Dark: reverse +
1101                    // bright cyan (SGR 7;96) — bright cyan as background
1102                    // pops against the dark default fg. Light: bold +
1103                    // standard magenta (SGR 1;35), no reverse — standard
1104                    // magenta maps to a dark, readable shade on light
1105                    // profiles, where bright-cyan reverse turned into
1106                    // pale-aqua-on-white and the chip vanished into the
1107                    // surrounding background.
1108                    let sgr = if crate::highlight::theme::is_light_for_render() {
1109                        "\x1b[1;35m"
1110                    } else {
1111                        "\x1b[7;96m"
1112                    };
1113                    let _ = write!(self.out, "{} {} \x1b[0m", sgr, name_for_pill);
1114                } else {
1115                    // No colors: surround with spaces so the name stays
1116                    // legible against the ━ rule on either side.
1117                    let _ = write!(self.out, " {} ", name_for_pill);
1118                }
1119            }
1120        }
1121
1122        // Input row: `❯ {buf}` flush-left at col 0. matches retained's
1123        // `build_middle_row`.
1124        let cup = format!("\x1b[{};1H\x1b[K", input_row);
1125        let _ = self.out.write_all(cup.as_bytes());
1126        let chev = self.caps.prompt_chevron();
1127        let buf_str = self.pending_input.as_ref().map(|(b, _)| b.as_str()).unwrap_or("");
1128        let cursor_byte = self
1129            .pending_input
1130            .as_ref()
1131            .map(|(_, c)| (*c).min(buf_str.len()))
1132            .unwrap_or(0);
1133        // Show `\n` as a visible marker so users typing `\<Enter>` (the
1134        // line-continuation escape, used when Shift/Alt+Enter are
1135        // swallowed by the host terminal — typical on Windows
1136        // cmd.exe / legacy conhost without Kitty keyboard protocol)
1137        // get visual feedback that the newline was inserted.
1138        // Replacing with a plain space made the input box render
1139        // `abc def` regardless of whether the user typed a space or
1140        // `\<Enter>`, so users on Windows cmd reported "shift+enter
1141        // / alt+enter / \<Enter> 都无法换行" — they had no UI signal
1142        // that `\<Enter>` actually worked. `↵` (U+21B5) is one
1143        // display cell in modern fonts; ASCII fallback uses two
1144        // chars `\n` so the marker stays readable on legacy conhost
1145        // with NSimSun.
1146        let nl_marker = if self.caps.unicode_symbols {
1147            "↵"
1148        } else {
1149            "\\n"
1150        };
1151        let safe_buf = scrub_controls(buf_str).replace('\n', nl_marker);
1152        let max_cols = (self.width as usize).saturating_sub(chev.chars().count());
1153        // Display column of the cursor *within* `safe_buf`, computed
1154        // with the SAME `\n → nl_marker` substitution as the rendered
1155        // line. The previous implementation replaced `\n` with a single
1156        // space here while the rendered line used `\\n` (two cols on
1157        // legacy conhost without unicode-capable fonts), so every
1158        // newline in the buffer slid the cursor one column to the left
1159        // of where the user could see they were typing.
1160        let prefix_safe = scrub_controls(&buf_str[..cursor_byte]).replace('\n', nl_marker);
1161        let cursor_col_in_buf = display_width(&prefix_safe);
1162        // Horizontal scroll: when the user types past `max_cols` (or
1163        // moves the cursor past it), slide the visible window so the
1164        // cursor stays at the right edge instead of falling off.
1165        // Without this, `truncate_to_width(&safe_buf, max_cols)` kept
1166        // only the leading window and the user's recent typing simply
1167        // disappeared — they reported "input box gets too long, can't
1168        // see what I'm typing anymore". The window ends at the cursor
1169        // (cursor visible at the rightmost col); if the cursor is in
1170        // the early portion of the buffer, no scrolling kicks in and
1171        // we render the head as before.
1172        let (trimmed, visible_cursor_col) = if cursor_col_in_buf < max_cols {
1173            (truncate_to_width(&safe_buf, max_cols), cursor_col_in_buf)
1174        } else {
1175            let start_col = cursor_col_in_buf + 1 - max_cols;
1176            (
1177                crate::width::slice_cols(&safe_buf, start_col, max_cols),
1178                max_cols.saturating_sub(1),
1179            )
1180        };
1181        let input_line = if self.caps.colors {
1182            format!("{}{}{}{}", SGR_CYAN, chev, SGR_RESET, trimmed)
1183        } else {
1184            format!("{}{}", chev, trimmed)
1185        };
1186        let _ = self.out.write_all(input_line.as_bytes());
1187
1188        // Bottom rule: same as top rule.
1189        let cup = format!("\x1b[{};1H\x1b[K", bot_rule_row);
1190        let _ = self.out.write_all(cup.as_bytes());
1191        if self.caps.colors {
1192            let _ = write!(self.out, "{}{}{}", SGR_CYAN, rule, SGR_RESET);
1193        } else {
1194            let _ = self.out.write_all(rule.as_bytes());
1195        }
1196
1197        // Attachment preview rows — `└ [Image #N]` in dim/muted style.
1198        // Pre-filtered upstream (see `event_loop::compute_input_attachments`)
1199        // to only include marker numbers whose bytes are actually pending,
1200        // so showing a row is a real visual confirmation that an image is
1201        // attached (not just literal `[Image #N]` text the user typed).
1202        // Mirrors the post-submit muted echo of the same string in the
1203        // body, so users see a consistent look pre- and post-submit.
1204        for (i, n) in self.pending_attachments.iter().enumerate() {
1205            let row_n = attach_first_row + i as u16;
1206            let cup = format!("\x1b[{};1H\x1b[K", row_n);
1207            let _ = self.out.write_all(cup.as_bytes());
1208            let line = format!("  \u{2514} [Image #{}]", n);
1209            if self.caps.colors {
1210                let _ = write!(self.out, "{}{}{}", SGR_DIM, line, SGR_RESET);
1211            } else {
1212                let _ = self.out.write_all(line.as_bytes());
1213            }
1214        }
1215
1216        // Menu rows: 0..4 of `/{name}  {desc}`. Selected gets `▸` prefix
1217        // + reverse-video for visibility. Pagination around `selected`
1218        // (matches retained's 4-item viewport) so a 50-command match
1219        // list doesn't crowd the screen.
1220        if let Some(menu) = self.pending_menu.clone() {
1221            let len = menu.items.len();
1222            let offset = if len <= 4 {
1223                0
1224            } else if menu.selected < 4 {
1225                0
1226            } else {
1227                (menu.selected + 1).saturating_sub(4).min(len.saturating_sub(4))
1228            };
1229            let end = (offset + 4).min(len);
1230            for (i, (name, desc)) in menu.items[offset..end].iter().enumerate() {
1231                let row_n = menu_first_row + i as u16;
1232                let cup = format!("\x1b[{};1H\x1b[K", row_n);
1233                let _ = self.out.write_all(cup.as_bytes());
1234                let selected = (offset + i) == menu.selected;
1235                let safe_name = scrub_controls(name);
1236                let safe_desc = scrub_controls(desc);
1237                let body = match menu.kind {
1238                    crate::render::MenuKind::SlashCommand => {
1239                        // Pad by DISPLAY width, not char count: `/设为默认`
1240                        // (5 chars, 9 cells) needs the same description
1241                        // start column as `/添加` (3 chars, 5 cells). The
1242                        // previous `{:<12}` char-count padding left CJK
1243                        // rows two cells to the right of ASCII rows.
1244                        let name_width = unicode_width::UnicodeWidthStr::width(safe_name.as_str());
1245                        let pad = 12usize.saturating_sub(name_width);
1246                        let padded = format!("{}{}", safe_name, " ".repeat(pad));
1247                        if selected {
1248                            format!("▸ /{}  {}", padded, safe_desc)
1249                        } else {
1250                            format!("  /{}  {}", padded, safe_desc)
1251                        }
1252                    }
1253                    crate::render::MenuKind::AtMention => {
1254                        // No leading whitespace — `+` flush left.
1255                        if safe_desc.is_empty() {
1256                            format!("+ {}", safe_name)
1257                        } else {
1258                            format!("+ {}  {}", safe_name, safe_desc)
1259                        }
1260                    }
1261                };
1262                // Clamp to terminal width before write. Without this,
1263                // long descriptions (CJK glyphs are 2 display cells)
1264                // overflow and the terminal auto-wraps onto subsequent
1265                // rows. Single-row wrap is wiped by the next iteration's
1266                // CUP+EL, but a 2+ row wrap leaks past that recovery
1267                // and leaves stale glyphs in column 1+ of later menu
1268                // items — observed on plugin skill listings with very
1269                // long Chinese descriptions.
1270                let body = truncate_to_width(&body, self.width as usize);
1271                if self.caps.colors {
1272                    if selected {
1273                        // Reverse video on the selected row to make
1274                        // the keyboard focus highly visible.
1275                        let _ = write!(self.out, "\x1b[7m{}\x1b[0m", body);
1276                    } else {
1277                        let _ = write!(self.out, "{}{}{}", SGR_DIM, body, SGR_RESET);
1278                    }
1279                } else {
1280                    let _ = self.out.write_all(body.as_bytes());
1281                }
1282            }
1283        }
1284
1285        // Status row at the bottom: dim `model · cwd`, optionally
1286        // prefixed by a brand-colored `PLAN` mode badge so non-default
1287        // agent modes are visible at a glance (mirrors retained's
1288        // build_status_row treatment).
1289        let cup = format!("\x1b[{};1H\x1b[K", status_row);
1290        let _ = self.out.write_all(cup.as_bytes());
1291        let mode_badge = self
1292            .pending_status
1293            .mode_indicator
1294            .as_ref()
1295            .map(|s| scrub_controls(s));
1296        // Pre-truncate cwd so it does not overflow the terminal width.
1297        // Compute a budget for cwd that accounts for model name, " · "
1298        // separators, and mode badge — same logic as retained's
1299        // build_status_row.
1300        let model = scrub_controls(&self.pending_status.model);
1301        let cwd_full = scrub_controls(&self.pending_status.cwd);
1302        let mode_badge_w = mode_badge
1303            .as_ref()
1304            .map(|s| crate::width::display_width(s) + 1)
1305            .unwrap_or(0);
1306        let sep_w = if !model.is_empty() && !cwd_full.is_empty() { 3 } else { 0 };
1307        let left_max = (self.width as usize).saturating_sub(mode_badge_w);
1308        let cwd_budget = left_max
1309            .saturating_sub(crate::width::display_width(&model))
1310            .saturating_sub(sep_w);
1311        let cwd = if !cwd_full.is_empty() && cwd_budget > 0
1312            && crate::width::display_width(&cwd_full) > cwd_budget
1313        {
1314            crate::width::truncate_path(&cwd_full, cwd_budget)
1315        } else if !cwd_full.is_empty() && cwd_budget == 0 {
1316            crate::width::truncate_path(&cwd_full, left_max)
1317        } else {
1318            cwd_full
1319        };
1320        let status_text = if !model.is_empty() || !cwd.is_empty() {
1321            if model.is_empty() {
1322                format!("  {}", cwd)
1323            } else if cwd.is_empty() {
1324                format!("  {}", model)
1325            } else {
1326                format!("  {} \u{00b7} {}", model, cwd)
1327            }
1328        } else {
1329            String::new()
1330        };
1331        if mode_badge.is_some() || !status_text.is_empty() {
1332            // Badge gets brand-colored magenta (Role::Brand). Status
1333            // body keeps its faint/dim style. Color codes only emit
1334            // when the terminal advertises color support.
1335            if let Some(badge) = &mode_badge {
1336                if self.caps.colors {
1337                    let _ = write!(self.out, "  {}{}{} ", SGR_MAGENTA, badge, SGR_RESET);
1338                } else {
1339                    let _ = write!(self.out, "  {} ", badge);
1340                }
1341            }
1342            if !status_text.is_empty() {
1343                // status_text already includes its own leading 2-space pad
1344                // when no badge precedes it. With a badge we already
1345                // emitted the leading spaces + badge + space, so trim
1346                // the duplicate leading pad to keep alignment.
1347                let body = if mode_badge.is_some() {
1348                    status_text.trim_start_matches(' ').to_string()
1349                } else {
1350                    status_text
1351                };
1352                let line = if self.caps.colors {
1353                    format!("{}{}{}", SGR_DIM, body, SGR_RESET)
1354                } else {
1355                    body
1356                };
1357                let _ = self.out.write_all(line.as_bytes());
1358            }
1359        }
1360
1361        // Position the terminal cursor inside the input row so the
1362        // user sees where their typing will land. `visible_cursor_col`
1363        // is the cursor's column *within the visible window* — already
1364        // accounts for both the `\n → nl_marker` rendering and any
1365        // horizontal scroll (when the buffer overflowed `max_cols` and
1366        // we slid the window so the cursor stays at the right edge).
1367        // Adding `chev.chars().count()` skips past the prompt glyph;
1368        // the `+ 1` converts to the 1-indexed CSI CUP coordinate.
1369        if self.pending_input.is_some() {
1370            let cursor_col = chev.chars().count() + visible_cursor_col;
1371            if self.cursor_shown {
1372                // Cursor already visible from a prior frame — just
1373                // reposition it. Skipping the `?25h` re-emit avoids
1374                // restarting the host terminal's hardware cursor blink
1375                // animation, which on macOS Terminal.app at streaming
1376                // framerate reads as constant flicker.
1377                let cup = format!("\x1b[{};{}H", input_row, cursor_col + 1);
1378                let _ = self.out.write_all(cup.as_bytes());
1379            } else {
1380                let cup = format!("\x1b[{};{}H\x1b[?25h", input_row, cursor_col + 1);
1381                let _ = self.out.write_all(cup.as_bytes());
1382                self.cursor_shown = true;
1383            }
1384        } else if self.cursor_shown {
1385            let _ = self.out.write_all(b"\x1b[?25l");
1386            self.cursor_shown = false;
1387        }
1388
1389        // No flush here: paint_frame's tail (anchor_cursor_to_input)
1390        // is the single flush point for the whole frame. Flushing
1391        // here gave the terminal a vsync window between footer
1392        // writes and anchor's final CUP, briefly showing the cursor
1393        // at the end of the status row before it jumped to input.
1394        // Tests call `r.flush()` explicitly when invoking
1395        // `paint_footer` directly.
1396        self.footer_dirty = false;
1397    }
1398
1399    /// Combined frame paint: body first, footer second so the cursor
1400    /// final-position belongs to the footer (typically the input row).
1401    ///
1402    /// Cursor visibility handling depends on `slow_paint_terminal`:
1403    ///
1404    /// **Slow terminals (JediTerm, legacy conhost, `slow_paint_terminal=true`):**
1405    /// hide cursor before paint_body so its journey through every
1406    /// intermediate CUP isn't visible. paint_footer's tail re-emits
1407    /// show-cursor (`?25h`) at the final input-row position when
1408    /// `pending_input` is set. Without this, JediTerm rendered the
1409    /// cursor's trail as visible "jumping" — Android Studio bug.
1410    ///
1411    /// **Fast terminals (macOS Terminal.app / iTerm2 / xterm /
1412    /// WezTerm / Kitty, `slow_paint_terminal=false`):** leave cursor
1413    /// visible the whole time; just reposition via final CUP. The
1414    /// per-row CUPs DO still flash the cursor through body cells but
1415    /// they execute in well under a refresh interval so the trail is
1416    /// imperceptible. Avoiding the per-frame `?25l`/`?25h` toggle is
1417    /// what matters here — at streaming framerate (~30 Hz) that
1418    /// toggle reads as constant flicker on macOS Terminal.app even
1419    /// after `?12l` disabled the hardware cursor blink.
1420    fn paint_frame(&mut self) {
1421        if self.slow_paint_terminal && self.cursor_shown {
1422            let _ = self.out.write_all(b"\x1b[?25l");
1423            self.cursor_shown = false;
1424        }
1425        self.paint_body();
1426        self.paint_footer();
1427        // paint_body always leaves the cursor at the last body-row
1428        // it touched (CUP+EL+content per row); paint_footer's tail
1429        // only re-anchors the cursor when footer_dirty is true, so
1430        // a body-only render (e.g. async MCP "已连接" CommandOutput
1431        // after startup) leaves the visible cursor stranded mid-body
1432        // far from the input row. Re-anchor on every frame when an
1433        // input prompt is showing — the CUP is one cheap escape and
1434        // matches what fast terminals (macOS Terminal.app etc.)
1435        // expect: cursor visible AT the input column.
1436        self.anchor_cursor_to_input();
1437        // Single flush point for the whole frame. paint_body and
1438        // paint_footer intentionally skip their own flushes so the
1439        // terminal renders only the final cursor position, not the
1440        // intermediate body-row / status-row landings that produced
1441        // a visible cursor "blink" mid-screen during streaming on
1442        // macOS Terminal.app. anchor_cursor_to_input early-returns
1443        // (no flush) when pending_input is None — handle that here.
1444        let _ = self.out.flush();
1445    }
1446
1447    /// Move the terminal cursor back to the input row's character
1448    /// position. Called at the end of every paint_frame so async
1449    /// body updates (which only set body_dirty and skip the footer
1450    /// repaint) don't leave the cursor stranded above the input
1451    /// box. No-op when no input prompt is active.
1452    fn anchor_cursor_to_input(&mut self) {
1453        let Some((buf_str, cursor_byte)) = self.pending_input.clone() else {
1454            return;
1455        };
1456        let total_footer = self.footer_rows();
1457        let footer_top = self.height.saturating_sub(total_footer) + 1;
1458        let input_row = footer_top + 2;
1459        let chev = self.caps.prompt_chevron();
1460        let chev_width = chev.chars().count();
1461        let max_cols = (self.width as usize).saturating_sub(chev_width);
1462        let cursor_byte = cursor_byte.min(buf_str.len());
1463        let nl_marker = if self.caps.unicode_symbols { "↵" } else { "\\n" };
1464        let prefix_safe = scrub_controls(&buf_str[..cursor_byte]).replace('\n', nl_marker);
1465        let cursor_col_in_buf = display_width(&prefix_safe);
1466        let visible_cursor_col = if cursor_col_in_buf < max_cols {
1467            cursor_col_in_buf
1468        } else {
1469            max_cols.saturating_sub(1)
1470        };
1471        let cursor_col = chev_width + visible_cursor_col;
1472        if self.cursor_shown {
1473            let cup = format!("\x1b[{};{}H", input_row, cursor_col + 1);
1474            let _ = self.out.write_all(cup.as_bytes());
1475        } else {
1476            let cup = format!("\x1b[{};{}H\x1b[?25h", input_row, cursor_col + 1);
1477            let _ = self.out.write_all(cup.as_bytes());
1478            self.cursor_shown = true;
1479        }
1480        let _ = self.out.flush();
1481    }
1482
1483    /// Pipe one completed line through the markdown renderer and push
1484    /// the result. None outputs (table buffering, fence toggle) are
1485    /// dropped intentionally — the renderer handles flush via the
1486    /// next non-buffered line. Always-some output (the common case)
1487    /// becomes one body_lines entry.
1488    fn render_md_and_push(&mut self, line: &str) {
1489        // Pass terminal width through so markdown tables render in flat
1490        // mode when they don't fit at natural column widths (mirrors the
1491        // `RetainedRenderer` path). Alt-screen body has no left padding,
1492        // so the full screen width is the budget.
1493        let md_width = self.width as usize;
1494        if let Some(rendered) =
1495            crate::markdown::render_line_with_width(line, &mut self.md_state, self.caps, md_width)
1496        {
1497            // `rendered` may itself contain `\n` when it includes a
1498            // table flush prefix from a prior buffered block. Split
1499            // so each physical line becomes its own raw_body_lines entry
1500            // — `push_body_row_raw` handles soft-wrapping at terminal
1501            // width and stores the raw line for re-flow on resize
1502            // (issue #363).
1503            for sub in rendered.split('\n') {
1504                self.push_body_row_raw(sub.to_string());
1505            }
1506        }
1507    }
1508
1509    /// Flush the in-progress assistant streaming buffer as a body row,
1510    /// regardless of whether a `\n` was seen. Called by
1511    /// `AssistantLineBreak`, `TurnComplete`, and any non-streaming
1512    /// UiLine that arrives mid-stream — locks in the partial chunk so
1513    /// it stays in scrollback rather than dangling.
1514    fn flush_assistant_remainder(&mut self) {
1515        if !self.assistant_line_buf.is_empty() {
1516            let line = std::mem::take(&mut self.assistant_line_buf);
1517            self.render_md_and_push(&line);
1518        }
1519        // Also flush any pending markdown state (e.g. a buffered
1520        // table block) so end-of-turn doesn't strand it. Mirrors
1521        // RetainedRenderer's TurnComplete handling.
1522        let md_width = self.width as usize;
1523        if let Some(tail) =
1524            crate::markdown::finalize_with_width(&mut self.md_state, self.caps, md_width)
1525        {
1526            for sub in tail.split('\n') {
1527                self.push_body_row_raw(sub.to_string());
1528            }
1529        }
1530    }
1531
1532    /// Append streaming assistant text. Splits at `\n` so each completed
1533    /// physical line gets routed through the markdown renderer; partial
1534    /// trailing chunks stay in the buffer until the next `\n` or
1535    /// `AssistantLineBreak`. Inline markdown (`**bold**`, `*italic*`,
1536    /// `\`code\``) and block markdown (headings, code fences, tables) all
1537    /// resolve through `crate::markdown::render_line`.
1538    fn append_assistant_text(&mut self, text: &str) {
1539        for ch in text.chars() {
1540            if ch == '\n' {
1541                let line = std::mem::take(&mut self.assistant_line_buf);
1542                self.render_md_and_push(&line);
1543            } else {
1544                self.assistant_line_buf.push(ch);
1545            }
1546        }
1547    }
1548
1549    /// Build a horizontal-rule TurnSeparator like
1550    /// `─────── label ───────` centred on the terminal width. Mirrors
1551    /// the retained renderer's TurnSeparator rendering at a coarser
1552    /// grain (no Cell layout, just inline SGR). Muted gray colour to
1553    /// match the existing aesthetic.
1554    fn build_turn_separator(&self, label: &str) -> String {
1555        let w = (self.width as usize).max(20);
1556        let label_text = format!(" {} ", scrub_controls(label));
1557        let label_w = label_text.chars().count();
1558        let remaining = w.saturating_sub(label_w);
1559        let left = remaining / 2;
1560        let right = remaining - left;
1561        let dashes_left = "─".repeat(left);
1562        let dashes_right = "─".repeat(right);
1563        if self.caps.colors {
1564            format!("{}{}{}{}{}", SGR_DIM, dashes_left, label_text, dashes_right, SGR_RESET)
1565        } else {
1566            format!("{}{}{}", dashes_left, label_text, dashes_right)
1567        }
1568    }
1569
1570    /// Banner rows pushed for `UiLine::Welcome`. Mirrors retained's
1571    /// layout (see `RetainedRenderer::build_welcome_rows`):
1572    ///   ◆ AtomCode                           v… · MIT
1573    ///   · {working_dir}
1574    ///   · {model}
1575    ///   (blank)
1576    ///   type something, or press / to browse commands
1577    ///   /provider  to add a custom model
1578    ///   (blank)
1579    fn push_welcome(&mut self, model: &str, working_dir: &str) {
1580        let diamond = if self.caps.unicode_symbols { "\u{25c6}" } else { "*" };
1581        let bullet = if self.caps.unicode_symbols { "\u{2219}" } else { "*" };
1582        // Title row with right-aligned version + license. Fill the
1583        // gap with spaces so v4.x.y · MIT lands at the right edge.
1584        let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1585        let licence = "MIT";
1586        let title_left = format!("{} AtomCode", diamond);
1587        let title_right = format!("{} \u{00b7} {}", version, licence);
1588        let title_left_w = display_width(&title_left);
1589        let title_right_w = display_width(&title_right);
1590        let total_w = self.width as usize;
1591        let gap = total_w
1592            .saturating_sub(title_left_w)
1593            .saturating_sub(title_right_w);
1594        let title = if self.caps.colors {
1595            format!(
1596                "{}{}{}{}{}{}{}",
1597                SGR_MAGENTA,
1598                title_left,
1599                SGR_RESET,
1600                " ".repeat(gap),
1601                SGR_DIM,
1602                title_right,
1603                SGR_RESET,
1604            )
1605        } else {
1606            format!("{}{}{}", title_left, " ".repeat(gap), title_right)
1607        };
1608        self.push_body_row(title);
1609        self.push_body_row(format!("{} {}", bullet, scrub_controls(working_dir)));
1610        self.push_body_row(format!("{} {}", bullet, scrub_controls(model)));
1611        self.push_body_row(String::new());
1612        // Onboarding hints: combine onto one row when the terminal is
1613        // wide enough; otherwise emit three rows. Mirrors the same
1614        // decision the retained renderer makes — push_body_row can't
1615        // reflow, so on narrow widths we'd otherwise overflow off the
1616        // right edge.
1617        let idle_full_w = display_width(&t(Msg::IdleHintFull));
1618        let provider_full_w = display_width(&t(Msg::IdleHintProviderFull));
1619        let codingplan_full_w = display_width(&t(Msg::IdleHintCodingplanFull));
1620        let combined_w = idle_full_w + 3 + provider_full_w + 3 + codingplan_full_w;
1621        if combined_w <= self.width as usize {
1622            let hint_line = if self.caps.colors {
1623                let hint_a = format!(
1624                    "{}{}{}{}{}{}{}{}{}",
1625                    SGR_DIM, t(Msg::IdleHintPrefix), SGR_RESET,
1626                    SGR_CYAN, t(Msg::IdleHintSlash), SGR_RESET,
1627                    SGR_DIM, t(Msg::IdleHintSuffix), SGR_RESET,
1628                );
1629                let hint_b = format!(
1630                    "{}{}{}  {}{}{}",
1631                    SGR_CYAN, t(Msg::IdleHintProvider), SGR_RESET,
1632                    SGR_DIM, t(Msg::IdleHintProviderSuffix), SGR_RESET,
1633                );
1634                let hint_c = format!(
1635                    "{}{}{}  {}{}{}",
1636                    SGR_CYAN, t(Msg::IdleHintCodingplan), SGR_RESET,
1637                    SGR_DIM, t(Msg::IdleHintCodingplanSuffix), SGR_RESET,
1638                );
1639                format!("{}   {}   {}", hint_a, hint_b, hint_c)
1640            } else {
1641                format!(
1642                    "{}   {}   {}",
1643                    t(Msg::IdleHintFull),
1644                    t(Msg::IdleHintProviderFull),
1645                    t(Msg::IdleHintCodingplanFull),
1646                )
1647            };
1648            self.push_body_row(hint_line);
1649        } else {
1650            let hint_a = if self.caps.colors {
1651                format!(
1652                    "{}{}{}{}{}{}{}{}{}",
1653                    SGR_DIM, t(Msg::IdleHintPrefix), SGR_RESET,
1654                    SGR_CYAN, t(Msg::IdleHintSlash), SGR_RESET,
1655                    SGR_DIM, t(Msg::IdleHintSuffix), SGR_RESET,
1656                )
1657            } else {
1658                t(Msg::IdleHintFull).into_owned()
1659            };
1660            self.push_body_row(hint_a);
1661            let hint_b = if self.caps.colors {
1662                format!(
1663                    "{}{}{}  {}{}{}",
1664                    SGR_CYAN, t(Msg::IdleHintProvider), SGR_RESET,
1665                    SGR_DIM, t(Msg::IdleHintProviderSuffix), SGR_RESET,
1666                )
1667            } else {
1668                t(Msg::IdleHintProviderFull).into_owned()
1669            };
1670            self.push_body_row(hint_b);
1671            let hint_c = if self.caps.colors {
1672                format!(
1673                    "{}{}{}  {}{}{}",
1674                    SGR_CYAN, t(Msg::IdleHintCodingplan), SGR_RESET,
1675                    SGR_DIM, t(Msg::IdleHintCodingplanSuffix), SGR_RESET,
1676                )
1677            } else {
1678                t(Msg::IdleHintCodingplanFull).into_owned()
1679            };
1680            self.push_body_row(hint_c);
1681        }
1682        self.push_body_row(String::new());
1683    }
1684
1685    /// User echo row: `❯ {text}` (or `> {text}` on dumb caps) + blank
1686    /// spacer. Multi-line input (`\<Enter>` line-continuation,
1687    /// Shift/Alt+Enter on terminals that disambiguate, paste with
1688    /// embedded newlines) splits each physical line into its own
1689    /// body row — `paint_body` CUPs every body line to a distinct
1690    /// terminal row, so a single body string with embedded `\n`
1691    /// would corrupt the alt-screen layout: the literal LF in raw
1692    /// mode advances row but not column, then the next paint_body
1693    /// iteration CUP+EL-erases whatever landed below. Windows cmd
1694    /// users reported "abc<\><Enter>def" submitted as echo only
1695    /// showed `❯ abc`, the `def` flashed and disappeared.
1696    /// Continuation lines indent under the chevron-and-space prefix
1697    /// so multi-line user messages read as one paragraph rather than
1698    /// orphaned rows.
1699    fn push_user(&mut self, text: &str) {
1700        self.flush_assistant_remainder();
1701        self.md_state.reset();
1702        let chev = self.caps.prompt_chevron();
1703        let safe = scrub_controls(text);
1704        let chev_w = crate::width::display_width(chev);
1705        let cont_pad: String = " ".repeat(chev_w);
1706        for (i, line) in safe.split('\n').enumerate() {
1707            let row = if i == 0 {
1708                if self.caps.colors {
1709                    format!("{}{}{}{}", SGR_CYAN, chev, SGR_RESET, line)
1710                } else {
1711                    format!("{}{}", chev, line)
1712                }
1713            } else {
1714                format!("{}{}", cont_pad, line)
1715            };
1716            // Use push_body_row_raw so long user lines are soft-wrapped
1717            // and can be re-flowed on resize (issue #363).
1718            self.push_body_row_raw(row);
1719        }
1720        self.push_body_row(String::new());
1721    }
1722
1723    /// `▸ name(detail)` row for tool calls. Cyan name when colours on.
1724    /// Same line for both `ToolCall` (terminal final-state) and
1725    /// `ToolCallInFlight` (Phase 2: no live spinner — stays static
1726    /// until commit). Spinner animation for in-flight ships in Phase 3.
1727    fn push_tool_call(&mut self, name: &str, detail: &str) {
1728        self.flush_assistant_remainder();
1729        // ● (U+25CF) — Geometric Shapes block, broadly available
1730        // across Windows monospace fonts. Was ▸ (U+25B8) but rendered
1731        // as `□` tofu on Windows VSCode/cmd.exe defaults; see the
1732        // matching comment in retained.rs ToolCall arm for the
1733        // Windows-font rationale.
1734        let arrow = "\u{25cf}";
1735        let name_safe = scrub_controls(name);
1736        let detail_safe = scrub_controls(detail);
1737        let row = match (self.caps.colors, detail_safe.is_empty()) {
1738            (true, true) => format!("{}{} {}{}", SGR_CYAN, arrow, name_safe, SGR_RESET),
1739            (true, false) => format!(
1740                "{}{} {}{}({})",
1741                SGR_CYAN, arrow, name_safe, SGR_RESET, detail_safe
1742            ),
1743            (false, true) => format!("{} {}", arrow, name_safe),
1744            (false, false) => format!("{} {}({})", arrow, name_safe, detail_safe),
1745        };
1746        // Use push_body_row_raw so long tool-call lines are soft-wrapped
1747        // and can be re-flowed on resize (issue #363).
1748        self.push_body_row_raw(row);
1749    }
1750
1751    /// `✓ summary` (green) or `✗ summary` (red) row. PlainRenderer-style.
1752    fn push_tool_result(&mut self, success: bool, summary: &str) {
1753        self.flush_assistant_remainder();
1754        let icon = if success { "\u{2713}" } else { "\u{2717}" }; // ✓ ✗
1755        let safe = scrub_controls(summary);
1756        let row = if self.caps.colors {
1757            let color = if success { SGR_GREEN } else { SGR_RED };
1758            format!("    {}{}{} {}", color, icon, SGR_RESET, safe)
1759        } else {
1760            format!("    {} {}", icon, safe)
1761        };
1762        // Use push_body_row_raw so long tool-result lines are soft-wrapped
1763        // and can be re-flowed on resize (issue #363).
1764        self.push_body_row_raw(row);
1765    }
1766
1767    /// `[Error: ...]` row. Red when colours on. Mirrors PlainRenderer.
1768    fn push_error(&mut self, msg: &str) {
1769        self.flush_assistant_remainder();
1770        let safe = scrub_controls(msg);
1771        let label = t(Msg::ErrorPrefix { msg: &safe });
1772        let row = if self.caps.colors {
1773            format!("{}{}{}", SGR_RED, label, SGR_RESET)
1774        } else {
1775            label.into_owned()
1776        };
1777        // Use push_body_row_raw so long error lines are soft-wrapped
1778        // and can be re-flowed on resize (issue #363).
1779        self.push_body_row_raw(row);
1780    }
1781
1782    fn push_warning(&mut self, msg: &str) {
1783        self.flush_assistant_remainder();
1784        let safe = scrub_controls(msg);
1785        // Bold yellow `! …` advisory. Visually softer than the red
1786        // [Error: …] but still high-contrast — meant to be impossible
1787        // to scroll past without noticing.
1788        let row = if self.caps.colors {
1789            format!("\x1b[1;33m! {}{}", safe, SGR_RESET)
1790        } else {
1791            format!("! {}", safe)
1792        };
1793        // Use push_body_row_raw so long warning lines are soft-wrapped
1794        // and can be re-flowed on resize (issue #363).
1795        self.push_body_row_raw(row);
1796    }
1797
1798    /// Push `text` as command-output rows wrapped in `sgr_open` (e.g.
1799    /// `SGR_GREY` or `SGR_BOLD`). Scrubs first, soft-wraps each line,
1800    /// THEN paints SGR around every wrapped chunk — this ordering is
1801    /// load-bearing: `push_command_output` runs `scrub_controls` which
1802    /// would otherwise strip caller-supplied SGR if the styling were
1803    /// applied first. Used for ToolGroup header (bold) and child
1804    /// rows (muted gray), mirroring retained's role-based styling.
1805    fn push_styled_command_output(&mut self, text: &str, sgr_open: &str) {
1806        self.flush_assistant_remainder();
1807        let safe = scrub_controls(text);
1808        let style_on = self.caps.colors && !sgr_open.is_empty();
1809        for line in safe.split('\n') {
1810            let row = if style_on {
1811                format!("{}{}{}", sgr_open, line, SGR_RESET)
1812            } else {
1813                line.to_string()
1814            };
1815            // Use push_body_row_raw so long styled output lines are
1816            // soft-wrapped and can be re-flowed on resize (issue #363).
1817            self.push_body_row_raw(row);
1818        }
1819    }
1820
1821    /// `(cancelled)` marker row.
1822    fn push_cancelled(&mut self) {
1823        self.flush_assistant_remainder();
1824        let label = t(Msg::Cancelled);
1825        let row = if self.caps.colors {
1826            format!("{}{}{}", SGR_DIM, label, SGR_RESET)
1827        } else {
1828            label.into_owned()
1829        };
1830        // Soft-wrap at terminal width (issue #363) — cancelled label
1831        // is typically short, but wrap for consistency and re-flow on
1832        // resize.
1833        self.push_body_row_raw(row);
1834    }
1835
1836    /// Diff line: `+ added` (green) or `- removed` (red). Per-row sign.
1837    fn push_diff_line(&mut self, added: bool, text: &str) {
1838        let safe = scrub_controls(text);
1839        let row = match (self.caps.colors, added) {
1840            (true, true) => format!("    {}+ {}{}", SGR_GREEN, safe, SGR_RESET),
1841            (true, false) => format!("    {}- {}{}", SGR_RED, safe, SGR_RESET),
1842            (false, true) => format!("    + {}", safe),
1843            (false, false) => format!("    - {}", safe),
1844        };
1845        // Use push_body_row_raw so long diff lines are soft-wrapped
1846        // and can be re-flowed on resize (issue #363).
1847        self.push_body_row_raw(row);
1848    }
1849
1850    /// Push CommandOutput verbatim, splitting on newlines so each
1851    /// physical line is its own body row.
1852    fn push_command_output(&mut self, text: &str) {
1853        self.flush_assistant_remainder();
1854        // CommandOutput is trusted internal text (slash-command
1855        // responses, setup reports, status echoes) — let SGR
1856        // through so things like the `/codingplan` red locked-model
1857        // row reach the terminal. Cursor moves, OSC, and other
1858        // potentially-dangerous escapes are still stripped. The
1859        // wrap helper inside push_body_row_raw is SGR-aware so
1860        // colour state survives line wrapping intact.
1861        let safe = crate::sanitize::scrub_controls_keep_sgr(text);
1862        // Use push_body_row_raw so long command-output lines are
1863        // soft-wrapped and can be re-flowed on resize (issue #363).
1864        for line in safe.split('\n') {
1865            self.push_body_row_raw(line.to_string());
1866        }
1867    }
1868}
1869
1870/// Compute the half-open column range `[start, end)` of `line` that
1871/// falls inside the ordered selection bounds `(lo, hi)`. Returns
1872/// `None` if the line is outside the row range. Bounds within the
1873/// line are clamped to the visible display width so a click past the
1874/// end doesn't extend selection into thin air.
1875///
1876/// Free function (rather than a method) so the body-paint loop can
1877/// call it while holding a borrow of `self.body_lines[i]` without
1878/// re-borrowing `self`.
1879fn selection_col_range_for_line(
1880    line_idx: usize,
1881    lo: (usize, usize),
1882    hi: (usize, usize),
1883    line: &str,
1884) -> Option<(usize, usize)> {
1885    if line_idx < lo.0 || line_idx > hi.0 {
1886        return None;
1887    }
1888    let line_w = line_display_width_sgr_aware(line);
1889    let start_col = if line_idx == lo.0 { lo.1 } else { 0 };
1890    // Line containing the head: include the cell under the head —
1891    // half-open `end_col` = head_col + 1. Middle lines select to
1892    // end of line; the bottom line of a multi-line selection uses
1893    // the same `hi.1 + 1` rule as a same-line selection.
1894    let end_col_exclusive = if line_idx == hi.0 {
1895        hi.1.saturating_add(1)
1896    } else {
1897        line_w
1898    };
1899    let s = start_col.min(line_w);
1900    let e = end_col_exclusive.min(line_w);
1901    if e <= s {
1902        return None;
1903    }
1904    Some((s, e))
1905}
1906
1907impl<W: Write + Send> Renderer for AltScreenRenderer<W> {
1908    fn render(&mut self, line: UiLine) {
1909        match line {
1910            // ── body: welcome / turn events ──
1911            UiLine::Welcome { model, working_dir } => {
1912                self.push_welcome(&model, &working_dir);
1913            }
1914            UiLine::User(text) => {
1915                self.push_user(&text);
1916            }
1917            UiLine::TurnSeparator { label } => {
1918                let row = self.build_turn_separator(&label);
1919                self.push_body_row(String::new());
1920                self.push_body_row(row);
1921                self.push_body_row(String::new());
1922            }
1923            UiLine::TurnComplete => {
1924                self.flush_assistant_remainder();
1925            }
1926            UiLine::TurnCancelled => {
1927                self.push_cancelled();
1928            }
1929
1930            // ── body: streaming assistant ──
1931            UiLine::AssistantText(text) => {
1932                self.append_assistant_text(&text);
1933            }
1934            UiLine::ReasoningText(text) => {
1935                // Dim styling for reasoning chunks; same SGR pattern
1936                // RetainedRenderer / PlainRenderer already use.
1937                if self.caps.colors {
1938                    let dimmed = format!("{}{}{}", SGR_DIM, scrub_controls(&text), SGR_RESET);
1939                    self.append_assistant_text(&dimmed);
1940                } else {
1941                    self.append_assistant_text(&text);
1942                }
1943            }
1944            UiLine::AssistantLineBreak => {
1945                self.flush_assistant_remainder();
1946            }
1947
1948            // ── body: tools & diffs ──
1949            UiLine::ToolCall { name, detail }
1950            | UiLine::ToolCallInFlight { name, detail, .. } => {
1951                self.push_tool_call(&name, &detail);
1952            }
1953            UiLine::ToolCallCommit { .. } => {
1954                // Phase 3 will add live-spinner freezing here. Phase 2
1955                // pushes ToolCallInFlight as a static row already, so
1956                // there's nothing to freeze yet.
1957            }
1958            UiLine::ToolGroupRender { batch_id: _, header, children } => {
1959                // alt-screen mirrors retained's append-style without
1960                // the in-place ✓ rewrite (alt-screen layout is
1961                // virtual-buffer based; live-group rewrite would need
1962                // its own row tracking). Header + children print
1963                // statically; ChildUpdate appends a new row.
1964                //
1965                // Style parity with retained (`UiLine::ToolGroupRender`
1966                // arm in retained.rs):
1967                //   - header: bold, default fg — emphasises the
1968                //     `● Running N tools in parallel` anchor row
1969                //   - children: muted gray (SGR 90) — high-frequency
1970                //     per-call rows (`▸ bash(cmd…)`) that should read
1971                //     as subordinate detail, not compete with the
1972                //     header. User reported children rendered in
1973                //     default fg here, so the visual hierarchy was
1974                //     flattened relative to retained.
1975                self.push_styled_command_output(&header, SGR_BOLD);
1976                for c in children {
1977                    self.push_styled_command_output(&c.text, SGR_GREY);
1978                }
1979            }
1980            UiLine::ToolGroupChildUpdate { batch_id: _, call_id: _, new_text } => {
1981                // Update inherits the muted child styling so the row
1982                // stays visually subordinate after the result lands.
1983                self.push_styled_command_output(&new_text, SGR_GREY);
1984            }
1985            UiLine::ToolGroupSummary { text } => {
1986                // Summary mirrors header: bold default-fg anchor row
1987                // closing the group. Matches retained's
1988                // `style_bold(Role::Secondary)` choice.
1989                self.push_styled_command_output(&text, SGR_BOLD);
1990            }
1991            UiLine::ToolResult { success, summary } => {
1992                self.push_tool_result(success, &summary);
1993            }
1994            UiLine::DiffLine { added, text } => {
1995                self.push_diff_line(added, &text);
1996            }
1997            UiLine::DiffBlock(entries) => {
1998                for entry in entries {
1999                    self.push_diff_line(entry.added, &entry.text);
2000                }
2001            }
2002            UiLine::ApprovalPrompt { tool, detail } => {
2003                // Mirror retained's chip-based prompt: bold-yellow
2004                // "▶ Waiting for approval:" label + Y/A/N reverse-video
2005                // chips (green / cyan / red) + their textual labels.
2006                // The previous alt-screen path emitted the flat
2007                // `ApprovalPromptAlt` sentence — visually indistinct from
2008                // a regular command-output row, so users couldn't tell at
2009                // a glance that an approval was pending.
2010                //
2011                // Include the tool name and detail so the user knows which
2012                // specific action they're being asked to approve. Without
2013                // this, parallel batch approvals (e.g. 3 × Read) show
2014                // identical prompts and the user can't tell which file
2015                // they're approving (issue #439).
2016                //
2017                // When the label + chips fit on one line, place them
2018                // together (issue #454: users reported unnecessary
2019                // line-splitting). Only split when the label is too long.
2020                let waiting = t(Msg::ApprovalWaitingLabel);
2021                let allow = t(Msg::ApprovalAllow);
2022                let always = t(Msg::ApprovalAlways);
2023                let deny = t(Msg::ApprovalDeny);
2024                let tool_label = if detail.is_empty() {
2025                    format!("{}: ", tool)
2026                } else {
2027                    format!("{}({}): ", tool, detail)
2028                };
2029                let prefix_w = crate::width::display_width(&waiting);
2030                let cont_pad = " ".repeat(prefix_w);
2031
2032                // Build the chips text — reused for both one-line and
2033                // two-line layouts.
2034                let chips_plain = format!("Y {allow} A {always} N {deny}");
2035                let chips_colored = format!(
2036                    "{rev}{green} Y {reset}{allow}{rev}{cyan} A {reset}{always}{rev}{red} N {reset}{deny}",
2037                    rev = SGR_REVERSE,
2038                    green = SGR_GREEN,
2039                    cyan = SGR_CYAN,
2040                    red = SGR_RED,
2041                    reset = SGR_RESET,
2042                    allow = allow,
2043                    always = always,
2044                    deny = deny,
2045                );
2046                let chips_display_w = crate::width::display_width(&chips_plain);
2047                let screen_w = self.width as usize;
2048
2049                // Build the label row (with or without color), wrap it,
2050                // and measure the last wrapped chunk's visible width.
2051                // This mirrors retained.rs which measures the last
2052                // build_prefixed_rows row's cell width.
2053                let label_raw = if self.caps.colors {
2054                    format!(
2055                        "{bold}{yellow}{waiting}{tool_label}{reset}",
2056                        bold = SGR_BOLD,
2057                        yellow = SGR_YELLOW,
2058                        reset = SGR_RESET,
2059                    )
2060                } else {
2061                    format!("{waiting}{tool_label}")
2062                };
2063                let wrapped_label = wrap_to_width_sgr_aware(&label_raw, screen_w);
2064                let last_label_w = wrapped_label
2065                    .last()
2066                    .map(|s| line_display_width_sgr_aware(s))
2067                    .unwrap_or(0);
2068
2069                if last_label_w + chips_display_w <= screen_w {
2070                    // Everything fits on one line.
2071                    if self.caps.colors {
2072                        let row = format!(
2073                            "{label_raw}{chips}",
2074                            label_raw = label_raw,
2075                            chips = chips_colored,
2076                        );
2077                        self.push_body_row_raw(row);
2078                    } else {
2079                        let row = format!("{label_raw}{chips_plain}");
2080                        self.push_body_row_raw(row);
2081                    }
2082                } else {
2083                    // Label too long — keep chips on a separate line.
2084                    self.push_body_row_raw(label_raw);
2085                    if self.caps.colors {
2086                        self.push_body_row(format!("{cont_pad}{chips_colored}"));
2087                    } else {
2088                        self.push_body_row(format!("{cont_pad}{chips_plain}"));
2089                    }
2090                }
2091            }
2092
2093            // ── body: command output / errors ──
2094            UiLine::CommandOutput(text) => {
2095                self.push_command_output(&text);
2096            }
2097            UiLine::ImageAttachment(n) => {
2098                // `└` at col 2, aligned under the `[` of `[Image #N]`
2099                // in the user-message echo above (push_user prefixes
2100                // `❯ ` so user content starts at col 2). alt-screen's
2101                // push_command_output passes through verbatim — no
2102                // PAD_COL auto-prefix — so we emit the leading 2
2103                // spaces explicitly here. Mirrors retained's render
2104                // visually: same `└` column, same indent under the
2105                // parent user message.
2106                //
2107                // Tight grouping: `push_user` always emits a trailing
2108                // blank spacer row. Pop it if present so the attachment
2109                // sits flush under the user message (no orphan blank
2110                // between `❯ msg` and `└ [Image #N]`), then re-emit a
2111                // fresh trailing blank so the next turn's content still
2112                // has paragraph separation.
2113                if self.body_lines.last().map_or(false, |r| r.is_empty()) {
2114                    self.body_lines.pop();
2115                }
2116                self.push_command_output(&format!("  └ [Image #{}]", n));
2117                self.push_body_row(String::new());
2118            }
2119            UiLine::VisionPreprocessSuccess { msg, model } => {
2120                // alt-screen has no two-style row primitive; degrade to
2121                // a plain command-output line concatenating message and
2122                // model. Loses the gray styling but preserves the
2123                // information. Acceptable for the alt-screen path
2124                // (used in non-retained terminals).
2125                //
2126                // Trailing blank: paragraph separation before the next
2127                // event (spinner / assistant text). Mirrors retained.
2128                self.push_command_output(&format!("{}  {}", msg, model));
2129                self.push_body_row(String::new());
2130            }
2131            UiLine::Error(msg) => {
2132                self.push_error(&msg);
2133            }
2134            UiLine::Warning(msg) => {
2135                self.push_warning(&msg);
2136            }
2137
2138            // ── footer: input box ──
2139            UiLine::InputPrompt {
2140                buf,
2141                cursor_byte,
2142                menu,
2143                status,
2144                attachments,
2145            } => {
2146                self.pending_input = Some((buf, cursor_byte));
2147                self.pending_status = status;
2148                self.pending_menu = menu; // slash-palette payload
2149                self.pending_attachments = attachments;
2150                self.pending_spinner = None; // input takes over from spinner
2151                self.footer_dirty = true;
2152                // Menu / attachment state changes the footer height
2153                // (variable rows). Repaint body too so it shrinks/grows
2154                // correspondingly.
2155                self.body_dirty = true;
2156            }
2157            UiLine::StreamingBox {
2158                buf,
2159                cursor_byte,
2160                frame,
2161                label,
2162                status,
2163                menu,
2164                attachments,
2165            } => {
2166                self.pending_input = Some((buf, cursor_byte));
2167                self.pending_status = status;
2168                self.pending_menu = menu;
2169                self.pending_attachments = attachments;
2170                self.pending_spinner = Some((frame, label));
2171                self.footer_dirty = true;
2172                self.body_dirty = true;
2173            }
2174            UiLine::InputCommit => {
2175                // The committed buffer became a `User` body row already
2176                // (event loop emits both); just clear input state so
2177                // the next paint shows an empty prompt.
2178                self.pending_input = Some((String::new(), 0));
2179                self.footer_dirty = true;
2180            }
2181            UiLine::Spinner { frame, label } => {
2182                self.pending_spinner = Some((frame, label));
2183                self.footer_dirty = true;
2184            }
2185            UiLine::ClearTransient => {
2186                self.pending_spinner = None;
2187                self.footer_dirty = true;
2188            }
2189        }
2190
2191        // Repaint after every render call. Both paint helpers are
2192        // no-ops when their *_dirty flag is false, so unconditional
2193        // calls cost only the branch — far cleaner than threading
2194        // dirty checks through every match arm.
2195        self.paint_frame();
2196    }
2197
2198    fn flush(&mut self) {
2199        let _ = self.out.flush();
2200    }
2201
2202    fn shutdown(&mut self) {
2203        self.leave_alt_screen();
2204    }
2205
2206    fn reset(&mut self) {
2207        // Wipe body_lines + viewport state, repaint blank canvas.
2208        // Used by `/clear` slash command. Footer state preserved so
2209        // the input box / status keep their value across the wipe.
2210        self.body_lines.clear();
2211        self.assistant_line_buf.clear();
2212        self.viewport_top = 0;
2213        self.sticky_bottom = true;
2214        self.body_dirty = true;
2215        self.footer_dirty = true;
2216        // Selection indices reference `body_lines`, which we just
2217        // wiped — keep them around and they'd point past end-of-
2218        // buffer on the next paint.
2219        self.selection = None;
2220        self.selection_active = false;
2221        let _ = self.out.write_all(b"\x1b[2J\x1b[H");
2222        self.paint_frame();
2223    }
2224
2225    fn clear_screen(&mut self) {
2226        // Same shape as reset: wipe everything. The slash `/clear`
2227        // semantic is "remove visible content"; in alt-screen there's
2228        // no host scrollback to preserve, so wiping body_lines too is
2229        // consistent with what the user expects ("a clean slate").
2230        self.reset();
2231    }
2232
2233    fn suspend_for_external(&mut self) {
2234        // To run an external child cleanly we pop alt-screen so the
2235        // child sees the host terminal's main screen. resume re-enters.
2236        self.leave_alt_screen();
2237    }
2238
2239    fn resume_from_external(&mut self) {
2240        self.enter_alt_screen();
2241        // After re-entering, the alt-screen is blank — repaint our
2242        // entire body buffer + footer chrome.
2243        self.body_dirty = true;
2244        self.footer_dirty = true;
2245        self.paint_frame();
2246    }
2247
2248    fn flush_deferred(&mut self) {
2249        // Phase 5+ adds frame coalescing. For now, nothing buffered.
2250    }
2251
2252    fn scroll_body(&mut self, delta: i32) {
2253        let body_height = self.body_height() as usize;
2254        let total = self.body_lines.len();
2255        let max_top = total.saturating_sub(body_height);
2256
2257        // Compute the new viewport_top. Treat sticky_bottom as
2258        // viewport_top = max_top so a user scrolling up from the
2259        // pinned-bottom state lands one page above the tail (not
2260        // anchored at 0 because the buffer might be much longer than
2261        // one page).
2262        let current_top = if self.sticky_bottom { max_top } else { self.viewport_top };
2263        let new_top: usize = if delta < 0 {
2264            current_top.saturating_sub(delta.unsigned_abs() as usize)
2265        } else {
2266            (current_top + delta as usize).min(max_top)
2267        };
2268
2269        self.viewport_top = new_top;
2270        // Sticky-bottom transitions:
2271        //   * Scrolling up (or anywhere short of max_top) breaks sticky.
2272        //   * Scrolling down past the end re-pins to bottom — new
2273        //     content auto-follows again from there.
2274        self.sticky_bottom = new_top >= max_top;
2275        self.body_dirty = true;
2276        // Footer also dirty: paint_body's last cursor position lands
2277        // somewhere in the body region, but the user expects the
2278        // terminal cursor to stay in the input row at the right
2279        // buf-prefix offset. Without this flag, paint_frame would
2280        // skip paint_footer and leave the cursor stranded mid-body.
2281        self.footer_dirty = true;
2282        self.paint_frame();
2283    }
2284
2285    fn scroll_body_to_top(&mut self) {
2286        self.viewport_top = 0;
2287        self.sticky_bottom = false;
2288        self.body_dirty = true;
2289        self.footer_dirty = true;
2290        self.paint_frame();
2291    }
2292
2293    fn scroll_body_to_bottom(&mut self) {
2294        let body_height = self.body_height() as usize;
2295        self.viewport_top = self.body_lines.len().saturating_sub(body_height);
2296        self.sticky_bottom = true;
2297        self.body_dirty = true;
2298        self.footer_dirty = true;
2299        self.paint_frame();
2300    }
2301
2302    fn on_resize(&mut self, cols: u16, rows: u16) {
2303        // No-op if size unchanged. Pairs with the burst coalescing in
2304        // `event_loop::handle_input`; same-size events still arrive
2305        // (focus changes, tab cycles, multiplexer pane shuffles) and
2306        // the `\x1b[2J\x1b[H` wipe below is visible flicker even when
2307        // the result is byte-identical.
2308        if cols == self.width && rows == self.height {
2309            return;
2310        }
2311        // Resize is the simplest of all renderers in alt-screen mode:
2312        // no DECSTBM region to renegotiate, no scroll-region edge
2313        // cases, no auto-wrap-into-footer issues. We just:
2314        //   1. update cached size
2315        //   2. re-flow body_lines from raw_body_lines at the new width
2316        //      so narrowing the terminal wraps (not truncates) long
2317        //      rows and widening re-merges previously split short rows
2318        //      (issue #363)
2319        //   3. wipe the alt-screen with `\x1b[2J\x1b[H` so stale
2320        //      pre-resize glyphs at old absolute positions can't
2321        //      ghost — iTerm2 / some terminals leave them visible
2322        //      until something overwrites them
2323        //   4. mark both panes dirty + repaint
2324        self.width = cols;
2325        self.height = rows;
2326        // Re-flow body content at the new width. raw_body_lines
2327        // preserves the original (unwrapped) logical lines so that
2328        // widening the terminal re-merges previously split rows and
2329        // narrowing the terminal splits long rows instead of
2330        // truncating them.
2331        self.reflow_body_lines();
2332        // Re-clamp viewport_top against the new (possibly smaller)
2333        // body_height, so a user who'd Page-Up'd into the buffer
2334        // doesn't end up with viewport_top past end-of-buffer.
2335        let new_body_height = self.body_height() as usize;
2336        self.viewport_top = self
2337            .viewport_top
2338            .min(self.body_lines.len().saturating_sub(new_body_height));
2339        // Selection's display-column anchors were taken at the old
2340        // width; after a resize they'd land in the wrong spot of the
2341        // re-flowed line. Cleanest is to drop the selection entirely
2342        // — the user can drag-select again at the new geometry.
2343        self.selection = None;
2344        self.selection_active = false;
2345        let _ = self.out.write_all(b"\x1b[2J\x1b[H");
2346        self.body_dirty = true;
2347        self.footer_dirty = true;
2348        self.paint_frame();
2349    }
2350
2351    fn begin_selection(&mut self, col: u16, row: u16) {
2352        // Only anchor a selection when the press lands inside the
2353        // body region. Footer / blank-area presses clear any prior
2354        // selection (so a stray click also acts as "deselect").
2355        match self.screen_to_body(col, row) {
2356            Some(pos) => {
2357                self.selection = Some(Selection { anchor: pos, head: pos });
2358                self.selection_active = true;
2359            }
2360            None => {
2361                self.selection = None;
2362                self.selection_active = false;
2363            }
2364        }
2365        self.body_dirty = true;
2366        self.paint_frame();
2367    }
2368
2369    fn update_selection(&mut self, col: u16, row: u16) {
2370        // Guard against terminals that emit a coalesced motion event
2371        // right after Up — without this, that stale motion would
2372        // shift `head` of an already-finalised selection.
2373        if !self.selection_active {
2374            return;
2375        }
2376        let Some(pos) = self.screen_to_body_clamped(col, row) else {
2377            return;
2378        };
2379        if let Some(sel) = self.selection.as_mut() {
2380            if sel.head == pos {
2381                return; // no-op move (cell-granularity, drag jitter)
2382            }
2383            sel.head = pos;
2384            self.body_dirty = true;
2385            self.paint_frame();
2386        }
2387    }
2388
2389    fn end_selection(&mut self) {
2390        // Mark the selection as finalised but keep it visible so the
2391        // user can see what they captured. A subsequent press starts
2392        // a fresh selection (or deselects on footer/empty hit).
2393        self.selection_active = false;
2394        let text = self.extract_selection_text();
2395        self.write_osc52_clipboard(&text);
2396    }
2397
2398    fn copy_selection(&mut self) -> bool {
2399        let text = self.extract_selection_text();
2400        if text.is_empty() {
2401            return false;
2402        }
2403        // Use arboard to write the system clipboard directly. OSC 52
2404        // is unreliable on Windows (Windows Terminal / conhost ignore
2405        // it), so this is the primary copy path on that platform.
2406        // macOS and Linux terminals that honour OSC 52 already got the
2407        // text via end_selection, but copy_selection is still useful
2408        // when the user re-selects or the OSC 52 write failed silently.
2409        if let Ok(mut clipboard) = arboard::Clipboard::new() {
2410            if clipboard.set_text(&text).is_ok() {
2411                // Clear the visual selection so the user sees feedback
2412                // that the copy was consumed (mirrors how most editors
2413                // deselect after Ctrl+C).
2414                self.selection = None;
2415                self.body_dirty = true;
2416                self.paint_frame();
2417                return true;
2418            }
2419        }
2420        // arboard failed (e.g. another process holds the clipboard) —
2421        // fall back to OSC 52 as a best-effort retry.
2422        self.write_osc52_clipboard(&text);
2423        self.selection = None;
2424        self.body_dirty = true;
2425        self.paint_frame();
2426        true // text was non-empty, selection existed
2427    }
2428}
2429
2430impl<W: Write + Send> Drop for AltScreenRenderer<W> {
2431    fn drop(&mut self) {
2432        // Belt-and-suspenders pop. `shutdown()` already runs on
2433        // normal exit and `leave_alt_screen` is idempotent (gated
2434        // on `alt_screen_active`), so the duplicate pop is safe.
2435        // This Drop is what saves the user's terminal when a panic
2436        // bypasses `shutdown()`.
2437        self.leave_alt_screen();
2438    }
2439}
2440
2441#[cfg(test)]
2442mod tests {
2443    use super::*;
2444
2445    fn caps_default() -> TerminalCaps {
2446        TerminalCaps {
2447            tty: true,
2448            colors: true,
2449            spinner: true,
2450            bracketed_paste: true,
2451            raw_mode: true,
2452            scroll_region: true,
2453            unicode_symbols: true,
2454        }
2455    }
2456
2457    /// Construction enters alt-screen + enables mouse capture.
2458    /// Drop reverses both. The lifecycle is what the rest of Phase 1
2459    /// hangs off — if this is wrong, every later test is moot.
2460    #[test]
2461    fn construct_emits_alt_screen_enter_sequence() {
2462        let mut buf = Vec::new();
2463        let r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2464        drop(r);
2465        let s = String::from_utf8_lossy(&buf);
2466        assert!(s.contains("\x1b[?1049h"), "alt-screen ENTER missing. got: {:?}", s);
2467        assert!(s.contains("\x1b[?1002h"), "mouse-mode ENTER (1002h) missing. got: {:?}", s);
2468        assert!(s.contains("\x1b[?1006h"), "mouse-mode ENTER (1006h) missing. got: {:?}", s);
2469        assert!(s.contains("\x1b[?1049l"), "alt-screen LEAVE missing. got: {:?}", s);
2470        assert!(s.contains("\x1b[?1002l"), "mouse-mode LEAVE (1002l) missing. got: {:?}", s);
2471        assert!(s.contains("\x1b[?1006l"), "mouse-mode LEAVE (1006l) missing. got: {:?}", s);
2472    }
2473
2474    /// Welcome pushes 4 rows (title, working_dir, model, blank) into
2475    /// body_lines and paint_body emits each at absolute CUP. Phase 2:
2476    /// no longer "renders at fixed rows 1/2/3" — rows are derived from
2477    /// body_lines + viewport, but in a fresh session the welcome lands
2478    /// at the top of the buffer so rows 1-4 still hold its content.
2479    #[test]
2480    fn welcome_pushes_four_body_rows_at_top() {
2481        let mut buf = Vec::new();
2482        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2483        r.render(UiLine::Welcome {
2484            model: "claude-opus-4-7".into(),
2485            working_dir: "/tmp/proj".into(),
2486        });
2487        r.flush();
2488        drop(r);
2489        let s = String::from_utf8_lossy(&buf);
2490        // First three rows of the body received CUP + content.
2491        assert!(s.contains("\x1b[1;1H"), "row 1 CUP missing. got: {:?}", s);
2492        assert!(s.contains("\x1b[2;1H"), "row 2 CUP missing. got: {:?}", s);
2493        assert!(s.contains("\x1b[3;1H"), "row 3 CUP missing. got: {:?}", s);
2494        assert!(
2495            s.contains("AtomCode"),
2496            "welcome banner must include 'AtomCode'. got: {:?}",
2497            s
2498        );
2499        assert!(
2500            s.contains("claude-opus-4-7"),
2501            "welcome banner must include the model name. got: {:?}",
2502            s
2503        );
2504        assert!(
2505            s.contains("/tmp/proj"),
2506            "welcome banner must include the working dir. got: {:?}",
2507            s
2508        );
2509    }
2510
2511    /// Multiline user input (`\<Enter>` on terminals that swallow
2512    /// Shift/Alt+Enter — typical Windows cmd.exe / legacy conhost,
2513    /// where the modifier bits never reach the application — plus
2514    /// pasted content with embedded newlines) MUST split into one
2515    /// body row per physical line. Was a single body string with
2516    /// embedded `\n`, which `paint_body` writes verbatim — the
2517    /// terminal interprets LF as row-advance, and the next CUP+EL
2518    /// for the following body row erases whatever landed there.
2519    /// User-reported on Windows cmd: "abc<\><Enter>def" submitted as
2520    /// echo only showed `❯ abc`, the `def` flashed and disappeared.
2521    #[test]
2522    fn push_user_splits_on_newline_into_separate_body_rows() {
2523        let mut buf = Vec::new();
2524        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2525        r.render(UiLine::User("first\nsecond\nthird".into()));
2526        r.flush();
2527        drop(r);
2528        let s = String::from_utf8_lossy(&buf);
2529        assert!(s.contains("first"), "first line missing. got: {:?}", s);
2530        assert!(s.contains("second"), "second line missing. got: {:?}", s);
2531        assert!(s.contains("third"), "third line missing. got: {:?}", s);
2532        // No raw `\n` survives into a single painted body row —
2533        // `paint_body` CUPs each row independently, so multi-line
2534        // echo must emit each line through `push_body_row` separately.
2535        assert!(
2536            !s.contains("first\nsecond"),
2537            "multiline echo must not embed raw \\n in a single body row \
2538             (would corrupt alt-screen layout). got: {:?}",
2539            s
2540        );
2541    }
2542
2543    /// Phase 2: User / AssistantText / ToolCall / ToolResult / Error
2544    /// all push body rows. Verify each surfaces in the painted output.
2545    #[test]
2546    fn body_uilines_render_into_viewport() {
2547        // UiLine::Error localizes via i18n — pin the locale so a
2548        // concurrent test that flipped to ZhCn doesn't make this
2549        // assertion see `[错误:boom]`.
2550        let _g = crate::i18n::test_lock();
2551        crate::i18n::set_locale(crate::i18n::Locale::En);
2552        let mut buf = Vec::new();
2553        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2554        r.render(UiLine::User("hi".into()));
2555        r.render(UiLine::AssistantText("hello there\n".into()));
2556        r.render(UiLine::AssistantLineBreak);
2557        r.render(UiLine::ToolCall {
2558            name: "read_file".into(),
2559            detail: "x.rs".into(),
2560        });
2561        r.render(UiLine::ToolResult {
2562            success: true,
2563            summary: "ok".into(),
2564        });
2565        r.render(UiLine::Error("boom".into()));
2566        r.render(UiLine::TurnComplete);
2567        r.flush();
2568        drop(r);
2569        let s = String::from_utf8_lossy(&buf);
2570        assert!(s.contains("hi"), "user echo missing. got: {:?}", s);
2571        assert!(s.contains("hello there"), "assistant text missing. got: {:?}", s);
2572        assert!(s.contains("read_file"), "tool call name missing. got: {:?}", s);
2573        assert!(s.contains("ok"), "tool result summary missing. got: {:?}", s);
2574        assert!(s.contains("[Error: boom]"), "error line missing. got: {:?}", s);
2575    }
2576
2577    /// Each body push produces a paint cycle that EL-clears every row
2578    /// in the viewport (including ones past end-of-content) so a
2579    /// previous frame's content can't ghost. Phase 3: body_height =
2580    /// height − footer_rows, so verify the BODY rows specifically (1..=7
2581    /// when height=10, footer_rows=3) all get CUP+EL.
2582    #[test]
2583    fn paint_body_clears_every_viewport_row() {
2584        let mut buf = Vec::new();
2585        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
2586        r.render(UiLine::User("hi".into()));
2587        r.flush();
2588        drop(r);
2589        let s = String::from_utf8_lossy(&buf);
2590        // 10-row terminal − 3-row footer = 7-row body. Body paints
2591        // emit CUP+EL for rows 1..=7.
2592        for row in 1..=7u16 {
2593            assert!(
2594                s.contains(&format!("\x1b[{};1H", row)),
2595                "row {} CUP missing. got: {:?}",
2596                row,
2597                s
2598            );
2599        }
2600    }
2601
2602    /// Bounded buffer: when body_lines exceeds max_scrollback_rows,
2603    /// oldest rows drop from the front. Sanity-check via direct field
2604    /// access (bypass the env var by going through with_writer + manual
2605    /// max_scrollback_rows override via test-only API). Keep the cap
2606    /// small so the test runs fast.
2607    #[test]
2608    fn bounded_buffer_drops_front_rows_on_overflow() {
2609        let mut buf = Vec::new();
2610        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2611        // Override the cap directly. Field is private but we're in the
2612        // same module so this is fine for tests.
2613        r.max_scrollback_rows = 5;
2614        for i in 0..10 {
2615            r.push_body_row(format!("row {}", i));
2616        }
2617        // Cap is 5, pushed 10 → only the last 5 should remain (rows 5..9).
2618        assert_eq!(r.body_lines.len(), 5, "buffer must be capped at 5");
2619        assert_eq!(r.body_lines[0], "row 5");
2620        assert_eq!(r.body_lines[4], "row 9");
2621        drop(r);
2622    }
2623
2624    /// sticky_bottom (default) shows the TAIL of body_lines. With more
2625    /// body rows than viewport height, only the last viewport_height
2626    /// rows should be in the painted output.
2627    #[test]
2628    fn sticky_bottom_shows_tail_when_body_exceeds_viewport() {
2629        let mut buf = Vec::new();
2630        // Phase 4.5: footer reserves 5 rows. Use height=10 so body_height=5.
2631        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
2632        for i in 0..10 {
2633            r.push_body_row(format!("ROW{}", i));
2634        }
2635        r.body_dirty = true;
2636        r.paint_body();
2637        r.flush();
2638        drop(r);
2639        let s = String::from_utf8_lossy(&buf);
2640        // 5-row body viewport, 10 body rows → tail = ROW5..ROW9.
2641        // ROW0..ROW4 must NOT be in the most recent painted output.
2642        // Since each paint emits all 5 rows, the latest paint contains
2643        // ROW5..ROW9.
2644        for i in 5..10 {
2645            assert!(
2646                s.contains(&format!("ROW{}", i)),
2647                "expected ROW{} in tail. got: {:?}",
2648                i,
2649                s
2650            );
2651        }
2652        // The leading rows might still appear in EARLIER paints (one
2653        // per push_body_row when called via render()); we don't assert
2654        // their absence — only that the tail is present in the final
2655        // state. This test would need a "rendered final frame only"
2656        // helper for stronger assertions; out of scope for Phase 2.
2657    }
2658
2659    /// Assistant streaming: chunks accumulate in assistant_line_buf
2660    /// across multiple AssistantText events; complete physical lines
2661    /// (terminated by `\n`) get pushed into body_lines; trailing
2662    /// partial chunks stay in the buffer until AssistantLineBreak or
2663    /// TurnComplete flushes them.
2664    #[test]
2665    fn assistant_streaming_buffers_until_newline_or_break() {
2666        let mut buf = Vec::new();
2667        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2668        // First chunk has no newline — should buffer, not push.
2669        r.render(UiLine::AssistantText("hello ".into()));
2670        assert_eq!(r.body_lines.len(), 0, "no newline yet → no body row");
2671        assert_eq!(r.assistant_line_buf, "hello ");
2672
2673        // Second chunk completes the line with `\n` → push.
2674        r.render(UiLine::AssistantText("world\n".into()));
2675        assert_eq!(r.body_lines.len(), 1, "newline triggers push");
2676        assert_eq!(r.body_lines[0], "hello world");
2677        assert!(r.assistant_line_buf.is_empty(), "buffer drained on \\n");
2678
2679        // Trailing chunk without newline → buffer again.
2680        r.render(UiLine::AssistantText("tail ".into()));
2681        assert_eq!(r.body_lines.len(), 1, "trailing chunk doesn't push yet");
2682
2683        // AssistantLineBreak forces flush.
2684        r.render(UiLine::AssistantLineBreak);
2685        assert_eq!(r.body_lines.len(), 2, "AssistantLineBreak flushes");
2686        assert_eq!(r.body_lines[1], "tail ");
2687        drop(r);
2688    }
2689
2690    /// TurnSeparator pushes 3 rows: blank, ─── label ───, blank.
2691    /// Mirrors the visual breathing-room used by retained mode.
2692    #[test]
2693    fn turn_separator_pushes_three_rows() {
2694        let mut buf = Vec::new();
2695        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2696        r.render(UiLine::TurnSeparator {
2697            label: "Done".into(),
2698        });
2699        assert_eq!(r.body_lines.len(), 3);
2700        assert!(r.body_lines[0].is_empty(), "first row is blank spacer");
2701        assert!(r.body_lines[1].contains("Done"), "middle row has label");
2702        assert!(r.body_lines[1].contains("─"), "middle row has rule chars");
2703        assert!(r.body_lines[2].is_empty(), "third row is blank spacer");
2704        drop(r);
2705    }
2706
2707    /// Phase 3.5: assistant text routes through `markdown::render_line`,
2708    /// so inline markdown syntax (`**bold**`) becomes ANSI SGR (bold
2709    /// escape) when caps.colors is on. Verify a complete-line streaming
2710    /// sequence ends with a body row containing the bold SGR sequence.
2711    #[test]
2712    fn assistant_text_renders_inline_bold_via_markdown() {
2713        let mut buf = Vec::new();
2714        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2715        r.render(UiLine::AssistantText("This is **bold** text\n".into()));
2716        // After the newline the line gets pushed.
2717        assert_eq!(r.body_lines.len(), 1);
2718        let row = &r.body_lines[0];
2719        // Bold SGR is `\x1b[1m` ... `\x1b[22m` (or `\x1b[0m` reset).
2720        assert!(
2721            row.contains("\x1b[1m"),
2722            "bold SGR opener missing — markdown didn't fire. got: {:?}",
2723            row
2724        );
2725        assert!(row.contains("bold"), "literal text retained. got: {:?}", row);
2726        drop(r);
2727    }
2728
2729    /// Phase 3.5: `# Heading` becomes a styled body row (markdown
2730    /// renderer applies bold + colour for headings). Just verify the
2731    /// SGR emerges; we don't assert exact escape since the renderer
2732    /// may evolve heading style.
2733    #[test]
2734    fn assistant_heading_renders_with_sgr() {
2735        let mut buf = Vec::new();
2736        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2737        r.render(UiLine::AssistantText("# My Heading\n".into()));
2738        assert_eq!(r.body_lines.len(), 1);
2739        let row = &r.body_lines[0];
2740        assert!(
2741            row.contains("\x1b["),
2742            "heading should have SGR styling. got: {:?}",
2743            row
2744        );
2745        assert!(row.contains("My Heading"));
2746        drop(r);
2747    }
2748
2749    /// Phase 3.5 (updated for buffer-and-flush): a ```fenced``` block now
2750    /// buffers body lines until close fence. The fence-open line and each
2751    /// body line return None (no body row pushed). The close fence flushes
2752    /// the whole block as a single body row containing all lines (with
2753    /// per-line indent).
2754    #[test]
2755    fn fenced_code_block_state_carries_across_streaming_chunks() {
2756        let mut buf = Vec::new();
2757        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2758
2759        r.render(UiLine::AssistantText("```rust\n".into()));
2760        // Fence-open line doesn't render — md_state.in_code_block flips on,
2761        // no body row pushed and no body_dirty for an empty fence.
2762        assert_eq!(r.body_lines.len(), 0, "fence-open line must not push");
2763        assert!(r.md_state.in_code_block, "code-block state must flip on");
2764
2765        r.render(UiLine::AssistantText("let x = 1;\n".into()));
2766        // Buffered — no body row pushed yet, code_buf has 1 entry, state still on.
2767        assert_eq!(
2768            r.body_lines.len(),
2769            0,
2770            "body line inside open fence must buffer, not push"
2771        );
2772        assert_eq!(r.md_state.code_buf.len(), 1, "code_buf must hold the body line");
2773        assert!(r.md_state.in_code_block);
2774
2775        r.render(UiLine::AssistantText("```\n".into()));
2776        // Close fence flushes — state off, code_buf empty, at least one body row
2777        // pushed containing the flushed (highlighted-or-plain) block.
2778        assert!(!r.md_state.in_code_block, "code-block state must flip off");
2779        assert!(r.md_state.code_buf.is_empty(), "code_buf must be drained");
2780        assert!(r.body_lines.len() >= 1, "close fence must flush at least one body row");
2781        drop(r);
2782    }
2783
2784    /// Phase 3.5: `push_user` resets md_state so a previous turn's
2785    /// stuck-open fence can't bleed into the new turn.
2786    #[test]
2787    fn user_turn_resets_markdown_state() {
2788        let mut buf = Vec::new();
2789        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2790        // Open a fence in turn 1, never close.
2791        r.render(UiLine::AssistantText("```\n".into()));
2792        assert!(r.md_state.in_code_block);
2793
2794        // New user turn — md_state should reset.
2795        r.render(UiLine::User("next question".into()));
2796        assert!(
2797            !r.md_state.in_code_block,
2798            "User turn must reset md_state.in_code_block"
2799        );
2800        drop(r);
2801    }
2802
2803    /// `reset()` (and `clear_screen()` which forwards to reset) wipes
2804    /// body_lines and the assistant streaming buffer so the next paint
2805    /// starts from a blank slate.
2806    #[test]
2807    fn reset_wipes_body_lines_and_streaming_buffer() {
2808        let mut buf = Vec::new();
2809        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2810        r.render(UiLine::User("first".into()));
2811        r.render(UiLine::AssistantText("partial chunk".into()));
2812        assert!(!r.body_lines.is_empty());
2813        assert!(!r.assistant_line_buf.is_empty());
2814
2815        r.reset();
2816        assert!(r.body_lines.is_empty(), "body_lines wiped on reset");
2817        assert!(r.assistant_line_buf.is_empty(), "buffer wiped on reset");
2818        drop(r);
2819    }
2820
2821    /// Phase 4.5: footer is now 5 rows (spinner | top_rule | input |
2822    /// bot_rule | status). With height=10, footer_top=6, so:
2823    /// spinner@6, top_rule@7, input@8, bot_rule@9, status@10.
2824    #[test]
2825    fn input_prompt_renders_at_footer_with_cursor() {
2826        let mut buf = Vec::new();
2827        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
2828        r.render(UiLine::InputPrompt {
2829            buf: "hello".into(),
2830            cursor_byte: 5,
2831            menu: None,
2832            status: crate::render::StatusLine::default(),
2833            attachments: Vec::new(),
2834        });
2835        r.flush();
2836        drop(r);
2837        let s = String::from_utf8_lossy(&buf);
2838        assert!(s.contains("\x1b[8;1H"), "input row CUP at row 8 missing. got: {:?}", s);
2839        assert!(s.contains("hello"), "input buf missing. got: {:?}", s);
2840        // Cursor at row 8 col 8 (chevron 2 cols + 5 buf chars + 1 for
2841        // 1-indexed) followed by show-cursor.
2842        assert!(
2843            s.contains("\x1b[8;8H\x1b[?25h"),
2844            "cursor must be positioned at end of buf with show-cursor. got: {:?}",
2845            s
2846        );
2847    }
2848
2849    /// Phase 4.5: status bar at row 10 (height=10, last row).
2850    #[test]
2851    fn status_bar_renders_model_and_cwd() {
2852        let mut buf = Vec::new();
2853        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
2854        r.render(UiLine::InputPrompt {
2855            buf: "".into(),
2856            cursor_byte: 0,
2857            menu: None,
2858            status: crate::render::StatusLine {
2859                model: "claude-opus-4-7".into(),
2860                cwd: "/tmp/proj".into(),
2861                ..Default::default()
2862            },
2863            attachments: Vec::new(),
2864        });
2865        r.flush();
2866        drop(r);
2867        let s = String::from_utf8_lossy(&buf);
2868        assert!(s.contains("\x1b[10;1H"), "status row CUP at row 10 missing. got: {:?}", s);
2869        assert!(
2870            s.contains("claude-opus-4-7 \u{00b7} /tmp/proj"),
2871            "status content missing. got: {:?}",
2872            s
2873        );
2874        assert!(s.contains("\x1b[2m"), "status should be dim. got: {:?}", s);
2875    }
2876
2877    /// Phase 4.5: top + bottom rules render as cyan ━ across full width.
2878    /// (Heavy variant ━ U+2501 instead of light ─ U+2500 — see
2879    /// `paint_footer` for the legacy-conhost dashed-look rationale.)
2880    #[test]
2881    fn input_box_has_top_and_bottom_rules() {
2882        let mut buf = Vec::new();
2883        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 20, 10);
2884        r.render(UiLine::InputPrompt {
2885            buf: "".into(),
2886            cursor_byte: 0,
2887            menu: None,
2888            status: crate::render::StatusLine::default(),
2889            attachments: Vec::new(),
2890        });
2891        r.flush();
2892        drop(r);
2893        let s = String::from_utf8_lossy(&buf);
2894        // top_rule at row 7, bot_rule at row 9. Each row has 20 ━.
2895        let twenty_heavy = "━".repeat(20);
2896        assert!(s.contains("\x1b[7;1H"), "top rule row CUP missing. got: {:?}", s);
2897        assert!(s.contains("\x1b[9;1H"), "bot rule row CUP missing. got: {:?}", s);
2898        assert!(
2899            s.contains(&twenty_heavy),
2900            "20 ━ chars missing. got: {:?}",
2901            s
2902        );
2903        // Bright cyan (96) — matches retained's `Palette::BORDER`.
2904        assert!(s.contains("\x1b[96m"), "rule should be bright cyan. got: {:?}", s);
2905    }
2906
2907    /// `wrap_to_width_sgr_aware` is the soft-wrap helper that keeps long
2908    /// CommandOutput lines (notably the `/login` OAuth URL) selectable
2909    /// in alt-screen mode. Direct tests on the helper since it owns the
2910    /// CSI / Unicode-width edge cases.
2911    #[test]
2912    fn wrap_to_width_sgr_aware_handles_url_and_csi_and_wide_chars() {
2913        // Empty input still produces one (empty) chunk so callers
2914        // preserve the blank-line invariant.
2915        assert_eq!(wrap_to_width_sgr_aware("", 10), vec![String::new()]);
2916
2917        // Short line under width → single chunk, untouched.
2918        assert_eq!(
2919            wrap_to_width_sgr_aware("hello", 10),
2920            vec!["hello".to_string()]
2921        );
2922
2923        // Realistic OAuth URL ≈ 200 chars on an 80-col terminal: must
2924        // produce ≥ 3 chunks, every chunk ≤ 80 display cols, and the
2925        // concatenation must reproduce the input byte-for-byte.
2926        let url = "https://atomgit.com/oauth/authorize?client_id=85a8b0099b4144a19a7542d5cc90fdcc&redirect_uri=https%3A%2F%2Facs.atomgit.com%2Fcallback&response_type=code&state=atomcode_1777469916784730326_e2d348c6072a47beb1b0b414f25c8ef6&scope=user_info+projects";
2927        let chunks = wrap_to_width_sgr_aware(url, 80);
2928        assert!(chunks.len() >= 3, "URL must wrap into ≥3 chunks, got {}", chunks.len());
2929        for c in &chunks {
2930            assert!(
2931                line_display_width_sgr_aware(c) <= 80,
2932                "chunk exceeds width: {:?}",
2933                c
2934            );
2935        }
2936        assert_eq!(chunks.join(""), url, "wrapped chunks must round-trip");
2937
2938        // CSI sequences contribute zero width and stay attached to
2939        // their current chunk (no spurious wraps mid-escape).
2940        let with_sgr = format!("\x1b[31m{}\x1b[0m", "x".repeat(10));
2941        let chunks = wrap_to_width_sgr_aware(&with_sgr, 5);
2942        assert_eq!(chunks.len(), 2, "10 visible chars at width 5 → 2 chunks");
2943        assert!(chunks[0].contains("\x1b[31m"), "opening SGR stays in first chunk");
2944        assert_eq!(chunks.iter().map(|c| c.len()).sum::<usize>(), with_sgr.len());
2945
2946        // Wide CJK glyph (2 cells) at the boundary wraps cleanly
2947        // instead of being split across chunks.
2948        let cjk = "ab中文de"; // widths: 1 1 2 2 1 1 = 8
2949        let chunks = wrap_to_width_sgr_aware(cjk, 3);
2950        for c in &chunks {
2951            assert!(line_display_width_sgr_aware(c) <= 3);
2952        }
2953        assert_eq!(chunks.join(""), cjk);
2954    }
2955
2956    /// Long `CommandOutput` (e.g. the OAuth URL) must end up as multiple
2957    /// body rows so the entire content is visible AND selectable in
2958    /// alt-screen mode. Regression: previously a 200-char URL became
2959    /// one body row that `paint_body` truncated at the right edge,
2960    /// making the tail uncopyable.
2961    #[test]
2962    fn command_output_wraps_long_url_into_multiple_body_rows() {
2963        let mut buf = Vec::new();
2964        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2965        let url = "https://atomgit.com/oauth/authorize?client_id=85a8b0099b4144a19a7542d5cc90fdcc&redirect_uri=https%3A%2F%2Facs.atomgit.com%2Fcallback&response_type=code&state=atomcode_1777469916784730326_e2d348c6072a47beb1b0b414f25c8ef6&scope=user_info+projects";
2966        let body = format!("  Open this URL in any browser to sign in to AtomGit:\n  {}\n", url);
2967        r.render(UiLine::CommandOutput(body));
2968        r.flush();
2969        // Header line + ≥3 wrapped URL rows + trailing blank.
2970        assert!(
2971            r.body_lines.len() >= 4,
2972            "long URL must wrap into ≥4 body rows, got {}: {:#?}",
2973            r.body_lines.len(),
2974            r.body_lines
2975        );
2976        for line in &r.body_lines {
2977            assert!(
2978                line_display_width_sgr_aware(line) <= 80,
2979                "body row exceeds 80 cols: {:?}",
2980                line
2981            );
2982        }
2983        // Every byte of the URL must survive somewhere in body_lines so
2984        // the user can still select-and-copy the whole thing.
2985        let joined: String = r.body_lines.iter().cloned().collect::<Vec<_>>().join("");
2986        assert!(
2987            joined.contains(url),
2988            "wrapped body rows must reconstruct the full URL"
2989        );
2990        drop(r);
2991    }
2992
2993    /// Phase 4.5: slash menu palette grows the footer dynamically.
2994    /// 4 menu items → footer_rows = 5 + 4 = 9. body_height shrinks.
2995    #[test]
2996    fn slash_menu_grows_footer_and_shrinks_body() {
2997        let mut buf = Vec::new();
2998        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
2999        let baseline_body = r.body_height();
3000        assert_eq!(baseline_body, 24 - 5, "no menu → body = 24 - 5 = 19");
3001
3002        r.render(UiLine::InputPrompt {
3003            buf: "/".into(),
3004            cursor_byte: 1,
3005            menu: Some(crate::render::MenuPayload {
3006                items: vec![
3007                    ("login".into(), "sign in".into()),
3008                    ("model".into(), "switch model".into()),
3009                    ("exit".into(), "leave".into()),
3010                ],
3011                selected: 0,
3012                    kind: crate::render::MenuKind::SlashCommand,
3013            }),
3014            status: crate::render::StatusLine::default(),
3015            attachments: Vec::new(),
3016        });
3017        // 3 menu items → footer = 5 + 3 = 8 → body = 24 - 8 = 16.
3018        assert_eq!(r.body_height(), 24 - 8);
3019        drop(r);
3020    }
3021
3022    /// Phase 4.5: selected menu item gets reverse-video SGR (`\x1b[7m`)
3023    /// so keyboard focus is highly visible. Non-selected items get dim.
3024    #[test]
3025    fn slash_menu_selected_uses_reverse_video() {
3026        let mut buf = Vec::new();
3027        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3028        r.render(UiLine::InputPrompt {
3029            buf: "/".into(),
3030            cursor_byte: 1,
3031            menu: Some(crate::render::MenuPayload {
3032                items: vec![
3033                    ("login".into(), "sign in".into()),
3034                    ("exit".into(), "leave".into()),
3035                ],
3036                selected: 1,
3037                    kind: crate::render::MenuKind::SlashCommand,
3038            }),
3039            status: crate::render::StatusLine::default(),
3040            attachments: Vec::new(),
3041        });
3042        r.flush();
3043        drop(r);
3044        let s = String::from_utf8_lossy(&buf);
3045        assert!(
3046            s.contains("\x1b[7m"),
3047            "selected menu row should use reverse video. got: {:?}",
3048            s
3049        );
3050        // Both items present.
3051        assert!(s.contains("login"));
3052        assert!(s.contains("exit"));
3053    }
3054
3055    /// Long CJK descriptions (plugin skill listings can have 100+
3056    /// display columns of Chinese) used to overflow past terminal
3057    /// width and auto-wrap onto subsequent rows. The next iteration's
3058    /// CUP+EL only wiped the immediately-next row, so 2+ row wraps
3059    /// leaked stale glyphs into column 1+ of later menu items.
3060    /// Truncating each menu body to terminal width keeps everything
3061    /// confined to a single row per item.
3062    #[test]
3063    fn slash_menu_truncates_overlong_body_to_terminal_width() {
3064        let mut buf = Vec::new();
3065        // Narrow window to make overflow easy to construct without huge
3066        // descriptions: 30 cols total.
3067        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 30, 24);
3068        // First item's description is 60+ display cols of CJK, ~2× wider
3069        // than the window. Pre-fix this would wrap onto the second
3070        // item's row. Post-fix: clamped at 30 cols, no wrap.
3071        let very_long_cjk = "中文描述非常非常长".repeat(5); // 9 chars * 5 = 45 chars * 2 cols = 90 cols
3072        r.render(UiLine::InputPrompt {
3073            buf: "/".into(),
3074            cursor_byte: 1,
3075            menu: Some(crate::render::MenuPayload {
3076                items: vec![
3077                    ("first".into(), very_long_cjk.clone()),
3078                    ("second".into(), "short".into()),
3079                ],
3080                selected: 0,
3081                    kind: crate::render::MenuKind::SlashCommand,
3082            }),
3083            status: crate::render::StatusLine::default(),
3084            attachments: Vec::new(),
3085        });
3086        r.flush();
3087        // Assert each menu row's writeable payload between CUPs fits
3088        // inside the 30-col window. We can't easily measure visible
3089        // columns from raw bytes here, but we can assert truncation
3090        // happened by checking the second item's name is still emitted
3091        // (it would be drowned by an unbounded first-row wrap).
3092        let body_lines = r.body_lines.clone();
3093        drop(r);
3094        let s = String::from_utf8_lossy(&buf);
3095        assert!(
3096            s.contains("first"),
3097            "first item must be present in output. got: {:?}",
3098            s
3099        );
3100        assert!(
3101            s.contains("second"),
3102            "second item must remain visible despite first row's overlong CJK. got: {:?}",
3103            s
3104        );
3105        // The full 90-col CJK description must NOT all be present
3106        // verbatim — it would only fit if the truncation was bypassed.
3107        assert!(
3108            !s.contains(very_long_cjk.as_str()),
3109            "full overlong CJK description must be truncated, but emit kept the entire run. got: {:?}",
3110            s
3111        );
3112        let _ = body_lines;
3113    }
3114
3115    /// Phase 4.5: welcome banner now includes the version (right-aligned)
3116    /// and the onboarding hints (`type something...`, `/provider...`).
3117    #[test]
3118    fn welcome_includes_version_and_hint_lines() {
3119        let mut buf = Vec::new();
3120        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3121        r.render(UiLine::Welcome {
3122            model: "claude-opus-4-7".into(),
3123            working_dir: "/tmp/proj".into(),
3124        });
3125        r.flush();
3126        drop(r);
3127        let s = String::from_utf8_lossy(&buf);
3128        assert!(s.contains("AtomCode"));
3129        assert!(s.contains("MIT"), "license MIT missing from banner. got: {:?}", s);
3130        assert!(s.contains("type something"), "hint A missing. got: {:?}", s);
3131        assert!(s.contains("/provider"), "hint B missing. got: {:?}", s);
3132    }
3133
3134    /// Phase 3: Spinner sets the spinner-row content; ClearTransient
3135    /// wipes it. Spinner row is footer-top (row N-2 for footer_rows=3).
3136    #[test]
3137    fn spinner_renders_at_footer_top() {
3138        let mut buf = Vec::new();
3139        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3140        r.render(UiLine::Spinner {
3141            frame: "\u{280b}",
3142            label: "Thinking".into(),
3143        });
3144        r.flush();
3145        drop(r);
3146        let s = String::from_utf8_lossy(&buf);
3147        // Spinner row CUP at row 8 + label.
3148        assert!(s.contains("\x1b[8;1H"), "spinner row CUP missing. got: {:?}", s);
3149        assert!(s.contains("Thinking"), "spinner label missing. got: {:?}", s);
3150    }
3151
3152    /// The spinner FRAME (the rotating glyph) must be coloured brand
3153    /// magenta (`\x1b[95m`) when caps.colors is on — visual anchor for
3154    /// the rotation. Label is bold default-fg (mirrors retained's
3155    /// `style_bold(Role::Secondary)` in build_spinner_body_row); the
3156    /// previous SGR_DIM choice rendered as hard-to-read mid-gray on
3157    /// Windows legacy conhost.
3158    #[test]
3159    fn spinner_frame_uses_brand_magenta() {
3160        let mut buf = Vec::new();
3161        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3162        r.render(UiLine::Spinner {
3163            frame: "\u{280b}",
3164            label: "Thinking".into(),
3165        });
3166        r.flush();
3167        drop(r);
3168        let s = String::from_utf8_lossy(&buf);
3169        assert!(
3170            s.contains("\x1b[95m\u{280b}\x1b[0m"),
3171            "spinner frame must be wrapped in magenta SGR. got: {:?}",
3172            s
3173        );
3174        // Label is bold + default-fg — bold SGR (\x1b[1m) wraps the
3175        // label, no foreground colour change. Co-exists with the
3176        // magenta frame SGR on the same row.
3177        assert!(
3178            s.contains("\x1b[1m"),
3179            "label should be wrapped in bold SGR. got: {:?}",
3180            s
3181        );
3182        assert!(
3183            !s.contains("\x1b[2m"),
3184            "label must not use dim SGR (broken on Windows conhost). got: {:?}",
3185            s
3186        );
3187    }
3188
3189    /// `ClearTransient` flips `pending_spinner` back to None so the
3190    /// next paint of the spinner row emits only EL (no content).
3191    /// Verify by inspecting field state directly — checking the byte
3192    /// stream in the cumulative buffer is fragile because the spinner
3193    /// row gets repainted multiple times.
3194    #[test]
3195    fn clear_transient_drops_pending_spinner() {
3196        let mut buf = Vec::new();
3197        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3198        r.render(UiLine::Spinner {
3199            frame: "\u{280b}",
3200            label: "Thinking".into(),
3201        });
3202        assert!(r.pending_spinner.is_some(), "spinner should be active");
3203        r.render(UiLine::ClearTransient);
3204        assert!(r.pending_spinner.is_none(), "ClearTransient must drop spinner");
3205        drop(r);
3206    }
3207
3208    /// Plan-mode badge gets brand-color SGR (magenta, mirrors retained
3209    /// renderer's `Role::Brand`) and is emitted BEFORE the dim
3210    /// `model · cwd` body so the user sees the mode at a glance. Same
3211    /// layout as the retained `build_status_row` test, just at the
3212    /// alt-screen byte-stream level since alt-screen writes raw to
3213    /// stdout instead of going through the cell-diff renderer.
3214    #[test]
3215    fn paint_footer_renders_plan_badge_in_brand_color() {
3216        let mut buf = Vec::new();
3217        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3218        r.render(UiLine::InputPrompt {
3219            buf: "".into(),
3220            cursor_byte: 0,
3221            menu: None,
3222            status: crate::render::StatusLine {
3223                model: "glm-5".into(),
3224                cwd: "~/proj".into(),
3225                ctx_used: 0,
3226                ctx_window: 0,
3227                hint: None,
3228                mode_indicator: Some("PLAN".into()),
3229                session_name: None,
3230            },
3231            attachments: Vec::new(),
3232        });
3233        r.flush();
3234        drop(r);
3235        let s = String::from_utf8_lossy(&buf);
3236        assert!(
3237            s.contains("\x1b[95m"),
3238            "PLAN badge must use SGR_MAGENTA (Role::Brand). got: {:?}",
3239            s
3240        );
3241        assert!(
3242            s.contains("PLAN"),
3243            "PLAN literal must appear in the rendered status. got: {:?}",
3244            s
3245        );
3246        // Badge precedes the dim model/cwd run — confirm the magenta SGR
3247        // appears earlier in the byte stream than the dim SGR (\x1b[2m).
3248        let badge_pos = s
3249            .find("\x1b[95m")
3250            .expect("magenta SGR must be present");
3251        let dim_pos = s
3252            .find("\x1b[2m")
3253            .expect("dim SGR (status body) must be present");
3254        assert!(
3255            badge_pos < dim_pos,
3256            "PLAN badge SGR ({}) must precede status-body dim SGR ({}). buf: {:?}",
3257            badge_pos,
3258            dim_pos,
3259            s
3260        );
3261    }
3262
3263    /// After the user runs `/rename`, the conversation name should
3264    /// appear as a right-aligned cyan-bg pill overlaid on the top
3265    /// rule of the input box. Mirrors CC's per-conversation badge so
3266    /// users can confirm which session they're typing into without
3267    /// running `/status`.
3268    #[test]
3269    fn paint_footer_renders_session_name_badge_in_reverse_cyan() {
3270        let mut buf = Vec::new();
3271        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3272        r.render(UiLine::InputPrompt {
3273            buf: "".into(),
3274            cursor_byte: 0,
3275            menu: None,
3276            status: crate::render::StatusLine {
3277                model: "glm-5".into(),
3278                cwd: "~/proj".into(),
3279                ctx_used: 0,
3280                ctx_window: 0,
3281                hint: None,
3282                mode_indicator: None,
3283                session_name: Some("atomcode加解密".into()),
3284            },
3285            attachments: Vec::new(),
3286        });
3287        r.flush();
3288        drop(r);
3289        let s = String::from_utf8_lossy(&buf);
3290        assert!(
3291            s.contains("atomcode加解密"),
3292            "session name literal must appear in the rendered footer. got: {:?}",
3293            s
3294        );
3295        // Pill style: SGR_REVERSE (7m) combined with SGR_CYAN (96m) to
3296        // paint a cyan-filled chip. The exact concatenation order isn't
3297        // load-bearing — only that both attributes are emitted somewhere
3298        // in the same byte stream.
3299        assert!(
3300            s.contains("\x1b[7m") || s.contains("\x1b[7;"),
3301            "session-name pill must emit reverse-video SGR (7m). got: {:?}",
3302            s
3303        );
3304        assert!(
3305            s.contains("\x1b[96m") || s.contains(";96m"),
3306            "session-name pill must emit cyan SGR (96m). got: {:?}",
3307            s
3308        );
3309    }
3310
3311    /// When `session_name = None` (auto-named / fresh session) the
3312    /// footer must NOT emit the reverse-cyan pill on the top rule —
3313    /// guards against the badge leaking onto sessions the user hasn't
3314    /// explicitly renamed. We check for the exact `\x1b[7;96m` /
3315    /// `\x1b[96;7m` SGR combo rather than a bare `\x1b[7;` prefix
3316    /// because the buffer is full of CUP escapes like `\x1b[7;1H`
3317    /// (cursor to row 7) which share that prefix but aren't SGR.
3318    #[test]
3319    fn paint_footer_no_session_name_emits_no_pill() {
3320        let mut buf = Vec::new();
3321        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3322        r.render(UiLine::InputPrompt {
3323            buf: "".into(),
3324            cursor_byte: 0,
3325            menu: None,
3326            status: crate::render::StatusLine {
3327                model: "glm-5".into(),
3328                cwd: "~/proj".into(),
3329                ctx_used: 0,
3330                ctx_window: 0,
3331                hint: None,
3332                mode_indicator: None,
3333                session_name: None,
3334            },
3335            attachments: Vec::new(),
3336        });
3337        r.flush();
3338        drop(r);
3339        let s = String::from_utf8_lossy(&buf);
3340        assert!(
3341            !s.contains("\x1b[7;96m") && !s.contains("\x1b[96;7m") && !s.contains("\x1b[7m"),
3342            "no session_name must produce no reverse-cyan pill SGR. got: {:?}",
3343            s
3344        );
3345    }
3346
3347    /// A name wider than the available budget gets ellipsised — the
3348    /// alternative is either overflowing the terminal width (visible
3349    /// wrap glitch) or swallowing the entire top rule (badge eats the
3350    /// border, the input box loses its visual anchor). Budget leaves
3351    /// at least 8 cells of `━` rule to the left so the box still
3352    /// reads as bordered.
3353    #[test]
3354    fn paint_footer_truncates_overlong_session_name() {
3355        let mut buf = Vec::new();
3356        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 40, 24);
3357        let very_long = "这是一个非常非常非常长的会话名字应该被截断省略";
3358        r.render(UiLine::InputPrompt {
3359            buf: "".into(),
3360            cursor_byte: 0,
3361            menu: None,
3362            status: crate::render::StatusLine {
3363                model: "glm-5".into(),
3364                cwd: "~/proj".into(),
3365                ctx_used: 0,
3366                ctx_window: 0,
3367                hint: None,
3368                mode_indicator: None,
3369                session_name: Some(very_long.into()),
3370            },
3371            attachments: Vec::new(),
3372        });
3373        r.flush();
3374        drop(r);
3375        let s = String::from_utf8_lossy(&buf);
3376        assert!(
3377            s.contains('…'),
3378            "overlong session name must be truncated with ellipsis. got: {:?}",
3379            s
3380        );
3381        assert!(
3382            !s.contains(very_long),
3383            "full overlong name must NOT appear verbatim (it'd overflow the rule). got: {:?}",
3384            s
3385        );
3386    }
3387
3388    /// Default Build mode (`mode_indicator = None`) emits no PLAN
3389    /// literal — protects against accidental "PLAN" leak when the
3390    /// status line is rendered for a non-plan session. Mirrors the
3391    /// retained-renderer guard test.
3392    #[test]
3393    fn paint_footer_default_mode_emits_no_plan_badge() {
3394        let mut buf = Vec::new();
3395        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3396        r.render(UiLine::InputPrompt {
3397            buf: "".into(),
3398            cursor_byte: 0,
3399            menu: None,
3400            status: crate::render::StatusLine {
3401                model: "glm-5".into(),
3402                cwd: "~/proj".into(),
3403                ctx_used: 0,
3404                ctx_window: 0,
3405                hint: None,
3406                mode_indicator: None,
3407                session_name: None,
3408            },
3409            attachments: Vec::new(),
3410        });
3411        r.flush();
3412        drop(r);
3413        let s = String::from_utf8_lossy(&buf);
3414        assert!(
3415            !s.contains("PLAN"),
3416            "no mode_indicator must produce no PLAN literal. got: {:?}",
3417            s
3418        );
3419        // Sanity: model/cwd still present so we know the status row
3420        // actually rendered (not skipped via some empty-status path).
3421        assert!(s.contains("glm-5"));
3422        assert!(s.contains("~/proj"));
3423    }
3424
3425    /// `on_resize` is a no-op when the size hasn't actually changed.
3426    /// Some terminals fire spurious Resize events on focus / tab /
3427    /// pane-shuffle (no grid change), and the `\x1b[2J\x1b[H` wipe
3428    /// inside the resize handler is visible flicker even when the
3429    /// outcome would be byte-identical. Pairs with the burst-coalesce
3430    /// in `event_loop::handle_input`. Linux Mint / gnome-terminal
3431    /// users reported "拉伸窗口刷屏" for exactly this reason.
3432    #[test]
3433    fn on_resize_same_size_emits_nothing() {
3434        // Drive two AltScreenRenderer instances against separate
3435        // capture buffers — one runs a same-size on_resize, the other
3436        // runs a real resize. Compare their output. (Single-renderer
3437        // pattern doesn't work because `with_writer` keeps the &mut
3438        // Vec borrow alive for the renderer's lifetime.)
3439        let mut baseline = Vec::new();
3440        {
3441            let mut r = AltScreenRenderer::with_writer(&mut baseline, caps_default(), 80, 24);
3442            r.render(UiLine::User("hi".into()));
3443            r.flush();
3444            r.on_resize(80, 24); // same size — should be a no-op
3445            drop(r);
3446        }
3447
3448        let mut real_resize = Vec::new();
3449        {
3450            let mut r = AltScreenRenderer::with_writer(&mut real_resize, caps_default(), 80, 24);
3451            r.render(UiLine::User("hi".into()));
3452            r.flush();
3453            r.on_resize(60, 16); // different size — should emit wipe + repaint
3454            drop(r);
3455        }
3456
3457        let baseline_str = String::from_utf8_lossy(&baseline);
3458        let real_str = String::from_utf8_lossy(&real_resize);
3459        assert!(
3460            !baseline_str.contains("\x1b[2J\x1b[H"),
3461            "same-size on_resize must not emit \\x1b[2J\\x1b[H wipe (flicker source). \
3462             baseline: {:?}",
3463            baseline_str
3464        );
3465        assert!(
3466            real_str.contains("\x1b[2J\x1b[H"),
3467            "real resize MUST still emit \\x1b[2J\\x1b[H wipe; got: {:?}",
3468            real_str
3469        );
3470    }
3471
3472    /// Phase 4: `on_resize` updates cached dimensions, wipes the
3473    /// alt-screen, and repaints. body_lines are kept verbatim — paint
3474    /// truncates each row to the new width on the fly so we don't have
3475    /// to re-flow at resize time.
3476    #[test]
3477    fn on_resize_updates_dimensions_and_repaints() {
3478        let mut buf = Vec::new();
3479        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3480        r.render(UiLine::User("hi".into()));
3481        assert_eq!(r.width, 80);
3482        assert_eq!(r.height, 24);
3483
3484        r.on_resize(60, 16);
3485        assert_eq!(r.width, 60);
3486        assert_eq!(r.height, 16);
3487        // body_height = 16 - 5 = 11.
3488        assert_eq!(r.body_height(), 11);
3489
3490        drop(r);
3491        let s = String::from_utf8_lossy(&buf);
3492        assert!(
3493            s.contains("\x1b[2J\x1b[H"),
3494            "on_resize should wipe screen. got: {:?}",
3495            s
3496        );
3497    }
3498
3499    /// Phase 4: long body lines get clipped to terminal width at paint
3500    /// time so they don't autowrap into the next row's slot. `truncate_to_width`
3501    /// is SGR-aware (skips ESC chars in width count) so colour styling
3502    /// survives the clip.
3503    #[test]
3504    fn paint_body_clips_long_lines_to_width() {
3505        let mut buf = Vec::new();
3506        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 20, 10);
3507        // Push a row much longer than terminal width — 50 chars.
3508        let long = "a".repeat(50);
3509        r.push_body_row(long);
3510        r.body_dirty = true;
3511        r.paint_body();
3512        r.flush();
3513        drop(r);
3514        let s = String::from_utf8_lossy(&buf);
3515        // The terminal is 20 cols wide. After paint, the line should
3516        // appear at most 20 a's in a single row (no autowrap into
3517        // the next row).
3518        let twenty_a = "a".repeat(20);
3519        assert!(
3520            s.contains(&twenty_a),
3521            "20 a's should appear (the visible portion). got: {:?}",
3522            s
3523        );
3524        // 21 a's must NOT appear consecutively — that would mean we
3525        // failed to truncate and the terminal autowrapped.
3526        let twenty_one_a = "a".repeat(21);
3527        assert!(
3528            !s.contains(&twenty_one_a),
3529            "long line should be truncated to 20 cols. got: {:?}",
3530            s
3531        );
3532    }
3533
3534    /// Phase 4: paint emits SGR reset after every row so an open
3535    /// colour span on one row can't leak into the next row's CUP+EL
3536    /// region. Verify the reset sequence appears in the output.
3537    #[test]
3538    fn paint_body_appends_sgr_reset_per_row() {
3539        let mut buf = Vec::new();
3540        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3541        r.render(UiLine::User("hi".into()));
3542        r.flush();
3543        drop(r);
3544        let s = String::from_utf8_lossy(&buf);
3545        assert!(
3546            s.contains("\x1b[0m"),
3547            "expected SGR reset after at least one body row. got: {:?}",
3548            s
3549        );
3550    }
3551
3552    /// scroll_body with negative delta scrolls UP (towards older
3553    /// content), breaks sticky_bottom, and the next paint shows
3554    /// earlier rows.
3555    #[test]
3556    fn scroll_body_up_breaks_sticky_and_shows_older_rows() {
3557        let mut buf = Vec::new();
3558        // height=10 → body_height=5 (Phase 4.5: footer is 5 rows).
3559        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3560        for i in 0..20 {
3561            r.push_body_row(format!("R{:02}", i));
3562        }
3563        assert!(r.sticky_bottom);
3564        r.scroll_body(-5);
3565        assert!(!r.sticky_bottom, "scroll up must break sticky_bottom");
3566        // viewport_top: max_top = 20 - 5 = 15, after -5 → 10.
3567        assert_eq!(r.viewport_top, 10);
3568        drop(r);
3569    }
3570
3571    /// scroll_body that lands at max_top (or past) re-pins sticky.
3572    /// Verifies the auto-follow-on-scroll-down behaviour.
3573    #[test]
3574    fn scroll_body_down_to_end_re_pins_sticky() {
3575        let mut buf = Vec::new();
3576        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3577        for i in 0..20 {
3578            r.push_body_row(format!("R{:02}", i));
3579        }
3580        r.scroll_body(-5); // up first
3581        assert!(!r.sticky_bottom);
3582        // Scroll down enough to pass max_top (5 was distance up, scroll
3583        // down 10 should overshoot and clamp).
3584        r.scroll_body(10);
3585        assert!(r.sticky_bottom, "reaching max_top must re-stick to bottom");
3586        drop(r);
3587    }
3588
3589    /// scroll_body_to_top jumps viewport_top to 0 and clears sticky.
3590    #[test]
3591    fn scroll_body_to_top_jumps_to_zero() {
3592        let mut buf = Vec::new();
3593        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3594        for i in 0..20 {
3595            r.push_body_row(format!("R{:02}", i));
3596        }
3597        r.scroll_body_to_top();
3598        assert_eq!(r.viewport_top, 0);
3599        assert!(!r.sticky_bottom);
3600        drop(r);
3601    }
3602
3603    /// scroll_body_to_bottom jumps to max_top and re-pins sticky.
3604    #[test]
3605    fn scroll_body_to_bottom_jumps_to_max_top_and_sticks() {
3606        let mut buf = Vec::new();
3607        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3608        for i in 0..20 {
3609            r.push_body_row(format!("R{:02}", i));
3610        }
3611        r.scroll_body_to_top();
3612        r.scroll_body_to_bottom();
3613        // body_height = 5, total = 20, max_top = 15.
3614        assert_eq!(r.viewport_top, 15);
3615        assert!(r.sticky_bottom);
3616        drop(r);
3617    }
3618
3619    /// While scrolled up, new body content arrives via push_body_row.
3620    /// sticky_bottom is false → viewport_top stays put → user keeps
3621    /// looking at old content. body_dirty flips so next paint reflects
3622    /// the new buffer length but visible content is the same. (When
3623    /// new content pushes the user's snapshot out of the bounded buffer
3624    /// front, viewport_top would shift; that's the bounded-buffer test.)
3625    #[test]
3626    fn new_content_during_scroll_holds_user_position() {
3627        let mut buf = Vec::new();
3628        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3629        for i in 0..20 {
3630            r.push_body_row(format!("R{:02}", i));
3631        }
3632        r.scroll_body(-5);
3633        let pinned_top = r.viewport_top;
3634        // Append new content while user is scrolled up.
3635        r.push_body_row("NEW".into());
3636        // viewport_top unchanged because sticky_bottom was false.
3637        assert_eq!(r.viewport_top, pinned_top);
3638        assert!(!r.sticky_bottom);
3639        drop(r);
3640    }
3641
3642    /// Phase 4 edge case: resize that puts viewport_top past the new
3643    /// end-of-buffer must clamp viewport_top instead of leaving it
3644    /// in an out-of-range state.
3645    #[test]
3646    fn on_resize_clamps_viewport_top_when_buffer_shorter_than_viewport() {
3647        let mut buf = Vec::new();
3648        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3649        // Push 5 rows; resize to a height that gives body_height=10.
3650        // viewport_top should clamp to body_lines.len() - body_height,
3651        // saturating to 0 because 5 < 10.
3652        for i in 0..5 {
3653            r.push_body_row(format!("r{}", i));
3654        }
3655        r.viewport_top = 3; // simulate user scrolled up
3656        r.on_resize(80, 13); // body_height = 13 - 3 = 10
3657        assert_eq!(
3658            r.viewport_top, 0,
3659            "viewport_top must clamp to 0 when body_lines.len() < body_height"
3660        );
3661        drop(r);
3662    }
3663
3664    /// `with_writer` takes terminal width/height; `body_height()`
3665    /// subtracts footer_rows. Verify the math + saturating-min.
3666    #[test]
3667    fn body_height_subtracts_footer_rows_with_min_one() {
3668        let mut buf = Vec::new();
3669        let r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3670        // height=10, footer base = 5 (no menu) → body_height=5.
3671        assert_eq!(r.body_height(), 5);
3672        drop(r);
3673
3674        // Tiny terminal: height=2, footer would consume all → degrade
3675        // to body_height=1 (saturating min) instead of 0 / underflow.
3676        let mut buf = Vec::new();
3677        let r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 2);
3678        assert_eq!(r.body_height(), 1);
3679        drop(r);
3680    }
3681
3682    /// `suspend_for_external` pops alt-screen so a child process
3683    /// sees the host terminal's main screen; `resume` re-enters.
3684    /// Used by the OAuth login flow and any future shell-out.
3685    #[test]
3686    fn suspend_resume_pops_and_re_enters_alt_screen() {
3687        let mut buf = Vec::new();
3688        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3689        r.suspend_for_external();
3690        r.resume_from_external();
3691        drop(r);
3692        let s = String::from_utf8_lossy(&buf);
3693        // Sequence on the wire should be: enter, leave (suspend),
3694        // enter again (resume), leave (drop). Two of each.
3695        assert_eq!(
3696            s.matches("\x1b[?1049h").count(),
3697            2,
3698            "expected two ENTERs (construct + resume). got: {:?}",
3699            s
3700        );
3701        assert_eq!(
3702            s.matches("\x1b[?1049l").count(),
3703            2,
3704            "expected two LEAVEs (suspend + drop). got: {:?}",
3705            s
3706        );
3707    }
3708
3709    /// Regression: when scrollback navigation runs (PageUp / Shift+Up /
3710    /// mouse wheel) the body region repaints but the terminal cursor
3711    /// must stay in the input row at the right buf-prefix offset.
3712    /// Earlier `scroll_body` only flipped `body_dirty`, leaving
3713    /// `footer_dirty=false` and skipping the input-row CUP at the
3714    /// end of `paint_footer` — symptom: cursor stranded mid-body
3715    /// at the last paint_body row, where the user's next keystroke
3716    /// would visually echo into the conversation history rather than
3717    /// the input box. Both flags now get set.
3718    #[test]
3719    fn scroll_repositions_terminal_cursor_into_input_row() {
3720        let mut buf = Vec::new();
3721        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3722        // Set up an active InputPrompt so paint_footer has cursor data.
3723        r.render(UiLine::InputPrompt {
3724            buf: "hello".into(),
3725            cursor_byte: 5,
3726            menu: None,
3727            status: crate::render::StatusLine::default(),
3728            attachments: Vec::new(),
3729        });
3730        // Push enough body to give scrollback room.
3731        for i in 0..20 {
3732            r.push_body_row(format!("R{:02}", i));
3733        }
3734        // Scroll then drop so we can read `buf` cleanly. The post-scroll
3735        // bytes include both the scroll repaint AND the alt-screen pop
3736        // sequence; we assert on the cursor CUP being present anywhere
3737        // in those bytes — paint_body alone never emits `\x1b[8;...H`
3738        // followed by show-cursor (only paint_footer does).
3739        r.scroll_body(-3);
3740        drop(r);
3741        let s = String::from_utf8_lossy(&buf);
3742        // Input row is at row 8 (height 10 - footer 5 + 3 = row 8).
3743        // After scroll, paint_footer must emit a CUP back to row 8
3744        // (the input row) followed by show-cursor — otherwise the
3745        // terminal cursor stays in the last body row. We assert at
3746        // least one `\x1b[8;{col}H\x1b[?25h` sequence is in the
3747        // post-scroll bytes.
3748        assert!(
3749            s.contains("\x1b[8;") && s.contains("H\x1b[?25h"),
3750            "scroll must re-emit the input-row cursor CUP. got: {:?}",
3751            s
3752        );
3753    }
3754
3755    /// Regression: on slow-paint terminals (JediTerm, legacy conhost),
3756    /// every paint_frame must start by hiding the cursor so its
3757    /// journey through ~10+ intermediate CUP positions (one per body
3758    /// row, one per footer row) isn't visible to the user. paint_footer
3759    /// re-emits show-cursor at its tail when pending_input is set, so
3760    /// the cursor only appears once at its final position. Reported in
3761    /// Android Studio's terminal as "cursor jumps around when scrolling
3762    /// history".
3763    #[test]
3764    fn paint_frame_hides_cursor_before_painting_on_slow_terminal() {
3765        let mut buf = Vec::new();
3766        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3767        r.slow_paint_terminal = true;
3768        // Force a paint via any body push.
3769        r.render(UiLine::User("hello".into()));
3770        drop(r);
3771        let s = String::from_utf8_lossy(&buf);
3772        // Hide-cursor (`\x1b[?25l`) must precede the body row CUP
3773        // sequences — proves we hide before painting, not after.
3774        let hide_pos = s.find("\x1b[?25l").expect("hide-cursor sequence missing");
3775        let first_body_cup = s.find("\x1b[1;1H\x1b[K")
3776            .expect("body row 1 CUP+EL missing");
3777        assert!(
3778            hide_pos < first_body_cup,
3779            "hide-cursor must come before the first body CUP. hide@{}, body@{}, output: {:?}",
3780            hide_pos,
3781            first_body_cup,
3782            s
3783        );
3784    }
3785
3786    /// Regression: on fast terminals (default), paint_frame must NOT
3787    /// emit `?25l` before paint_body — at streaming framerate the
3788    /// per-frame `?25l` / `?25h` toggle reads as constant cursor
3789    /// flicker on macOS Terminal.app even with hardware blink
3790    /// disabled (`?12l`). Painting body without hiding is safe on
3791    /// fast terminals because the per-row CUPs flash the cursor
3792    /// through cells in well under one refresh interval.
3793    #[test]
3794    fn paint_frame_does_not_hide_cursor_on_fast_terminal() {
3795        let mut buf = Vec::new();
3796        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3797        // slow_paint_terminal stays false (default).
3798        r.render(UiLine::InputPrompt {
3799            buf: String::new(),
3800            cursor_byte: 0,
3801            menu: None,
3802            status: crate::render::StatusLine::default(),
3803            attachments: Vec::new(),
3804        });
3805        // Trigger a streaming-style repaint by pushing more body.
3806        r.render(UiLine::User("hello".into()));
3807        r.render(UiLine::User("world".into()));
3808        drop(r);
3809        let s = String::from_utf8_lossy(&buf);
3810        // No `?25l` before the first body CUP. Drop's leave_alt_screen
3811        // emits `?25h\x1b[?12h…?1049l` at the end, which contains
3812        // `?25h` but no `?25l`, so the only way `?25l` could be in the
3813        // output is from paint_frame — which we don't want.
3814        if let Some(first_body_cup) = s.find("\x1b[1;1H\x1b[K") {
3815            let pre = &s[..first_body_cup];
3816            assert!(
3817                !pre.contains("\x1b[?25l"),
3818                "fast terminal must not hide cursor before body paint. output: {:?}",
3819                s
3820            );
3821        }
3822    }
3823
3824    /// Regression: on fast terminals, the `?25h` show-cursor sequence
3825    /// must be emitted at most once for repeated input-prompt frames
3826    /// — re-emitting it every frame restarts the host terminal's
3827    /// hardware cursor blink animation, producing visible flicker on
3828    /// macOS Terminal.app. Subsequent frames must reposition via a
3829    /// bare CUP only.
3830    #[test]
3831    fn fast_terminal_dedupes_show_cursor_across_frames() {
3832        let mut buf = Vec::new();
3833        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
3834        // slow_paint_terminal stays false (default).
3835        for i in 0..5 {
3836            r.render(UiLine::InputPrompt {
3837                buf: format!("typed{}", i),
3838                cursor_byte: 6,
3839                menu: None,
3840                status: crate::render::StatusLine::default(),
3841                attachments: Vec::new(),
3842            });
3843        }
3844        drop(r);
3845        let s = String::from_utf8_lossy(&buf);
3846        // Drop's leave_alt_screen emits one `?25h`. paint_footer
3847        // emits at most one more (on the first frame, transitioning
3848        // from initial cursor_shown=true → still true via no-op,
3849        // actually never re-emits because cursor_shown starts true).
3850        // So the count should be exactly 1 (from leave). If paint_footer
3851        // were re-emitting per frame we'd see 6+.
3852        let show_count = s.matches("\x1b[?25h").count();
3853        assert!(
3854            show_count <= 1,
3855            "fast terminal must dedupe show-cursor; got {} occurrences. output: {:?}",
3856            show_count,
3857            s
3858        );
3859    }
3860
3861    /// Mouse scroll wheel routes through `scroll_body`. Negative
3862    /// delta scrolls UP (older content), positive scrolls DOWN.
3863    /// Verifies the same field-level outcome as keyboard PageUp.
3864    #[test]
3865    fn mouse_scroll_via_scroll_body_updates_viewport() {
3866        let mut buf = Vec::new();
3867        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
3868        for i in 0..20 {
3869            r.push_body_row(format!("R{:02}", i));
3870        }
3871        assert!(r.sticky_bottom);
3872        // Reader emits MouseScroll(-3) for ScrollUp; event_loop calls
3873        // renderer.scroll_body(-3). Verify here at the renderer level.
3874        r.scroll_body(-3);
3875        assert!(!r.sticky_bottom, "scroll up via mouse must break sticky");
3876        // body_height = 5 (height 10 - footer 5), max_top = 15. -3
3877        // from sticky-bottom origin → 12.
3878        assert_eq!(r.viewport_top, 12);
3879        drop(r);
3880    }
3881
3882    // ── selection / clipboard ──
3883
3884    /// `line_display_width_sgr_aware` returns the visible-width of a
3885    /// styled line. SGR escapes are zero-cost; CJK chars are 2 cols.
3886    /// Sanity check that the helpers used by the selection paint
3887    /// don't double-count colour escapes.
3888    #[test]
3889    fn line_display_width_skips_sgr() {
3890        assert_eq!(line_display_width_sgr_aware("hello"), 5);
3891        assert_eq!(line_display_width_sgr_aware("\x1b[31mhello\x1b[0m"), 5);
3892        assert_eq!(line_display_width_sgr_aware("中文"), 4);
3893        assert_eq!(line_display_width_sgr_aware("\x1b[1m中\x1b[0m文"), 4);
3894    }
3895
3896    /// `extract_line_selection_text` should return only the chars
3897    /// whose display column falls in `[start, end)`, with all CSI
3898    /// escapes dropped — that's what gets written to the clipboard.
3899    /// Visible cols of `"\x1b[31mhello\x1b[0m world"` are
3900    /// `h=0 e=1 l=2 l=3 o=4 ' '=5 w=6 o=7 r=8 l=9 d=10`.
3901    #[test]
3902    fn extract_line_selection_strips_sgr_and_clips_to_range() {
3903        let line = "\x1b[31mhello\x1b[0m world";
3904        assert_eq!(extract_line_selection_text(line, 0, 5), "hello");
3905        assert_eq!(extract_line_selection_text(line, 6, 11), "world");
3906        // crosses the SGR boundary: cols 3..8 = "lo wo"
3907        assert_eq!(extract_line_selection_text(line, 3, 8), "lo wo");
3908        // empty range
3909        assert_eq!(extract_line_selection_text(line, 5, 5), "");
3910        // out-of-bounds end clips to last visible col
3911        assert_eq!(extract_line_selection_text(line, 7, 100), "orld");
3912    }
3913
3914    /// `render_line_with_selection` wraps the selected range in
3915    /// reverse-video and ends it with a reset. CSI escapes outside
3916    /// the selection pass through verbatim; CSI escapes inside the
3917    /// selection are dropped so the highlight stays solid.
3918    #[test]
3919    fn render_line_with_selection_emits_reverse_video() {
3920        let line = "hello world";
3921        let out = render_line_with_selection(line, 80, 0, 5);
3922        assert!(out.starts_with("\x1b[0m\x1b[7m"), "should open with reset+reverse. got: {:?}", out);
3923        assert!(out.contains("hello"), "selected text missing. got: {:?}", out);
3924        assert!(out.contains("\x1b[0m world"), "post-selection plain text missing. got: {:?}", out);
3925    }
3926
3927    /// A CSI escape *inside* the selection range must be dropped
3928    /// (otherwise an inline `\x1b[0m` from markdown styling would
3929    /// tear a hole in the highlight by closing the reverse-video
3930    /// span mid-selection).
3931    ///
3932    /// Visible cols of `"he\x1b[31mre\x1b[0m"` are `h=0 e=1 r=2 e=3`.
3933    /// Select [0, 4) — both interior CSI escapes (`\x1b[31m` between
3934    /// cols 1-2 and `\x1b[0m` after col 3) must be stripped.
3935    #[test]
3936    fn render_line_with_selection_drops_inline_csi_inside_range() {
3937        let line = "he\x1b[31mre\x1b[0m";
3938        let out = render_line_with_selection(line, 80, 0, 4);
3939        assert!(
3940            !out.contains("\x1b[31m"),
3941            "inline red CSI inside selection should be dropped. got: {:?}",
3942            out
3943        );
3944        // Reset count: open-reset at selection start + close-reset
3945        // at selection end. The interior `\x1b[0m` from the source
3946        // line MUST be dropped; if it leaked through we'd see 3.
3947        let resets = out.matches("\x1b[0m").count();
3948        assert_eq!(resets, 2, "expected open-reset + close-reset only. got: {:?}", out);
3949    }
3950
3951    /// Empty selection range collapses to a plain SGR-aware truncate.
3952    /// Guards `selection_col_range_for_line` returning `None` from
3953    /// upstream — the path that calls `render_line_with_selection`
3954    /// shouldn't, but if it ever did the visual would just be the
3955    /// unhighlighted line.
3956    #[test]
3957    fn render_line_with_empty_selection_is_plain_truncate() {
3958        let line = "hello world";
3959        assert_eq!(render_line_with_selection(line, 80, 5, 5), "hello world");
3960    }
3961
3962    /// `selection_col_range_for_line` clamps to the visible width
3963    /// of the line — clicking past EOL on a one-line selection
3964    /// shouldn't extend the range past the last visible col.
3965    #[test]
3966    fn selection_range_clamps_to_line_width() {
3967        // 5-col line. Anchor at col 0, head at col 100 → [0, 5).
3968        let r = selection_col_range_for_line(0, (0, 0), (0, 100), "hello");
3969        assert_eq!(r, Some((0, 5)));
3970        // Anchor past EOL → None.
3971        let r = selection_col_range_for_line(0, (0, 50), (0, 100), "hello");
3972        assert_eq!(r, None);
3973    }
3974
3975    /// Multi-line selection: first line covers [start_col, EOL],
3976    /// middle lines fully selected, last line covers [0, head_col+1].
3977    #[test]
3978    fn selection_range_multi_line_shape() {
3979        // Three lines, anchor at (0, 3), head at (2, 2). Lines are
3980        // "first", "middle", "last".
3981        let lo = (0, 3);
3982        let hi = (2, 2);
3983        assert_eq!(
3984            selection_col_range_for_line(0, lo, hi, "first"),
3985            Some((3, 5)),
3986            "first line [3, 5) — from col 3 to EOL",
3987        );
3988        assert_eq!(
3989            selection_col_range_for_line(1, lo, hi, "middle"),
3990            Some((0, 6)),
3991            "middle line fully selected",
3992        );
3993        assert_eq!(
3994            selection_col_range_for_line(2, lo, hi, "last"),
3995            Some((0, 3)),
3996            "last line [0, head+1) = [0, 3)",
3997        );
3998        // Lines outside [lo.0, hi.0] return None.
3999        assert_eq!(selection_col_range_for_line(3, lo, hi, "outside"), None);
4000    }
4001
4002    /// Base64 round-trip on the standard alphabet, including padding
4003    /// for non-multiple-of-3 inputs. OSC 52 expects exactly this
4004    /// encoding (the `c` selector is the system clipboard).
4005    #[test]
4006    fn base64_encode_matches_standard_alphabet() {
4007        // Empty.
4008        assert_eq!(base64_encode(b""), "");
4009        // 1 byte → 2 chars + 2 pad.
4010        assert_eq!(base64_encode(b"f"), "Zg==");
4011        // 2 bytes → 3 chars + 1 pad.
4012        assert_eq!(base64_encode(b"fo"), "Zm8=");
4013        // 3 bytes → no pad.
4014        assert_eq!(base64_encode(b"foo"), "Zm9v");
4015        // 4 bytes → 6 chars + 2 pad.
4016        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
4017        // RFC 4648 vector.
4018        assert_eq!(base64_encode(b"hello world"), "aGVsbG8gd29ybGQ=");
4019    }
4020
4021    /// Begin → drag → end emits OSC 52 with the selected text.
4022    ///
4023    /// `UiLine::User` pushes a body row prefixed with the 2-col
4024    /// chevron `❯ `, so the visible cols of "hello there" are:
4025    /// `❯=0 space=1 h=2 e=3 l=4 l=5 o=6 ' '=7 t=8 …`. Drag cols
4026    /// 2..=6 captures "hello".
4027    #[test]
4028    fn drag_select_writes_osc52_to_writer() {
4029        let mut buf = Vec::new();
4030        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4031        r.render(UiLine::User("hello there".into()));
4032        r.begin_selection(2, 0);
4033        r.update_selection(6, 0);
4034        r.end_selection();
4035        r.flush();
4036        drop(r);
4037        let s = String::from_utf8_lossy(&buf);
4038        let expected = format!("\x1b]52;c;{}\x07", base64_encode(b"hello"));
4039        assert!(
4040            s.contains(&expected),
4041            "OSC 52 with base64('hello') missing. got: {:?}",
4042            s
4043        );
4044    }
4045
4046    /// Drag end with empty selection (begin only, no movement, head
4047    /// landed past EOL) writes nothing. We don't want a release that
4048    /// captured zero chars to clobber the user's existing clipboard.
4049    #[test]
4050    fn drag_end_does_not_emit_osc52_when_selection_empty() {
4051        let mut buf = Vec::new();
4052        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4053        r.render(UiLine::User("hi".into()));
4054        // Begin at col 50 (way past EOL "hi" which is 2 cols wide).
4055        // selection_col_range_for_line clamps both ends to width 2,
4056        // so the effective range is empty.
4057        r.begin_selection(50, 0);
4058        r.end_selection();
4059        r.flush();
4060        drop(r);
4061        let s = String::from_utf8_lossy(&buf);
4062        assert!(
4063            !s.contains("\x1b]52;c;"),
4064            "no OSC 52 should be emitted for empty selection. got: {:?}",
4065            s
4066        );
4067    }
4068
4069    /// Begin in the footer area should refuse to anchor a selection.
4070    /// Anchoring there would bind to a line index that doesn't
4071    /// exist in body_lines (or worse, points at a row no longer
4072    /// shown after a scroll), yielding a phantom highlight.
4073    #[test]
4074    fn begin_selection_in_footer_does_not_anchor() {
4075        let mut buf = Vec::new();
4076        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4077        r.render(UiLine::User("hi".into()));
4078        // body_height = 5, footer starts at row 5. Press at row 7
4079        // (in the input box / status area).
4080        r.begin_selection(0, 7);
4081        assert!(r.selection.is_none(), "footer press must not start a selection");
4082        assert!(!r.selection_active);
4083        drop(r);
4084    }
4085
4086    /// `update_selection` after `end_selection` is a no-op. JediTerm /
4087    /// Windows conhost can emit a final coalesced motion event right
4088    /// after the Up; without `selection_active` gating the head
4089    /// would jump to that stale point.
4090    #[test]
4091    fn update_after_end_does_not_move_head() {
4092        let mut buf = Vec::new();
4093        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4094        r.render(UiLine::User("hello there".into()));
4095        r.begin_selection(0, 0);
4096        r.update_selection(4, 0);
4097        let head_before_end = r.selection.unwrap().head;
4098        r.end_selection();
4099        // Stray motion after release.
4100        r.update_selection(10, 0);
4101        let head_after_stray = r.selection.unwrap().head;
4102        assert_eq!(
4103            head_before_end, head_after_stray,
4104            "post-end motion must not move head",
4105        );
4106        drop(r);
4107    }
4108
4109    /// Selection survives a `end_selection` (so the user can see what
4110    /// they captured) but a subsequent `reset` (e.g. /clear) wipes it
4111    /// since body_lines have been emptied — leaving stale indices
4112    /// would point past end-of-buffer on the next paint.
4113    #[test]
4114    fn reset_clears_selection() {
4115        let mut buf = Vec::new();
4116        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4117        r.render(UiLine::User("hello".into()));
4118        r.begin_selection(0, 0);
4119        r.update_selection(3, 0);
4120        r.end_selection();
4121        assert!(r.selection.is_some());
4122        r.reset();
4123        assert!(r.selection.is_none(), "reset should clear selection");
4124        drop(r);
4125    }
4126
4127    /// `on_resize` clears selection — display columns were anchored
4128    /// against the old width, after reflow they'd land in the wrong
4129    /// spots of the painted line.
4130    #[test]
4131    fn resize_clears_selection() {
4132        let mut buf = Vec::new();
4133        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4134        r.render(UiLine::User("hello".into()));
4135        r.begin_selection(0, 0);
4136        r.update_selection(3, 0);
4137        assert!(r.selection.is_some());
4138        r.on_resize(40, 10);
4139        assert!(r.selection.is_none(), "resize should clear selection");
4140        drop(r);
4141    }
4142
4143    /// During an active drag, paint emits the reverse-video sequence
4144    /// over the selected cells. End-to-end check that the click →
4145    /// drag path actually decorates the body row.
4146    ///
4147    /// No menu is rendered in this test, so the only source of
4148    /// `\x1b[7m` in the buffer is the selection paint.
4149    #[test]
4150    fn drag_paints_reverse_video_in_body() {
4151        let mut buf = Vec::new();
4152        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4153        r.render(UiLine::User("hello there".into()));
4154        r.begin_selection(0, 0);
4155        r.update_selection(4, 0);
4156        r.flush();
4157        drop(r);
4158        let s = String::from_utf8_lossy(&buf);
4159        assert!(
4160            s.contains("\x1b[7m"),
4161            "drag must emit reverse-video. got: {:?}",
4162            s
4163        );
4164    }
4165
4166    /// Multi-line selection: drag from line 0 col 2 to line 1 col 3
4167    /// across two body rows. Extracted text should be the cross-row
4168    /// slice joined by `\n`.
4169    #[test]
4170    fn multi_line_drag_extracts_across_rows() {
4171        let mut buf = Vec::new();
4172        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4173        // Two body rows. body_height = 5; both fit.
4174        r.body_lines.push("first row".into());
4175        r.body_lines.push("second row".into());
4176        r.body_dirty = true;
4177        // Begin on row 0 of body (= screen row 0 since body_lines.len=2
4178        // < body_height=5, so viewport_start=0). Drag to row 1, col 3.
4179        r.begin_selection(2, 0);
4180        r.update_selection(3, 1);
4181        let text = r.extract_selection_text();
4182        // Line 0: from col 2 to EOL of "first row" (9 cols) = "rst row"
4183        // Line 1: from col 0 to col 4 (head+1) of "second row" = "seco"
4184        assert_eq!(text, "rst row\nseco", "multi-line extract mismatch: {:?}", text);
4185        drop(r);
4186    }
4187
4188    /// Regression guard for `/language` modal feedback. The picker's
4189    /// Enter handler emits CommandOutput THEN returns Close; the event
4190    /// loop then re-renders the input prompt without the menu. The
4191    /// CommandOutput must survive that second render — otherwise the
4192    /// user sees no confirmation that the locale switch took effect.
4193    #[test]
4194    fn command_output_survives_subsequent_input_prompt_redraw() {
4195        let mut buf = Vec::new();
4196        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 10);
4197
4198        // Simulate the exact flow language_picker.rs uses on Enter:
4199        // first push a confirmation line, then redraw the input prompt
4200        // without a menu (the event loop's `redraw_idle_plain` after
4201        // `ModalAction::Close`).
4202        r.render(UiLine::CommandOutput(
4203            "  ✓ Language switched to 简体中文 (zh_CN).\n".into(),
4204        ));
4205        r.render(UiLine::InputPrompt {
4206            buf: String::new(),
4207            cursor_byte: 0,
4208            menu: None,
4209            status: crate::render::StatusLine::default(),
4210            attachments: Vec::new(),
4211        });
4212        r.flush();
4213
4214        // The body line must still be present in body_lines AND
4215        // visible in the painted output stream — both layers matter
4216        // because painting clips out-of-viewport rows.
4217        let in_body = r
4218            .body_lines
4219            .iter()
4220            .any(|row| row.contains("Language switched to") && row.contains("简体中文"));
4221        assert!(
4222            in_body,
4223            "confirmation line missing from body_lines: {:?}",
4224            r.body_lines
4225        );
4226        drop(r);
4227        let s = String::from_utf8_lossy(&buf);
4228        assert!(
4229            s.contains("Language switched to") && s.contains("简体中文"),
4230            "confirmation line missing from painted output: {:?}",
4231            s
4232        );
4233    }
4234
4235    /// Re-flow on resize: widening the terminal should re-merge previously
4236    /// split short rows back into fewer longer rows. A single logical
4237    /// line that was soft-wrapped into 3 chunks at width=10 should
4238    /// become 1 row after widening to width=80.
4239    #[test]
4240    fn resize_wider_merges_split_rows() {
4241        let mut buf = Vec::new();
4242        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 10, 24);
4243        // Push a 25-char line via push_body_row_raw. At width=10 it
4244        // wraps into 3 chunks (10 + 10 + 5 chars visible).
4245        r.push_body_row_raw("abcdefghijklmnopqrstuvwxyz".to_string());
4246        assert_eq!(
4247            r.body_lines.len(),
4248            3,
4249            "narrow terminal should split 25-char line into 3 rows, got: {:?}",
4250            r.body_lines
4251        );
4252        // Verify raw_body_lines has exactly 1 entry (the original line).
4253        assert_eq!(
4254            r.raw_body_lines.len(),
4255            1,
4256            "raw_body_lines should have 1 entry, got: {:?}",
4257            r.raw_body_lines
4258        );
4259        // Widen to 80 cols.
4260        r.on_resize(80, 24);
4261        // After re-flow the line should fit in a single row.
4262        assert_eq!(
4263            r.body_lines.len(),
4264            1,
4265            "widened terminal should have 1 row for the 25-char line, got: {:?}",
4266            r.body_lines
4267        );
4268        assert!(
4269            r.body_lines[0].contains("abcdefghijklmnopqrstuvwxyz"),
4270            "re-flowed row should contain the original text, got: {:?}",
4271            r.body_lines[0]
4272        );
4273    }
4274
4275    /// Re-flow on resize: narrowing the terminal should split a
4276    /// long row into multiple rows instead of truncating it.
4277    #[test]
4278    fn resize_narrower_splits_long_rows() {
4279        let mut buf = Vec::new();
4280        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
4281        // Push a 40-char line at width=80 — fits in one row.
4282        r.push_body_row_raw("a".repeat(40));
4283        assert_eq!(
4284            r.body_lines.len(),
4285            1,
4286            "80-col terminal should fit 40-char line in 1 row"
4287        );
4288        // Narrow to 20 cols.
4289        r.on_resize(20, 24);
4290        // After re-flow the line should be split into 2 rows
4291        // (20 + 20 chars).
4292        assert_eq!(
4293            r.body_lines.len(),
4294            2,
4295            "narrowed terminal should split 40-char line into 2 rows, got: {:?}",
4296            r.body_lines
4297        );
4298        // Each chunk should be at most 20 visible columns.
4299        for (i, row) in r.body_lines.iter().enumerate() {
4300            let w = line_display_width_sgr_aware(row);
4301            assert!(
4302                w <= 20,
4303                "body row {} has display width {} > 20 after resize: {:?}",
4304                i,
4305                w,
4306                row
4307            );
4308        }
4309    }
4310
4311    /// Re-flow on resize: SGR colour codes in body rows survive
4312    /// the re-wrap without bleeding into adjacent rows.
4313    #[test]
4314    fn resize_reflow_preserves_sgr_colours() {
4315        let mut buf = Vec::new();
4316        let mut r = AltScreenRenderer::with_writer(&mut buf, caps_default(), 80, 24);
4317        // Push a coloured line that fits in 80 cols.
4318        let coloured = format!("{}hello world{}", SGR_CYAN, SGR_RESET);
4319        r.push_body_row_raw(coloured.clone());
4320        // Narrow to 5 cols so it wraps.
4321        r.on_resize(5, 24);
4322        // Re-flow should produce multiple rows, each containing
4323        // part of the text.
4324        assert!(
4325            r.body_lines.len() > 1,
4326            "narrowed terminal should split the coloured line, got: {:?}",
4327            r.body_lines
4328        );
4329        // Widen back — the full coloured line should re-merge.
4330        r.on_resize(80, 24);
4331        assert_eq!(
4332            r.body_lines.len(),
4333            1,
4334            "widened back should re-merge into 1 row, got: {:?}",
4335            r.body_lines
4336        );
4337        assert!(
4338            r.body_lines[0].contains("hello world"),
4339            "re-merged row should contain original text, got: {:?}",
4340            r.body_lines[0]
4341        );
4342    }
4343}