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}