Skip to main content

atomcode_tuix/render/
retained.rs

1// crates/atomcode-tuix/src/render/retained.rs
2//
3// Retained-mode `Renderer` implementation — the alternative to
4// `AnsiRenderer`. Enabled by `ATOMCODE_TUIX_RETAINED=1` (dual-track
5// until Phase 6).
6//
7// Phase 2 scope: smoke test of the plumbing. Only `InputPrompt`
8// actually draws anything; every other `UiLine` is a no-op.
9// Phase 3 fills in the full footer (rules / spinner / menu / status);
10// Phase 4 adds body append (scroll_up + draw). Phase 5 adds the 16ms
11// frame-coalesce tick. Phase 6 deletes `AnsiRenderer`.
12//
13// Architecture:
14//   event_loop ── UiLine ─▶ RetainedRenderer ── updates widget state
15//                                           ── re-draws into Screen
16//                                           ── render_diff → bytes
17//                                           ── out.write_all(bytes)
18
19use std::io::{BufWriter, Stdout, Write};
20
21use crossterm::event::{
22    DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
23    PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
24};
25use crossterm::execute;
26
27use super::cell::{push_str_cells, serialize_row, Cell, CellStyle};
28use super::screen::Screen;
29use super::theme::{role, Role};
30use super::{MenuPayload, Renderer, StatusLine, UiLine};
31use crate::i18n::{t, Msg};
32use crate::sanitize::scrub_controls;
33use crate::terminal::TerminalCaps;
34use crossterm::style::Color;
35
36const PAD_COL: usize = 2;
37
38/// Render context usage as `12.3k / 131k tok` when both used and window
39/// are known, or `12.3k tok` when only the used count is known (provider
40/// hasn't reported its window yet, e.g. pre-config or fallback).
41fn format_ctx_usage(used: usize, window: usize) -> String {
42    let used_label = if used < 1000 {
43        format!("{}", used)
44    } else {
45        format!("{:.1}k", (used as f64) / 1000.0)
46    };
47    if window == 0 {
48        format!("{} tok", used_label)
49    } else {
50        let window_label = if window < 1000 {
51            format!("{}", window)
52        } else if window % 1000 == 0 {
53            format!("{}k", window / 1000)
54        } else {
55            format!("{:.0}k", (window as f64) / 1000.0)
56        };
57        format!("{}/{} tok", used_label, window_label)
58    }
59}
60
61// ── Markdown → Cell parser ─────────────────────────────────────────
62//
63// `crate::markdown::render_line` returns an ANSI-tinted string: the
64// markdown text with SGR escapes embedded (e.g. `**bold**` →
65// `\x1b[1mbold\x1b[22m`, `` `code` `` → `\x1b[97mcode\x1b[39m`).
66// AnsiRenderer wrote those bytes straight to stdout. Retained mode
67// works on `Cell`s, so we parse the ANSI string back into a stream
68// of cells carrying their computed style. Minimal parser — handles
69// only the SGR vocabulary our markdown crate emits:
70//
71//   1     bold on
72//   22    bold off
73//   3     italic on   (folded — CellStyle has no italic bit, so
74//                      italic text renders plain. Same visual loss
75//                      we'd have without markdown support at all;
76//                      acceptable for Phase 6.)
77//   23    italic off
78//   7     reverse on
79//   27    reverse off
80//   39    fg default
81//   90    fg DarkGrey (borders / soft headings)
82//   97    fg White (inline code / code blocks — bright white)
83//   0     reset everything
84//
85// Other SGR params (RGB, 256-color, italic, underline) are silently
86// ignored — the glyph still renders with the current accumulated
87// style. CSI sequences with a non-`m` final byte are skipped whole.
88
89/// Parse an ANSI-tinted markdown string into one or more cell
90/// lines, split on `\n`. Wide glyphs get one real cell + N-1
91/// `Cell::continuation()` cells so `cell_index == terminal_column`
92/// stays true.
93fn parse_markdown_to_cells(s: &str) -> Vec<Vec<Cell>> {
94    let mut lines: Vec<Vec<Cell>> = vec![Vec::new()];
95    let mut style = CellStyle::default();
96    let mut chars = s.chars().peekable();
97    while let Some(c) = chars.next() {
98        if c == '\x1b' {
99            if chars.peek() == Some(&'[') {
100                chars.next(); // consume '['
101                let mut params = String::new();
102                while let Some(&p) = chars.peek() {
103                    chars.next();
104                    if p.is_ascii_alphabetic() || p == '~' {
105                        if p == 'm' {
106                            apply_sgr(&params, &mut style);
107                        }
108                        break;
109                    }
110                    params.push(p);
111                }
112            }
113            continue;
114        }
115        if c == '\n' {
116            lines.push(Vec::new());
117            continue;
118        }
119        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
120        if w == 0 {
121            continue;
122        }
123        lines.last_mut().unwrap().push(Cell {
124            ch: c,
125            style: style.clone(),
126            width: w as u8,
127        });
128        for _ in 1..w {
129            lines.last_mut().unwrap().push(Cell::continuation());
130        }
131    }
132    lines
133}
134
135/// Clip a cell row to at most `max_cols` display columns. Drops
136/// trailing cells (including their continuation cells) so the total
137/// `cell.width` sum of the returned row is ≤ `max_cols`. A wide
138/// glyph that straddles `max_cols` is dropped whole — we never emit
139/// the left half without its continuation, which would leak into
140/// the next line on real terminals once auto-wrap kicks in.
141///
142/// Used on the resize path to make cached `body_lines` (built for
143/// the OLD screen width) safe to re-emit against a narrower new
144/// terminal. Without this, `serialize_row` would emit glyphs past
145/// the right edge; the terminal's own auto-wrap then spills them
146/// into the next row — which is the footer strip or a phantom body
147/// row — producing the "everything shifted by one column and the
148/// footer has garbage in it" symptom after a resize-smaller drag.
149fn clip_cells_to_width(cells: &[Cell], max_cols: usize) -> Vec<Cell> {
150    if max_cols == 0 {
151        return Vec::new();
152    }
153    let mut out = Vec::with_capacity(cells.len().min(max_cols));
154    let mut used = 0usize;
155    for cell in cells {
156        let w = cell.width as usize;
157        if w > 0 && used + w > max_cols {
158            break;
159        }
160        out.push(cell.clone());
161        used += w;
162    }
163    out
164}
165
166/// Cell-based wrap: splits a cell sequence into chunks whose sum
167/// of `cell.width` stays ≤ `max_cols`. Continuation cells (width 0)
168/// travel with their preceding real cell — the combined "grapheme"
169/// never splits mid-wide-glyph.
170fn wrap_cells_to_width(cells: &[Cell], max_cols: usize) -> Vec<Vec<Cell>> {
171    if max_cols == 0 || cells.is_empty() {
172        return vec![cells.to_vec()];
173    }
174    let mut chunks: Vec<Vec<Cell>> = vec![Vec::new()];
175    let mut cur_width = 0usize;
176    for cell in cells {
177        let w = cell.width as usize;
178        if w > 0 && cur_width + w > max_cols && !chunks.last().unwrap().is_empty() {
179            chunks.push(Vec::new());
180            cur_width = 0;
181        }
182        chunks.last_mut().unwrap().push(cell.clone());
183        cur_width += w;
184    }
185    chunks
186}
187
188fn apply_sgr(params: &str, style: &mut CellStyle) {
189    // `\x1b[m` (empty params) is treated as SGR 0 per ECMA-48.
190    let parts: Vec<&str> = if params.is_empty() {
191        vec!["0"]
192    } else {
193        params.split(';').collect()
194    };
195    let mut i = 0;
196    while i < parts.len() {
197        let part = parts[i];
198        match part.parse::<u32>().ok() {
199            Some(0) => *style = CellStyle::default(),
200            Some(1) => style.bold = true,
201            Some(22) => style.bold = false,
202            // Italic (3/23) — no CellStyle bit; text renders plain.
203            Some(3) | Some(23) => {}
204            Some(7) => style.reverse = true,
205            Some(27) => style.reverse = false,
206            Some(39) => style.fg = None,
207            Some(90) => style.fg = Some(Color::DarkGrey),
208            Some(91) => style.fg = Some(Color::Red),
209            Some(92) => style.fg = Some(Color::Green),
210            Some(93) => style.fg = Some(Color::Yellow),
211            Some(94) => style.fg = Some(Color::Blue),
212            Some(95) => style.fg = Some(Color::Magenta),
213            Some(96) => style.fg = Some(Color::Cyan),
214            Some(97) => style.fg = Some(Color::White),
215            // 38;2;R;G;B — truecolor foreground. Markdown emits this
216            // for inline code / code blocks / headings so the colour
217            // survives terminal palette remapping (bright-XX colours
218            // get re-tinted by themes; truecolor RGB does not).
219            // Consume 4 extra tokens (`2`, R, G, B) on success.
220            Some(38) => {
221                if parts.get(i + 1).copied() == Some("2") {
222                    if let (Some(r), Some(g), Some(b)) = (
223                        parts.get(i + 2).and_then(|s| s.parse::<u8>().ok()),
224                        parts.get(i + 3).and_then(|s| s.parse::<u8>().ok()),
225                        parts.get(i + 4).and_then(|s| s.parse::<u8>().ok()),
226                    ) {
227                        style.fg = Some(Color::Rgb { r, g, b });
228                        i += 4;
229                    }
230                }
231                // 38;5;N (256-colour) and other 38 sub-formats fall
232                // through silently — markdown doesn't emit them.
233            }
234            _ => {
235                // Other ANSI colours (30-37, 91-96, bg, underline)
236                // silently ignored — markdown doesn't emit them.
237            }
238        }
239        i += 1;
240    }
241}
242
243pub struct RetainedRenderer<W: Write + Send> {
244    out: W,
245    caps: TerminalCaps,
246    screen: Screen,
247    // ── widget state ──
248    input_buf: String,
249    input_cursor_byte: usize,
250    menu: Option<MenuPayload>,
251    status: StatusLine,
252    /// Marker numbers (`N`) that should render as `└ [Image #N]`
253    /// preview rows directly under the input box. Pre-computed by
254    /// `event_loop::compute_input_attachments` (intersect of buffer
255    /// `[Image #N]` markers with `pending_image_markers` +
256    /// `pending_recalled_attachments`), so we draw a row only when
257    /// the buffer text really maps to image bytes ready to ship —
258    /// not for literal `[Image #N]` strings the user typed by hand.
259    /// Always rendered in `Role::Muted`, mirroring the post-submit
260    /// `UiLine::ImageAttachment` echo style so the visual contract
261    /// pre- and post-submit reads identically.
262    input_attachments: Vec<usize>,
263    // ── body history ──
264    /// Pre-wrapped body rows, oldest first. Trimmed when exceeds
265    /// 2× screen height. Symbol-bearing rows (`❯`, `▸`, `▶`, `⎿`)
266    /// are flush-left at col 0; plain text rows (assistant prose,
267    /// errors, cancelled, cmd output, diff, turn separator) carry a
268    /// `PAD_COL` indent. `paint_body` just `draw_row`s the last N
269    /// directly.
270    body_lines: Vec<Vec<Cell>>,
271    /// Line-buffer for streaming assistant text — chunks accumulate
272    /// here until a `\n` boundary, at which point the completed
273    /// physical line is appended to `body_lines`.
274    assistant_line_buf: String,
275    /// Markdown parser state (code-block tracking, table row
276    /// buffering) passed to `crate::markdown::render_line` on each
277    /// completed assistant line.
278    md_state: crate::markdown::MdState,
279    // ── Phase 5: frame coalescing ──
280    /// True when widget state has changed since the last frame
281    /// emit. `render()` flips this to true instead of painting
282    /// immediately; `flush_deferred()` (called every 5ms by the
283    /// event loop tick) checks this and does the paint+emit at
284    /// most once per tick. An IME burst of 40 keystrokes in 1ms
285    /// thus produces ONE frame instead of 40 — the difference
286    /// between 40 Mac Terminal repaints and 1.
287    dirty: bool,
288    /// Footer row count at the last successful emit. When footer
289    /// geometry changes (wrap, menu open/close), absolute row
290    /// positions of the internal layout stay the same for some
291    /// rows but shift for others — and on Mac Terminal.app we've
292    /// observed the "rule" rows occasionally rendering as
293    /// half-width after such a transition, even though
294    /// `cells[row_57]` holds the full 209 dashes. Rather than
295    /// chase the terminal-side glitch, we invalidate prev_cells
296    /// on geometry change so the next paint emits every row
297    /// full-frame, guaranteeing the terminal re-processes the
298    /// rule regardless of diff skip.
299    last_painted_footer_rows: usize,
300    /// Bottom row (1-indexed) of the currently-set DECSTBM region.
301    /// `None` means "no region set" (terminal default = full screen).
302    /// Updated by `ensure_scroll_region()` before any body/footer
303    /// paint so `\n` in the body-emit path only scrolls body rows,
304    /// leaving the footer strip below untouched.
305    scroll_region_bottom: Option<u16>,
306    /// Set by `pop_approval_prompt` so the immediately-following
307    /// body-line emit overwrites the approval row in place instead of
308    /// scrolling the region up one row. Without this, the ToolResult
309    /// that follows Y/A/N would push the ▸ ToolCall row off to make
310    /// space for itself, leaving a blank gap between `▸ Tool(detail)`
311    /// and `⎿ result`.
312    /// Number of upcoming `push_body_row` calls that should overwrite in
313    /// place instead of scrolling the body region. Set by
314    /// `pop_approval_prompt` when the popped approval block occupied
315    /// more than one terminal row — each skipped scroll closes one row
316    /// of the gap between the last content row and body_bottom.
317    /// Decremented on every `emit_body_line_inner` call.
318    skip_body_scroll_count: u16,
319    /// Cached semantic welcome payload so resize can rebuild the
320    /// startup banner for the new terminal width.
321    welcome_banner: Option<(String, String)>,
322    /// Number of rows occupied by the welcome banner prefix in
323    /// `body_lines`.
324    welcome_line_count: usize,
325    /// True when `body_lines.last()` is a LIVE spinner row (the
326    /// emoji/label pair emitted by `UiLine::Spinner` /
327    /// `UiLine::StreamingBox`). A live row gets in-place re-emitted
328    /// on each subsequent spinner tick so body_lines doesn't grow
329    /// one entry per frame. Any non-spinner body push finalises
330    /// the row (flag flips to false) so the last animation frame
331    /// stays frozen as a historical paragraph header.
332    live_spinner_active: bool,
333    /// When `Some`, the live row at body_bottom is the animated
334    /// in-flight tool-call line (`<frame> Bash(cmd)`), not the generic
335    /// spinner. The Spinner / StreamingBox tick handlers consult this:
336    /// if Some they build a tool-call row with the new frame as icon;
337    /// if None they build the generic `<frame> Pondering…` spinner row.
338    /// Cleared by `ToolCallCommit`, which freezes the row to a static
339    /// `▸` icon (no longer live) so the next push_body_row appends
340    /// cleanly below it and the spinner can resume on the next tick.
341    /// (call_id, name, detail).
342    inflight_tool: Option<(String, String, String)>,
343    /// Number of body lines occupied by the multi-line wrapped in-flight
344    /// tool call (rendered via `render_inflight_tool`). Used to replace
345    /// those lines on each spinner tick and to clean up on commit.
346    inflight_tool_rows: usize,
347    /// Active multi-row "live group" — the tail of `body_lines` is one
348    /// header + N child rows for a parallel tool batch. Subsequent
349    /// `UiLine::ToolGroupChildUpdate` events resolve `call_id` →
350    /// `body_lines` index via the `child_indices` map and CUP+rewrite
351    /// in place, mirroring CC's `Read 4 files` block where each row
352    /// lights up `✓` as its result lands. Any external `push_body_row`
353    /// freezes the group (flag taken: subsequent updates fall back to
354    /// no-op since the group rows are no longer at the bottom and may
355    /// have scrolled out of the visible body strip).
356    live_group: Option<LiveGroup>,
357}
358
359/// Tracking state for an active multi-row live group. Populated by
360/// `UiLine::ToolGroupRender`, consulted by `UiLine::ToolGroupChildUpdate`,
361/// cleared by any unrelated `push_body_row`.
362#[derive(Debug, Clone)]
363struct LiveGroup {
364    batch_id: String,
365    /// Index of the header row in `body_lines`. Reserved for a
366    /// follow-up `ToolGroupHeaderUpdate` variant that appends the
367    /// `· N/M ok · Xs wall` summary in-place on batch completion
368    /// instead of pushing a separate row.
369    #[allow(dead_code)]
370    header_idx: usize,
371    /// `call_id` → index into `body_lines` for each child row. Indices
372    /// are absolute; they remain valid as long as no rows are drained
373    /// from the front of `body_lines` while the group is live.
374    child_indices: std::collections::HashMap<String, usize>,
375}
376
377impl RetainedRenderer<BufWriter<Stdout>> {
378    pub fn new(caps: TerminalCaps) -> Self {
379        let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
380        Self::with_writer(BufWriter::new(std::io::stdout()), caps, w, h)
381    }
382}
383
384impl<W: Write + Send> RetainedRenderer<W> {
385    pub fn with_writer(mut out: W, caps: TerminalCaps, w: u16, h: u16) -> Self {
386        // Clear scrollback buffer so previous terminal content (e.g. git log)
387        // doesn't remain visible above the atomcode viewport and mix with
388        // the atomcode session transcript. `\x1b[3J` only affects scrollback;
389        // it does not touch the visible screen rows.
390        let _ = out.write_all(b"\x1b[3J");
391        let _ = out.flush();
392        Self {
393            out,
394            caps,
395            screen: Screen::new(w, h),
396            input_buf: String::new(),
397            input_cursor_byte: 0,
398            menu: None,
399            status: StatusLine::default(),
400            input_attachments: Vec::new(),
401            body_lines: Vec::new(),
402            assistant_line_buf: String::new(),
403            md_state: crate::markdown::MdState::new(),
404            dirty: false,
405            last_painted_footer_rows: 0,
406            scroll_region_bottom: None,
407            skip_body_scroll_count: 0,
408            welcome_banner: None,
409            welcome_line_count: 0,
410            live_spinner_active: false,
411            inflight_tool: None,
412            inflight_tool_rows: 0,
413            live_group: None,
414        }
415    }
416
417    // ── Widget row builders (Cell-valued, no direct I/O) ──
418    //
419    // These are structurally identical to the ones in
420    // `render/ansi.rs` — when Phase 6 deletes AnsiRenderer, the
421    // duplication collapses (retained becomes the only owner).
422    // Keeping them verbatim here for Phase 3 means we don't have
423    // to refactor two renderers at once: the visual output is
424    // byte-exact against what AnsiRenderer produced in the same
425    // situation, giving the dual-track byte-cost tests a fair
426    // comparison.
427
428    fn style_for(&self, r: Role) -> CellStyle {
429        CellStyle {
430            fg: role(self.caps, r),
431            bold: false,
432            reverse: false,
433            faint: false,
434        }
435    }
436
437    fn style_bold(&self, r: Role) -> CellStyle {
438        CellStyle {
439            fg: role(self.caps, r),
440            bold: true,
441            reverse: false,
442            faint: false,
443        }
444    }
445
446    /// Theme-aware muting via SGR 2 (faint). Renders the role's fg
447    /// at ~50% intensity so secondary text reads as "subordinate"
448    /// without picking a fixed gray that may collide with the user's
449    /// terminal palette. Pair with `Role::Secondary` (no fg) to dim
450    /// the terminal default fg — the canonical "muted hint" look that
451    /// adapts across light/dark themes.
452    fn style_faint(&self, r: Role) -> CellStyle {
453        CellStyle {
454            fg: role(self.caps, r),
455            bold: false,
456            reverse: false,
457            faint: true,
458        }
459    }
460
461    /// Build the cells for a spinner body row: `<frame> <label>`,
462    /// flush-left at col 0 (no PAD_COL indent) so the frame glyph
463    /// aligns with `❯` user echoes and `▸` tool calls in the same
464    /// column. Used by the live spinner path to paint / re-paint
465    /// the "in-progress" row each tick.
466    fn build_spinner_body_row(&self, frame: &str, label: &str) -> Vec<Cell> {
467        let mut row = Vec::new();
468        let frame_style = self.style_for(Role::Brand);
469        push_str_cells(&mut row, frame, &frame_style);
470        push_str_cells(&mut row, " ", &CellStyle::default());
471        let label_style = self.style_bold(Role::Secondary);
472        push_str_cells(&mut row, &scrub_controls(label), &label_style);
473        row
474    }
475
476    /// Render (or re-render) the in-flight tool-call body text using
477    /// `icon` as the prefix, with proper multi-line wrapping via
478    /// `push_body_prefixed`. Removes any previously rendered inflight
479    /// tool lines from `body_lines` first so the spinner animation
480    /// replaces in-place rather than accumulating rows.
481    fn render_inflight_tool(&mut self, icon: &str, name: &str, detail: &str, meta: &str) {
482        // Spinner ticks fire at ~80ms cadence and re-call this fn with a
483        // new icon glyph each time. The OLD implementation truncated
484        // `body_lines` and called `push_body_prefixed` → `push_body_row`
485        // → `emit_body_line_inner` which uses `\n` to scroll new content
486        // into the DECSTBM body region. The model-state truncation hid
487        // the leak from the existing in-process test (`body_lines.len()`
488        // stayed flat) but the *terminal output* path scrolled a fresh
489        // copy of the inflight row IN every tick. After ~30s of cargo
490        // build, the user's scrollback held 30+ identical
491        // `▸ Bash(... cargo build ...)` rows even though the model only
492        // emitted ONE call (verified via datalog).
493        //
494        // Fix: when re-rendering on top of a prior inflight render with
495        // matching row count (the 99% case — only the icon glyph
496        // changes, all 1-cell-wide), bypass `push_body_row` entirely.
497        // Position the cursor at each previously-rendered row, erase
498        // the line, write the new cells. No `\n`, no scroll, no
499        // scrollback growth — same approach `push_or_update_live_spinner`
500        // already uses for the ordinary spinner row.
501        //
502        // Fallback (`prev_rows == 0`, or row count differs because
503        // the terminal was resized between ticks) keeps the original
504        // scroll-push semantics so layout still settles correctly; the
505        // one-frame scrollback ghost on a resize is acceptable since
506        // it doesn't accumulate across ticks.
507        let safe_name = scrub_controls(name);
508        let safe_detail = scrub_controls(detail);
509        let body_str = if safe_detail.is_empty() {
510            safe_name
511        } else {
512            format!("{}({})", safe_name, safe_detail)
513        };
514        // Safety cap: prevent degenerate bodies (e.g. multi-KB bash
515        // commands) from producing hundreds of terminal lines.
516        // This is a rendering safeguard only — the actual command
517        // execution uses the original, untruncated arguments.
518        let body_str = truncate_body_str(&body_str, 500);
519        // Append the spinner meta suffix (e.g. ` · 12s` or
520        // ` · 12s · 2 queued`) so the user has a time anchor while a
521        // long-running tool (cargo install, big test suite, etc.)
522        // executes. Without it the inflight row only shows
523        // `<spinner> Bash(cmd)` — no elapsed indicator — and looks
524        // indistinguishable from "stuck" once the user has been
525        // waiting >30s. `meta` carries its own leading ` · ` separator
526        // (or is empty); same single body style as the rest of the
527        // row, matching `build_spinner_body_row`'s convention where
528        // the suffix shares the label colour.
529        let body_str = if meta.is_empty() {
530            body_str
531        } else {
532            format!("{}{}", body_str, meta)
533        };
534        let prefix = format!("{} ", icon);
535        let prefix_style = self.style_for(Role::Muted);
536        let body_style = self.style_bold(Role::ToolName);
537        let new_rows = self.build_prefixed_rows(&prefix, &prefix_style, &body_str, &body_style);
538
539        let prev_rows = self.inflight_tool_rows;
540        let n = new_rows.len();
541        if n == 0 {
542            // Nothing to render (zero-width terminal etc.) — drop any
543            // prior inflight rows so state stays consistent.
544            let remove = prev_rows.min(self.body_lines.len());
545            self.body_lines.truncate(self.body_lines.len() - remove);
546            self.inflight_tool_rows = 0;
547            return;
548        }
549
550        self.ensure_scroll_region();
551        let bottom = self.body_bottom_row();
552        let inplace_ok = prev_rows > 0 && n == prev_rows && bottom >= n as u16;
553        if inplace_ok {
554            // In-place rewrite: the prior render's terminal rows are at
555            // (bottom - n + 1 ..= bottom). Update model state by
556            // swapping the trailing slice; then walk each terminal row
557            // with a position + erase + write triple.
558            let keep = self.body_lines.len().saturating_sub(prev_rows);
559            self.body_lines.truncate(keep);
560            let first = bottom - n as u16 + 1;
561            for (i, row) in new_rows.iter().enumerate() {
562                let r = first + i as u16;
563                let seq = format!("\x1b[{};1H\x1b[2K", r);
564                let _ = self.out.write_all(seq.as_bytes());
565                let bytes = serialize_row(row);
566                let _ = self.out.write_all(&bytes);
567                self.body_lines.push(row.clone());
568            }
569        } else {
570            // First render or row-count mismatch — fall back to scroll-push.
571            // Drop any prior inflight rows from model state; push new rows
572            // via the standard path so DECSTBM scrolling lands them at the
573            // bottom of the body region.
574            let remove = prev_rows.min(self.body_lines.len());
575            self.body_lines.truncate(self.body_lines.len() - remove);
576            for row in new_rows {
577                self.push_body_row(row);
578            }
579        }
580        self.inflight_tool_rows = n;
581    }
582
583    /// Pad a partially-built row with blank default-style cells until it
584    /// spans `target_w` display columns. Footer rows MUST be padded before
585    /// `draw_row` — otherwise stale body cells (welcome banner /provider
586    /// hint, previous turn text scrolled up through DECSTBM, etc.) bleed
587    /// through past the footer text on both iTerm2 and Terminal.app.
588    /// Our screen cell model doesn't track bytes written via
589    /// `emit_body_line_inner` (direct stdout), so the diff can't detect
590    /// the staleness and won't emit erase bytes unless we write explicit
591    /// blanks here.
592    fn pad_row_to_width(row: &mut Vec<Cell>, target_w: usize) {
593        let cur: usize = row.iter().map(|c| c.width as usize).sum();
594        if cur >= target_w {
595            return;
596        }
597        let blank = Cell {
598            ch: ' ',
599            style: CellStyle::default(),
600            width: 1,
601        };
602        for _ in cur..target_w {
603            row.push(blank.clone());
604        }
605    }
606
607    fn build_rule_row(&self, rule_width: usize) -> Vec<Cell> {
608        let mut row = Vec::with_capacity(rule_width);
609        let border = self.style_for(Role::Border);
610        for _ in 0..rule_width {
611            row.push(Cell {
612                ch: '─',
613                style: border.clone(),
614                width: 1,
615            });
616        }
617        row
618    }
619
620    /// Top-rule variant that may overlay a session-name pill on the
621    /// right side. Mirrors the alt-screen renderer's top-rule overlay
622    /// so both render paths show CC-style per-conversation badge. The
623    /// bot_rule keeps using `build_rule_row` (no badge there).
624    ///
625    /// Budget mirrors `alt_screen::paint_footer`:
626    ///   right_margin  = 2 cells
627    ///   pill_padding  = 2 cells (one space each side of the name)
628    ///   min_rule_left = 8 cells (keep some ─ on the left so the box
629    ///                  still reads as bordered)
630    /// Name truncated with `…` when display_width exceeds budget; if
631    /// the rule is too narrow for chrome + 1 cell, the badge is
632    /// skipped entirely and a plain rule is returned.
633    fn build_top_rule_with_badge(
634        &self,
635        rule_width: usize,
636        session_name: Option<&str>,
637    ) -> Vec<Cell> {
638        let mut row = self.build_rule_row(rule_width);
639        let Some(name) = session_name else {
640            return row;
641        };
642        if name.is_empty() {
643            return row;
644        }
645        const RIGHT_MARGIN: usize = 2;
646        const PILL_PADDING: usize = 2;
647        const MIN_RULE_LEFT: usize = 8;
648        let chrome = RIGHT_MARGIN + PILL_PADDING + MIN_RULE_LEFT;
649        if rule_width <= chrome {
650            return row;
651        }
652        let max_name_w = rule_width - chrome;
653        let name_w = crate::width::display_width(name);
654        let name_for_pill = if name_w <= max_name_w {
655            name.to_string()
656        } else if max_name_w <= 1 {
657            "…".to_string()
658        } else {
659            let truncated = crate::width::truncate_to_width(name, max_name_w - 1);
660            format!("{}…", truncated)
661        };
662        let pill_text = format!(" {} ", name_for_pill);
663        let pill_w = crate::width::display_width(&pill_text);
664        // Pill ends RIGHT_MARGIN cells from the right edge. Pill
665        // start cell index (0-indexed) = rule_width - RIGHT_MARGIN -
666        // pill_w. Saturating sub guards against arithmetic underflow
667        // if a future budget tweak shrinks the chrome below right_margin.
668        let pill_start = rule_width.saturating_sub(RIGHT_MARGIN + pill_w);
669        let pill_style = CellStyle {
670            fg: role(self.caps, Role::Border),
671            bold: false,
672            reverse: true,
673            faint: false,
674        };
675        let mut overlay_cells = Vec::new();
676        push_str_cells(&mut overlay_cells, &pill_text, &pill_style);
677        // Splice into `row` starting at pill_start. push_str_cells
678        // emits continuation cells (width 0) for wide glyphs so the
679        // overlay length already matches `pill_w` terminal columns;
680        // a straight overwrite preserves cell_index == column.
681        for (i, cell) in overlay_cells.into_iter().enumerate() {
682            let idx = pill_start + i;
683            if idx >= row.len() {
684                break;
685            }
686            row[idx] = cell;
687        }
688        row
689    }
690
691    fn build_middle_row(&self, line: &str, is_first: bool) -> Vec<Cell> {
692        let mut row = Vec::new();
693        let pad = CellStyle::default();
694        if is_first {
695            let accent = self.style_for(Role::Accent);
696            push_str_cells(&mut row, self.caps.prompt_chevron(), &accent);
697        } else {
698            push_str_cells(&mut row, "  ", &pad);
699        }
700        push_str_cells(&mut row, line, &pad);
701        row
702    }
703
704    fn build_menu_row(
705        &self,
706        name: &str,
707        desc: &str,
708        selected: bool,
709        rule_width: usize,
710        kind: super::MenuKind,
711    ) -> Vec<Cell> {
712        let mut row = Vec::new();
713        // Both menu kinds hug the left edge — content prefixes (`▸ /`
714        // or `+ `) carry the visual structure. The previous PAD_COL
715        // outer indent compounded with inner format-string padding to
716        // push the `▸` arrow 4 columns right of the rule edge, which
717        // read as a wonky margin against the flush-left rule.
718        let content = match kind {
719            super::MenuKind::SlashCommand => {
720                // Pad by DISPLAY width, not char count: `/设为默认`
721                // (5 chars, 9 cells) needs the same description
722                // start column as `/添加` (3 chars, 5 cells), so
723                // `{:<12}`'s char-count padding leaves CJK rows
724                // pushed two cells to the right of their ASCII
725                // neighbours. UnicodeWidthStr knows CJK glyphs are
726                // 2 cells; compute and append spaces explicitly.
727                let name_width = unicode_width::UnicodeWidthStr::width(name);
728                let pad = 12usize.saturating_sub(name_width);
729                let padded = format!("{}{}", name, " ".repeat(pad));
730                if selected {
731                    format!("▸ /{}  {}", padded, desc)
732                } else {
733                    format!("  /{}  {}", padded, desc)
734                }
735            }
736            super::MenuKind::AtMention => {
737                // `+ <path>` for every row; selection is signalled by
738                // reverse-video on the row, no extra arrow needed.
739                if desc.is_empty() {
740                    format!("+ {}", name)
741                } else {
742                    format!("+ {}  {}", name, desc)
743                }
744            }
745        };
746
747        let style = if selected {
748            CellStyle {
749                fg: None,
750                bold: true,
751                reverse: true,
752                faint: false,
753            }
754        } else {
755            // Use terminal default fg (Secondary) instead of Muted
756            // (SGR 90 / DarkGrey). Several iTerm2 dark presets render
757            // bright-black at near-zero contrast against the bg, which
758            // makes the entire menu list invisible. Visual hierarchy
759            // here comes from the ▸ arrow + reverse-video on the
760            // selected row, not from a colour-contrast distinction.
761            self.style_for(Role::Secondary)
762        };
763        push_str_cells(&mut row, &content, &style);
764
765        if selected {
766            let content_w = crate::width::display_width(&content);
767            let right_pad = rule_width.saturating_sub(content_w);
768            for _ in 0..right_pad {
769                row.push(Cell {
770                    ch: ' ',
771                    style: style.clone(),
772                    width: 1,
773                });
774            }
775        }
776        row
777    }
778
779    fn build_status_row(&self, status: &StatusLine, rule_width: usize) -> Vec<Cell> {
780        let mut row = Vec::new();
781        let pad = CellStyle::default();
782        push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
783
784        // Status row carries load-bearing info (model / cwd / token count)
785        // and live hints. Use faint (SGR 2) over the terminal default fg:
786        // theme-aware muting that reads as subordinate without picking a
787        // fixed gray (DarkGrey collides with several iTerm2 light presets;
788        // unmuted default fg made the status row compete with primary
789        // body content on dark presets — see screenshot regression).
790        let secondary = self.style_faint(Role::Secondary);
791        let error = self.style_for(Role::Error);
792        let brand = self.style_for(Role::Brand);
793
794        // Mode indicator first — non-default modes (Plan today) prepend
795        // a brand-colored badge so the user sees at a glance that file
796        // edits / shell are gated. Build (default) is None and adds
797        // nothing.
798        let mode_badge: Option<String> = status
799            .mode_indicator
800            .as_ref()
801            .map(|s| scrub_controls(s));
802        let mode_badge_w = mode_badge
803            .as_ref()
804            .map(|s| crate::width::display_width(s) + 1) // +1 for the trailing space separator
805            .unwrap_or(0);
806
807        // Hint right-alignment math must reserve space for the mode badge
808        // so the badge never collides with the right-aligned hint when the
809        // status row is wide.
810        let max = rule_width.max(1);
811        let left_max = max.saturating_sub(mode_badge_w);
812
813        // Pre-truncate the cwd so that model + ctx_usage still get space
814        // on narrow terminals.  Budget for cwd: subtract model width and
815        // the " · " separator widths from left_max.  If the cwd alone
816        // would eat the entire row, `truncate_path` replaces leading
817        // segments with ".../" and keeps only the last segment.
818        let model_str = if !status.model.is_empty() {
819            scrub_controls(&status.model)
820        } else {
821            String::new()
822        };
823        let ctx_str = if status.ctx_used > 0 {
824            format_ctx_usage(status.ctx_used, status.ctx_window)
825        } else {
826            String::new()
827        };
828        // Widths of the static " · " separators between visible parts.
829        let sep_w = if !model_str.is_empty() { 3 } else { 0 }
830            + if !ctx_str.is_empty() && (!model_str.is_empty() || !status.cwd.is_empty()) {
831                3
832            } else {
833                0
834            };
835        let cwd_budget = left_max
836            .saturating_sub(crate::width::display_width(&model_str))
837            .saturating_sub(crate::width::display_width(&ctx_str))
838            .saturating_sub(sep_w);
839
840        let mut parts: Vec<String> = Vec::with_capacity(3);
841        if !model_str.is_empty() {
842            parts.push(model_str);
843        }
844        if !status.cwd.is_empty() {
845            let cwd_full = scrub_controls(&status.cwd);
846            let cwd_display = if cwd_budget > 0 && crate::width::display_width(&cwd_full) > cwd_budget {
847                crate::width::truncate_path(&cwd_full, cwd_budget)
848            } else if cwd_budget == 0 {
849                crate::width::truncate_path(&cwd_full, left_max)
850            } else {
851                cwd_full
852            };
853            parts.push(cwd_display);
854        }
855        if !ctx_str.is_empty() {
856            parts.push(ctx_str);
857        }
858        let left = parts.join(" · ");
859
860        // Helper: emit the badge (with trailing space) then the rest, so
861        // the mode indicator is always at column 0 (after PAD_COL) and
862        // both hint / no-hint branches share the same prefix.
863        let push_badge = |row: &mut Vec<Cell>| {
864            if let Some(badge) = &mode_badge {
865                push_str_cells(row, badge, &brand);
866                push_str_cells(row, " ", &pad);
867            }
868        };
869
870        if let Some((raw_hint, severity)) = status.hint.as_ref() {
871            let hint = scrub_controls(raw_hint);
872            let hint_w = crate::width::display_width(&hint);
873            let hint_style = match severity {
874                crate::render::HintSeverity::Warning => error,
875                crate::render::HintSeverity::Info => secondary.clone(),
876            };
877            if hint_w + 1 < left_max {
878                let left_budget = left_max - hint_w - 1;
879                let left_truncated = crate::width::truncate_to_width(&left, left_budget);
880                let left_w = crate::width::display_width(&left_truncated);
881                let pad_w = max - mode_badge_w - left_w - hint_w;
882                push_badge(&mut row);
883                push_str_cells(&mut row, &left_truncated, &secondary);
884                push_str_cells(&mut row, &" ".repeat(pad_w), &pad);
885                push_str_cells(&mut row, &hint, &hint_style);
886            } else {
887                let truncated = crate::width::truncate_to_width(&left, left_max);
888                push_badge(&mut row);
889                push_str_cells(&mut row, &truncated, &secondary);
890            }
891        } else {
892            let truncated = crate::width::truncate_to_width(&left, left_max);
893            push_badge(&mut row);
894            push_str_cells(&mut row, &truncated, &secondary);
895        }
896        row
897    }
898
899    /// Paint the full footer into `self.screen`. Layout mirrors
900    /// `AnsiRenderer::draw_footer_here_with_prev_cursor`:
901    ///
902    ///   row 0: spinner (or blank margin)
903    ///   row 1: top rule
904    ///   rows 2..2+N: middle input lines (N = wrap_with_cursor line count)
905    ///   row 2+N: bottom rule
906    ///   rows 3+N..3+N+M: menu items (M = 0..4)
907    ///   row 3+N+M: status line (if any chrome)
908    ///
909    /// Total rows = 1 + 1 + N + 1 + M + status_rows (where status is
910    /// 0 or 1). `footer_top = screen.height - total_rows`. Cursor
911    /// parks at `(footer_top + 2 + cursor_row_in_middle,
912    /// PAD_COL + 2 + cursor_col_in_row)` — 1-indexed at emit.
913    fn paint_footer(&mut self) {
914        let w = self.screen.width() as usize;
915        let h = self.screen.height() as usize;
916        if h == 0 || w == 0 {
917            return;
918        }
919        // menu/status keep the PAD_COL margin for visual balance; only
920        // the input-box rules and middle row go full-width so the box
921        // hugs the screen edges (per user request: remove left/right
922        // padding for the input box only).
923        let rule_width = w.saturating_sub(PAD_COL * 2);
924        let input_rule_width = w;
925        // "> " prompt prefix is 2 display cols; text fills the rest.
926        let text_budget = input_rule_width.saturating_sub(2);
927
928        // Wrap input + locate cursor in wrapped layout.
929        let safe = scrub_controls(&self.input_buf);
930        let (mut lines, cursor_row_in_middle, cursor_col_in_row) = if text_budget == 0 {
931            (vec![String::new()], 0usize, 0usize)
932        } else {
933            crate::width::wrap_with_cursor(&safe, text_budget, self.input_cursor_byte)
934        };
935        if lines.is_empty() {
936            lines.push(String::new());
937        }
938        let middle_rows = lines.len();
939
940        // Paginate menu to 4 items in view around `selected`.
941        let (menu_items, selected_in_view) = if let Some(m) = self.menu.as_ref() {
942            let len = m.items.len();
943            if len == 0 {
944                (Vec::<(String, String)>::new(), None)
945            } else {
946                let offset = if len <= 4 {
947                    0
948                } else if m.selected < 4 {
949                    0
950                } else {
951                    (m.selected + 1)
952                        .saturating_sub(4)
953                        .min(len.saturating_sub(4))
954                };
955                let end = (offset + 4).min(len);
956                let items: Vec<(String, String)> = m.items[offset..end].to_vec();
957                let sel = if m.selected >= offset && m.selected < end {
958                    Some(m.selected - offset)
959                } else {
960                    None
961                };
962                (items, sel)
963            }
964        } else {
965            (Vec::new(), None)
966        };
967
968        // Spinner moved to body as a live paragraph row — footer no
969        // longer reserves a spinner slot. Footer layout:
970        //   top_rule / middle... / bot_rule / menu... / status
971        let menu_rows = menu_items.len().min(4);
972        // Attachment-preview rows: one `└ [Image #N]` per kept marker,
973        // sitting between bot_rule and the menu. The list arrives
974        // pre-filtered by `compute_input_attachments` (only markers
975        // backed by real bytes survive), so we trust it directly here
976        // and don't re-validate against `input_buf`.
977        let attachment_rows = self.input_attachments.len();
978        let has_status = !self.status.model.is_empty()
979            || !self.status.cwd.is_empty()
980            || self.status.hint.is_some();
981        let status_rows = if has_status { 1 } else { 0 };
982        let total_rows = 1 + middle_rows + 1 + attachment_rows + menu_rows + status_rows;
983        let footer_top = h.saturating_sub(total_rows);
984
985        // Pre-build every row vector (immutable borrows of self).
986        let top_rule = self.build_top_rule_with_badge(
987            input_rule_width,
988            self.status.session_name.as_deref(),
989        );
990        let middle_cells: Vec<Vec<Cell>> = lines
991            .iter()
992            .enumerate()
993            .map(|(i, line)| self.build_middle_row(line, i == 0))
994            .collect();
995        let bot_rule = self.build_rule_row(input_rule_width);
996        let status_clone = self.status.clone();
997        let status_cells = if has_status {
998            Some(self.build_status_row(&status_clone, rule_width))
999        } else {
1000            None
1001        };
1002        let menu_kind = self
1003            .menu
1004            .as_ref()
1005            .map(|m| m.kind)
1006            .unwrap_or_default();
1007        let menu_cells: Vec<Vec<Cell>> = menu_items
1008            .iter()
1009            .enumerate()
1010            .map(|(i, (name, desc))| {
1011                let selected = selected_in_view == Some(i);
1012                self.build_menu_row(name, desc, selected, rule_width, menu_kind)
1013            })
1014            .collect();
1015        // Attachment rows: `  └ [Image #N]` in muted gray, identical
1016        // visual treatment to the post-submit `UiLine::ImageAttachment`
1017        // echo. PAD_COL is the leading 2-space indent every body /
1018        // footer info row uses; the `└` then sits at col 2, aligned
1019        // with the `[` of `[Image #N]` in the user input above.
1020        let attachment_cells: Vec<Vec<Cell>> = self
1021            .input_attachments
1022            .iter()
1023            .map(|n| {
1024                let mut row = Vec::new();
1025                let pad = CellStyle::default();
1026                push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
1027                let muted = self.style_for(Role::Muted);
1028                push_str_cells(&mut row, &format!("└ [Image #{}]", n), &muted);
1029                row
1030            })
1031            .collect();
1032
1033        // Mutate screen (now &mut self). Every footer row is padded to
1034        // screen width before emit so blank cells overwrite any stale
1035        // body content still showing from earlier frames (see
1036        // `pad_row_to_width` for full rationale).
1037        let mut top_rule = top_rule;
1038        Self::pad_row_to_width(&mut top_rule, w);
1039        self.screen.draw_row(footer_top, 0, &top_rule);
1040
1041        for (i, r) in middle_cells.into_iter().enumerate() {
1042            let mut padded = r;
1043            Self::pad_row_to_width(&mut padded, w);
1044            self.screen.draw_row(footer_top + 1 + i, 0, &padded);
1045        }
1046
1047        let bot_rule_row = footer_top + 1 + middle_rows;
1048        let mut bot_rule = bot_rule;
1049        Self::pad_row_to_width(&mut bot_rule, w);
1050        self.screen.draw_row(bot_rule_row, 0, &bot_rule);
1051
1052        for (i, r) in attachment_cells.into_iter().enumerate() {
1053            let mut padded = r;
1054            Self::pad_row_to_width(&mut padded, w);
1055            self.screen.draw_row(bot_rule_row + 1 + i, 0, &padded);
1056        }
1057
1058        let menu_top = bot_rule_row + 1 + attachment_rows;
1059        for (i, r) in menu_cells.into_iter().enumerate() {
1060            let mut padded = r;
1061            Self::pad_row_to_width(&mut padded, w);
1062            self.screen.draw_row(menu_top + i, 0, &padded);
1063        }
1064        if let Some(st) = status_cells {
1065            let mut padded = st;
1066            Self::pad_row_to_width(&mut padded, w);
1067            self.screen.draw_row(menu_top + menu_rows, 0, &padded);
1068        }
1069
1070        // Cursor park — 1-indexed, inside middle row at the input cell.
1071        // Input row is flush-left (no PAD_COL); "> " prefix is 2 cols.
1072        // Symbol-bearing body rows share this col-0 baseline.
1073        // Middle row lives at `footer_top + 1 + cursor_row_in_middle`
1074        // (0-indexed); +1 more to convert to the 1-indexed form the
1075        // cursor-set helper expects.
1076        let cursor_abs_row = (footer_top + 1 + cursor_row_in_middle + 1) as u16;
1077        let cursor_abs_col = (2 + cursor_col_in_row + 1) as u16;
1078        self.screen.set_cursor(cursor_abs_row, cursor_abs_col);
1079        // Hide the terminal cursor while EITHER a live spinner OR an
1080        // inflight-tool row is animating. The inflight branch was added
1081        // when `render_inflight_tool` switched to direct cursor-position
1082        // writes (to fix the scrollback-leak bug): those writes leave
1083        // the real terminal cursor at end-of-row, but `screen` doesn't
1084        // know that since it bypasses the cell-diff path. Without
1085        // hiding, the user sees a blinking caret floating at the right
1086        // edge of the active `▸ Bash(...)` row in addition to the input
1087        // box's caret. `inflight_tool.is_none()` flips back as soon as
1088        // the call commits, so the cursor reappears at the input box on
1089        // the very next 5ms paint tick.
1090        let suppress_cursor = self.live_spinner_active || self.inflight_tool.is_some();
1091        self.screen.set_cursor_visible(!suppress_cursor);
1092    }
1093
1094    /// Footer total height — mirrors the computation inside
1095    /// `paint_footer` so `paint_body` knows where body_bottom lands.
1096    fn current_footer_rows(&self) -> usize {
1097        // Mirror paint_footer: input box is full-width (only "> " prefix).
1098        let text_budget = (self.screen.width() as usize).saturating_sub(2);
1099        let safe = scrub_controls(&self.input_buf);
1100        let middle_rows = if text_budget == 0 {
1101            1
1102        } else {
1103            crate::width::wrap_with_cursor(&safe, text_budget, self.input_cursor_byte)
1104                .0
1105                .len()
1106                .max(1)
1107        };
1108        let menu_rows = self
1109            .menu
1110            .as_ref()
1111            .map(|m| m.items.len().min(4))
1112            .unwrap_or(0);
1113        let has_status = !self.status.model.is_empty()
1114            || !self.status.cwd.is_empty()
1115            || self.status.hint.is_some();
1116        let status_rows = if has_status { 1 } else { 0 };
1117        let attachment_rows = self.input_attachments.len();
1118        // 1 top rule + middle + 1 bot rule + attachments + menu + status.
1119        // (Spinner used to reserve a row here but now lives in body as
1120        // a live paragraph — see `push_or_update_live_spinner`.)
1121        1 + middle_rows + 1 + attachment_rows + menu_rows + status_rows
1122    }
1123
1124    /// Single-entry-point for painting a full frame. Body is already
1125    /// on-screen (written append-style by `emit_body_line`), so the
1126    /// frame paint just refreshes the footer strip + DECSTBM region.
1127    fn paint_frame(&mut self) {
1128        self.ensure_scroll_region();
1129        self.paint_footer();
1130    }
1131
1132    /// 1-indexed row of the bottom line of the body area (= top of
1133    /// the footer strip minus 1). `0` means "footer occupies the
1134    /// whole viewport" — in that pathological case we skip body
1135    /// emit entirely rather than clobber the footer.
1136    fn body_bottom_row(&self) -> u16 {
1137        let h = self.screen.height() as usize;
1138        let footer_rows = self.current_footer_rows();
1139        h.saturating_sub(footer_rows) as u16
1140    }
1141
1142    /// Sync the terminal's DECSTBM scroll region with the current
1143    /// body_bottom. Called at the top of `paint_frame` and before
1144    /// every body-line emit so `\n` in `emit_body_line` only scrolls
1145    /// the body strip — the footer stays pinned below.
1146    ///
1147    /// When the footer grows (body shrinks), we just shrink the
1148    /// region: the footer's own cell-diff paint will overwrite any
1149    /// body text that now lives in footer rows. When the footer
1150    /// shrinks (body grows), rows that were formerly footer need
1151    /// a physical wipe — easier to just clear+reflow the body
1152    /// tail than to track which rows dirty; viewport-only clear
1153    /// preserves scrollback.
1154    fn ensure_scroll_region(&mut self) {
1155        let bottom = self.body_bottom_row();
1156        if bottom == 0 {
1157            // Footer fills the viewport; release any region so
1158            // subsequent paints behave like classic full-screen.
1159            if self.scroll_region_bottom.is_some() {
1160                let _ = self.out.write_all(b"\x1b[r");
1161                self.scroll_region_bottom = None;
1162            }
1163            return;
1164        }
1165        if self.scroll_region_bottom == Some(bottom) {
1166            return;
1167        }
1168        // Capture the old region bottom BEFORE swapping in the new
1169        // value — needed by the repaint branch below to know which
1170        // rows may still hold stale body glyphs.
1171        let prev_bottom = self.scroll_region_bottom;
1172        let changed = matches!(prev_bottom, Some(prev) if prev != bottom);
1173        // Set the new region. 1-indexed, inclusive: `\x1b[1;N r`.
1174        // Pre-format into one buffer so the write hits the stream as
1175        // a single call — BufWriter's `write!` can fragment into 3-4
1176        // tiny write calls otherwise (Display adapter path), which
1177        // the chunk-counting test harness then observes as separate
1178        // "chunks" below the 512 B threshold.
1179        let seq = format!("\x1b[1;{}r", bottom);
1180        let _ = self.out.write_all(seq.as_bytes());
1181        self.scroll_region_bottom = Some(bottom);
1182        if changed {
1183            // Region shifted (footer grew or shrank). The visible
1184            // body rows are now misaligned with body_lines — either
1185            // stale body glyphs sit in what are now footer rows, or
1186            // new blank rows opened up above the footer. Repaint
1187            // the body in place so the viewport matches body_lines.
1188            //
1189            // CRITICAL — two constraints that together rule out the
1190            // obvious "2J + re-emit" approach:
1191            //
1192            //  1. No `\n`-based re-emit. `emit_body_line_inner` writes
1193            //     LF at region bottom, which promotes the region-top
1194            //     row into scrollback on every call. Each cached body
1195            //     row already scrolled into scrollback once during its
1196            //     original emit; re-emitting via LF here duplicates
1197            //     those rows in scrollback (user report: "往上翻会看
1198            //     到重复内容残留" after `/model`).
1199            //
1200            //  2. No `\x1b[2J`. macOS Terminal.app, iTerm2, and xterm
1201            //     with `cbScrollback` copy every non-blank visible row
1202            //     into scrollback when processing ED. That means the
1203            //     very first footer-height transition after startup
1204            //     (status line appears, body_bottom shrinks by 1)
1205            //     shoves the whole welcome banner into scrollback
1206            //     before we get a chance to repaint it (user report:
1207            //     "首次启动都出现了两次,上面的不带输入框").
1208            //
1209            // Instead: paint the tail of body_lines at absolute
1210            // positions with per-row EL (`\x1b[K`) for any stale
1211            // content, invalidate the cell cache so the footer diff
1212            // repaints rows fresh below body_bottom, and explicitly
1213            // erase the narrow "transition zone" — rows that changed
1214            // zone between old and new layouts and can't rely on
1215            // either writer to clean them:
1216            //
1217            //  * SHRINK: rows (new_bottom+1)..=prev_bottom were body,
1218            //    now footer. Footer diff would paint blank cells for
1219            //    those rows (e.g., the spinner slot when no spinner
1220            //    is active), but invalidated prev_cells are also
1221            //    blank → diff skips blank→blank and stale body
1222            //    glyphs persist. Symptom of the first-startup bug:
1223            //    welcome's last row "leaks" into the spinner slot.
1224            //
1225            //  * GROW: rows (prev_body_top)..(new_body_top) were the
1226            //    top of the old body but now sit above the new body
1227            //    anchor and aren't covered by either painter
1228            //    ("zombie zone" — fixed the `/` then Esc ghost
1229            //    regression).
1230            //
1231            // Per-row EL is row-local (no scroll, no ED) so it can't
1232            // leak content into scrollback the way `\x1b[2J` does on
1233            // macOS Terminal.app / iTerm2.
1234            let cap = bottom as usize;
1235            let total = self.body_lines.len();
1236            let start = total.saturating_sub(cap);
1237            let visible_count = total - start;
1238
1239            if let Some(prev) = prev_bottom.map(|v| v as usize) {
1240                // Erase the union of old and new footer regions
1241                // (rows min(prev,cap)+1 ..= h).
1242                //
1243                // Why the full union: the footer writer after this
1244                // runs `invalidate()` (prev_cells all blank) and
1245                // then only emits patches where new cells differ
1246                // from blank. `pad_row_to_width` fills middle /
1247                // spinner / absent-menu rows with default-style
1248                // blanks — those match prev blanks → no erase
1249                // patches. Meanwhile the terminal still holds the
1250                // prior frame's top_rule / bot_rule `─`-filled
1251                // cells at rows that are now blank in the new
1252                // layout.
1253                //
1254                // Two symptoms this protects against:
1255                //   * SHRINK: `❯ 1─────` — new middle content sits
1256                //     at an absolute row that used to be top_rule;
1257                //     the rule tail bleeds through.
1258                //   * GROW: Shift+Enter then delete leaves an
1259                //     extra ─── line above the input box — the
1260                //     old top_rule row lands on the new spinner
1261                //     slot (paint_footer writes a blank row there
1262                //     when no spinner is active), cell diff sees
1263                //     blank→blank, stale rule persists.
1264                //
1265                // Cost: a small handful of CUP+EL pairs per footer
1266                // resize (not per frame). EL is row-local → no
1267                // scroll, no scrollback pollution.
1268                let screen_h = self.screen.height() as usize;
1269                let transition_start = prev.min(cap) + 1;
1270                for row in transition_start..=screen_h {
1271                    let seq = format!("\x1b[{};1H\x1b[K", row);
1272                    let _ = self.out.write_all(seq.as_bytes());
1273                }
1274
1275                // Grow case only: the "zombie zone" above the new
1276                // body anchor — rows that held the top of the old
1277                // body but sit above the new body position and
1278                // aren't covered by either body paint or footer
1279                // diff. Fixed the menu-close ghost welcome
1280                // regression.
1281                if prev < cap && visible_count > 0 {
1282                    let prev_body_top = prev.saturating_sub(visible_count) + 1;
1283                    let new_body_top = cap.saturating_sub(visible_count) + 1;
1284                    if prev_body_top < new_body_top {
1285                        for row in prev_body_top..new_body_top {
1286                            let seq = format!("\x1b[{};1H\x1b[K", row);
1287                            let _ = self.out.write_all(seq.as_bytes());
1288                        }
1289                    }
1290                }
1291            }
1292
1293            self.screen.invalidate();
1294
1295            let start_row = (cap - visible_count) as u16 + 1;
1296            // Clone once; serialize_row borrows immutably, the
1297            // write borrows &mut self.out which is disjoint from
1298            // body_lines.
1299            let rows: Vec<Vec<Cell>> = self.body_lines[start..].to_vec();
1300            for (i, row) in rows.iter().enumerate() {
1301                let seq = format!("\x1b[{};1H\x1b[K", start_row + i as u16);
1302                let _ = self.out.write_all(seq.as_bytes());
1303                let bytes = serialize_row(row);
1304                let _ = self.out.write_all(&bytes);
1305            }
1306            // Park the cursor at the bottom of the body region so
1307            // the next `emit_body_line_inner` (with `\n` at bottom)
1308            // behaves the same as if the region had been stable all
1309            // along.
1310            let seq = format!("\x1b[{};1H", bottom);
1311            let _ = self.out.write_all(seq.as_bytes());
1312        }
1313    }
1314
1315    /// Write one body row to stdout at the bottom of the scroll
1316    /// region, scrolling the region up one line (oldest line enters
1317    /// scrollback, DECSTBM contains the scroll to the body strip).
1318    /// Assumes `ensure_scroll_region` has already set the region.
1319    ///
1320    /// When `skip_body_scroll_count` is non-zero (see `pop_approval_prompt`),
1321    /// the LF is skipped — the new row overwrites whatever was sitting
1322    /// at body_bottom (typically the freshly-popped approval prompt)
1323    /// so the visual flow `▸ Tool` → `⎿ result` has no gap.
1324    fn emit_body_line_inner(&mut self, row: &[Cell], bottom: u16) {
1325        // `\x1b[K` (EL — erase from cursor to end of line) runs AFTER
1326        // reposition and BEFORE writing the row. ECMA-48 says SU at
1327        // bottom of a scroll region must blank the new bottom row, but
1328        // Terminal.app and iTerm2 both leave stale cells there when the
1329        // source content was wider than the new row. Without the
1330        // explicit erase, short rows (e.g., "> hi", "(cancelled)", an
1331        // empty spacer) let the previous row's tail bleed through —
1332        // classic symptom was `/provider  to add a custom model` from
1333        // the welcome banner leaking past shorter subsequent rows.
1334        if self.skip_body_scroll_count > 0 {
1335            // In-place overwrite: position + erase, no LF (so the
1336            // body region isn't shifted up; the prior approval prompt
1337            // at body_bottom gets replaced cleanly). Each skipped
1338            // scroll closes one row of the gap left by
1339            // pop_approval_prompt.
1340            let target = bottom.saturating_sub(self.skip_body_scroll_count - 1);
1341            let seq = format!("\x1b[{};1H\x1b[K", target);
1342            let _ = self.out.write_all(seq.as_bytes());
1343            self.skip_body_scroll_count -= 1;
1344        } else {
1345            let seq = format!("\x1b[{};1H\n\x1b[{};1H\x1b[K", bottom, bottom);
1346            let _ = self.out.write_all(seq.as_bytes());
1347        }
1348        let bytes = serialize_row(row);
1349        let _ = self.out.write_all(&bytes);
1350    }
1351
1352    /// Erase the live spinner if one is active: pop the transient
1353    /// last row from `body_lines`, wipe its cells from the terminal
1354    /// at `body_bottom`, and clear the active flag. Returns true iff
1355    /// a clear actually happened, so callers (e.g. `push_body_row`)
1356    /// can arrange for their replacement row to overwrite in-place
1357    /// instead of scrolling.
1358    ///
1359    /// The spinner is treated as an in-progress indicator, not a
1360    /// historical paragraph header: any transition away from it
1361    /// (assistant text arriving, tool call pushing, user returning
1362    /// to the input prompt) means the row's purpose is done and it
1363    /// should disappear without residue — that matches what users
1364    /// expected from the old footer-based spinner (cell diff
1365    /// naturally cleared it on the next frame).
1366    fn clear_live_spinner(&mut self) -> bool {
1367        if !self.live_spinner_active {
1368            return false;
1369        }
1370        self.live_spinner_active = false;
1371        // The cursor will be re-shown on the next paint_footer (which
1372        // sees live_spinner_active=false and calls set_cursor_visible(true)).
1373        self.body_lines.pop();
1374        self.ensure_scroll_region();
1375        let bottom = self.body_bottom_row();
1376        if bottom > 0 {
1377            let seq = format!("\x1b[{};1H\x1b[K", bottom);
1378            let _ = self.out.write_all(seq.as_bytes());
1379        }
1380        true
1381    }
1382
1383    /// Append a fully-cell-formatted body row to history AND emit it
1384    /// immediately so it enters terminal scrollback. Trims oldest
1385    /// `body_lines` when over the retention cap (memory-only — rows
1386    /// already pushed to scrollback live on in the terminal's buffer).
1387    ///
1388    /// If a live spinner row is currently sitting at `body_bottom`,
1389    /// erase it first and overwrite in-place: the spinner is
1390    /// transient, the new row takes its slot without scrolling other
1391    /// history up by one.
1392    fn push_body_row(&mut self, row: Vec<Cell>) {
1393        // Any external body push freezes an active live-group: the
1394        // group's child rows are no longer guaranteed to sit at the
1395        // bottom (they may have scrolled into native scrollback the
1396        // moment this push commits a `\n`). Future ToolGroupChildUpdate
1397        // events fall back to no-op rather than CUP-rewriting some
1398        // unrelated row that took the group child's screen position.
1399        self.live_group = None;
1400        if self.clear_live_spinner() {
1401            // In-place overwrite at `body_bottom` — `emit_body_line_inner`
1402            // honours this flag to skip its LF and just CUP+EL+write at
1403            // the current bottom row. That way the slot previously held
1404            // by the spinner becomes the slot for this new body row,
1405            // with no intervening blank line.
1406            self.skip_body_scroll_count = self.skip_body_scroll_count.saturating_add(1);
1407        }
1408        // Region might be stale (first call after resume, or footer
1409        // just changed); sync before emit so the LF in emit_body_line
1410        // scrolls only within the body strip.
1411        self.ensure_scroll_region();
1412        let bottom = self.body_bottom_row();
1413        if bottom > 0 {
1414            self.emit_body_line_inner(&row, bottom);
1415        }
1416        self.body_lines.push(row);
1417        let max_keep = (self.screen.height() as usize).saturating_mul(4).max(128);
1418        if self.body_lines.len() > max_keep {
1419            let drain = self.body_lines.len() - max_keep;
1420            self.body_lines.drain(0..drain);
1421        }
1422    }
1423
1424    /// Push or update the live spinner body row. On the first call of a
1425    /// run it pushes fresh via `push_body_row` and marks the row live.
1426    /// On subsequent calls (every tick), it REPLACES `body_lines.last()`
1427    /// and re-emits at absolute `body_bottom_row()` without the
1428    /// `\n`-scroll — that way 80ms animation frames don't each push a
1429    /// new row into scrollback and don't scroll the user's real history
1430    /// off-screen.
1431    fn push_or_update_live_spinner(&mut self, row_cells: Vec<Cell>) {
1432        if self.live_spinner_active {
1433            if let Some(last) = self.body_lines.last_mut() {
1434                *last = row_cells.clone();
1435            }
1436            self.ensure_scroll_region();
1437            let bottom = self.body_bottom_row();
1438            if bottom > 0 {
1439                let seq = format!("\x1b[{};1H\x1b[K", bottom);
1440                let _ = self.out.write_all(seq.as_bytes());
1441                let bytes = serialize_row(&row_cells);
1442                let _ = self.out.write_all(&bytes);
1443            }
1444        } else {
1445            // `push_body_row` clears `live_spinner_active`; set it back
1446            // afterwards so the next tick takes the update-in-place
1447            // branch above.
1448            self.push_body_row(row_cells);
1449            self.live_spinner_active = true;
1450        }
1451        // Cursor visibility is driven by `paint_footer` reading
1452        // `live_spinner_active` — see set_cursor_visible call there.
1453        // No direct DECTCEM write here, otherwise the next render_diff
1454        // would re-emit \x1b[?25h based on screen.cursor_visible and
1455        // visually undo our hide on a 5ms cadence.
1456    }
1457
1458    /// Freeze the current inflight_tool row into the body transcript
1459    /// using `push_body_prefixed` so long commands are properly wrapped
1460    /// across multiple terminal lines. Used as the uniform commit path
1461    /// for: `ToolCallCommit`, `TurnComplete`, `TurnCancelled`, and the
1462    /// `ToolResult` fallback — same wrapping pipeline as
1463    /// `render_inflight_tool` but pushes a frozen `▸` icon and clears
1464    /// `inflight_tool_rows` so the next live tick starts fresh.
1465    fn commit_inflight_tool(&mut self) {
1466        if let Some((_id, name, detail)) = self.inflight_tool.take() {
1467            let safe_name = scrub_controls(&name);
1468            let safe_detail = scrub_controls(&detail);
1469            let body_str = if safe_detail.is_empty() {
1470                safe_name
1471            } else {
1472                format!("{}({})", safe_name, safe_detail)
1473            };
1474            // Safety cap: prevent degenerate bodies (e.g. multi-KB bash
1475            // commands) from producing hundreds of terminal lines.
1476            let body_str = truncate_body_str(&body_str, 500);
1477            // Clear any previously rendered inflight tool rows so
1478            // push_body_prefixed appends fresh committed lines.
1479            self.live_spinner_active = false;
1480            let remove = self.inflight_tool_rows.min(self.body_lines.len());
1481            self.body_lines.truncate(self.body_lines.len() - remove);
1482            self.inflight_tool_rows = 0;
1483            self.ensure_scroll_region();
1484            let bottom = self.body_bottom_row();
1485            if bottom > 0 && remove > 0 {
1486                // Erase ALL terminal rows previously occupied by the
1487                // inflight spinner (may be >1 when the command was long
1488                // enough to wrap). Without this, the old `⠙ Bash(...)`
1489                // row lingers on-screen above the freshly committed
1490                // `● Bash(...)` row, producing a visual duplicate.
1491                let start_row = bottom.saturating_sub(remove as u16 - 1).max(1);
1492                let mut seq = String::with_capacity((bottom - start_row + 1) as usize * 8);
1493                use std::fmt::Write as _;
1494                for row in start_row..=bottom {
1495                    let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
1496                }
1497                let _ = self.out.write_all(seq.as_bytes());
1498            }
1499            // The CUP+EL above erased the inflight rows in place — the
1500            // committed rows should land in those exact slots. Without
1501            // this flag, `push_body_prefixed`'s underlying
1502            // `emit_body_line_inner` emits an LF that scrolls the body
1503            // region up by one, leaving the just-erased row as a
1504            // second blank between the user message and the committed
1505            // tool call (visible as the `> question \n \n ● tool`
1506            // double-gap in screenshots). Use `remove` (not just 1)
1507            // so multi-row inflight spinners are fully covered.
1508            self.skip_body_scroll_count = self.skip_body_scroll_count.saturating_add(remove as u16);
1509            self.push_body_prefixed(
1510                // Frozen icon matches the static ToolCall arm — see its
1511                // comment for the Windows-font rationale that picked ●
1512                // (U+25CF, Geometric Shapes block) over ▸ (U+25B8,
1513                // missing from Consolas/NSimSun and rendered as `□`
1514                // tofu in screenshots).
1515                "\u{25cf} ",
1516                &self.style_for(Role::Muted),
1517                &body_str,
1518                &self.style_bold(Role::ToolName),
1519            );
1520        }
1521    }
1522
1523    /// Copy the visible body tail into the host terminal's native
1524    /// scrollback before we wipe the viewport on exit. Retained mode
1525    /// keeps the newest body rows pinned on screen behind a fixed
1526    /// footer; those rows have not naturally scrolled off yet, so a
1527    /// plain viewport clear would make the bottom of the transcript
1528    /// disappear after `/quit`.
1529    fn promote_visible_body_to_scrollback(&mut self) {
1530        let bottom = self.body_bottom_row() as usize;
1531        if bottom == 0 || self.body_lines.is_empty() {
1532            return;
1533        }
1534
1535        let screen_w = self.screen.width() as usize;
1536        let screen_h = self.screen.height() as usize;
1537        let n = self.body_lines.len().min(bottom);
1538        if n == 1 && screen_h < 2 {
1539            return;
1540        }
1541        let start = self.body_lines.len() - n;
1542        let rows: Vec<Vec<Cell>> = self.body_lines[start..]
1543            .iter()
1544            .map(|row| clip_cells_to_width(row, screen_w))
1545            .collect();
1546
1547        // Repaint the visible transcript tail at the top of a temporary
1548        // top-anchored scroll region, then LF each row out of that
1549        // region. Top-anchored DECSTBM is the path terminals promote
1550        // into native scrollback; absolute repainting itself has no
1551        // scrollback side effect.
1552        let region_bottom = if n == 1 { 2 } else { n } as u16;
1553        let seq = format!("\x1b[1;{}r", region_bottom);
1554        let _ = self.out.write_all(seq.as_bytes());
1555        for (i, row) in rows.iter().enumerate() {
1556            let seq = format!("\x1b[{};1H\x1b[K", i + 1);
1557            let _ = self.out.write_all(seq.as_bytes());
1558            let bytes = serialize_row(row);
1559            let _ = self.out.write_all(&bytes);
1560        }
1561        if region_bottom as usize > n {
1562            let seq = format!("\x1b[{};1H\x1b[K", region_bottom);
1563            let _ = self.out.write_all(seq.as_bytes());
1564        }
1565        let seq = format!("\x1b[{};1H", region_bottom);
1566        let _ = self.out.write_all(seq.as_bytes());
1567        for _ in 0..n {
1568            let _ = self.out.write_all(b"\n");
1569        }
1570        self.scroll_region_bottom = Some(region_bottom);
1571    }
1572
1573    /// Wrap `text` to content width and push each wrapped chunk as
1574    /// its own body row with a PAD_COL prefix. Used by variants
1575    /// whose content is plain (assistant text, command output).
1576    fn push_body_text(&mut self, text: &str, style: &CellStyle) {
1577        let w = (self.screen.width() as usize).saturating_sub(PAD_COL * 2);
1578        if w == 0 {
1579            return;
1580        }
1581        // `text.split('\n')` on `"foo\n"` yields `["foo", ""]` and the
1582        // empty chunk pushes a blank row. Callers rely on this to add
1583        // a trailing breathing-row after their content (e.g. the
1584        // bash `Ctrl+O` hint, status echoes from `/model`/`/login`).
1585        // Internal `\n`s split into multiple rows. Don't pre-strip the
1586        // trailing `\n` — that's a meaningful "give me a separator"
1587        // signal at the call site, not noise.
1588        for phys in text.split('\n') {
1589            for chunk in crate::width::wrap_line_to_width(phys, w) {
1590                let mut row = Vec::new();
1591                let pad = CellStyle::default();
1592                push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
1593                push_str_cells(&mut row, &chunk, style);
1594                self.push_body_row(row);
1595            }
1596        }
1597    }
1598
1599    /// SGR-aware variant of `push_body_text` for **trusted** content
1600    /// that may carry inline `\x1b[...m` colour / bold / faint /
1601    /// reverse spans (e.g. the `/codingplan` setup report's red
1602    /// locked-model rows). Splits on `\n`, wraps each physical line,
1603    /// and feeds each chunk through `push_str_cells_sgr` so the
1604    /// working style mutates as cells are produced. SGR state resets
1605    /// at every `\n` so a forgotten reset doesn't bleed colour into
1606    /// the next logical row.
1607    ///
1608    /// Only used from the `UiLine::CommandOutput` arm — every other
1609    /// caller has plain text and stays on the simpler
1610    /// `push_body_text`.
1611    fn push_body_text_sgr(&mut self, text: &str) {
1612        let w = (self.screen.width() as usize).saturating_sub(PAD_COL * 2);
1613        if w == 0 {
1614            return;
1615        }
1616        for phys in text.split('\n') {
1617            let mut style = CellStyle::default();
1618            for chunk in crate::width::wrap_line_to_width(phys, w) {
1619                let mut row = Vec::new();
1620                push_str_cells(&mut row, &" ".repeat(PAD_COL), &CellStyle::default());
1621                style = crate::render::cell::push_str_cells_sgr(&mut row, &chunk, style);
1622                self.push_body_row(row);
1623            }
1624        }
1625    }
1626
1627    /// Build one row with a leading `prefix` (often an accent
1628    /// glyph with its own style) and a plain-styled body. Used by
1629    /// User echo ("> …"), ToolCall ("▸ name(detail)"), etc.
1630    ///
1631    /// Multi-line `body` (Shift+Enter in the input, or a tool detail
1632    /// that happens to contain `\n`) is split on '\n' BEFORE width
1633    /// wrapping — otherwise the newlines ride through as width-1 cells
1634    /// and `serialize_row` writes them to stdout as bare LF bytes,
1635    /// which under raw-mode + DECSTBM produces the staircase pattern
1636    /// (cursor drops a row without returning to col 1, every LF also
1637    /// triggers a region scroll).
1638    fn push_body_prefixed(
1639        &mut self,
1640        prefix: &str,
1641        prefix_style: &CellStyle,
1642        body: &str,
1643        body_style: &CellStyle,
1644    ) {
1645        let rows = self.build_prefixed_rows(prefix, prefix_style, body, body_style);
1646        for row in rows {
1647            self.push_body_row(row);
1648        }
1649    }
1650
1651    /// Symbol-anchored row builder. Wraps `body` to `screen_width − PAD_COL`,
1652    /// emits the leading row with `prefix`, continuation rows with a blank
1653    /// pad of equal display width. Pure: no side effects on `body_lines`
1654    /// or terminal output. Used by `push_body_prefixed` (which appends each
1655    /// row via push_body_row) and `render_inflight_tool` (which writes
1656    /// in-place over previously-rendered inflight rows during spinner
1657    /// ticks — see that fn's doc comment for the scrollback-leak bug
1658    /// this split addresses).
1659    fn build_prefixed_rows(
1660        &self,
1661        prefix: &str,
1662        prefix_style: &CellStyle,
1663        body: &str,
1664        body_style: &CellStyle,
1665    ) -> Vec<Vec<Cell>> {
1666        let w = (self.screen.width() as usize).saturating_sub(PAD_COL);
1667        if w == 0 {
1668            return Vec::new();
1669        }
1670        let prefix_w = crate::width::display_width(prefix);
1671        let first_budget = w.saturating_sub(prefix_w);
1672        let cont_pad: String = " ".repeat(prefix_w);
1673        let mut rows = Vec::new();
1674        let mut first_emitted = false;
1675        for phys in body.split('\n') {
1676            let chunks: Vec<String> = crate::width::wrap_line_to_width(phys, first_budget.max(1))
1677                .into_iter()
1678                .map(|c| c.to_string())
1679                .collect();
1680            for chunk in &chunks {
1681                let mut row = Vec::new();
1682                let pad = CellStyle::default();
1683                if !first_emitted {
1684                    push_str_cells(&mut row, prefix, prefix_style);
1685                    first_emitted = true;
1686                } else {
1687                    push_str_cells(&mut row, &cont_pad, &pad);
1688                }
1689                push_str_cells(&mut row, chunk.as_str(), body_style);
1690                rows.push(row);
1691            }
1692        }
1693        rows
1694    }
1695
1696    /// Flush complete lines (those terminated by `\n`) from the
1697    /// streaming assistant buffer into `body_lines`, rendering
1698    /// each through the markdown inline renderer so bold / inline
1699    /// code / lists / headings get their styled cells.
1700    fn flush_assistant_lines(&mut self) {
1701        if !self.assistant_line_buf.contains('\n') {
1702            return;
1703        }
1704        let md_width = (self.screen.width() as usize).saturating_sub(PAD_COL * 2);
1705        let mut completed: Vec<String> = Vec::new();
1706        while let Some(nl) = self.assistant_line_buf.find('\n') {
1707            let line: String = self.assistant_line_buf.drain(..=nl).collect();
1708            let content = line[..line.len() - 1].to_string();
1709            if let Some(rendered) = crate::markdown::render_line_with_width(
1710                &content,
1711                &mut self.md_state,
1712                self.caps,
1713                md_width,
1714            ) {
1715                completed.push(rendered);
1716            }
1717        }
1718        for rendered in completed {
1719            self.push_markdown_body(&rendered);
1720        }
1721    }
1722
1723    /// Turn the partial buffer into a body row (as if `\n`
1724    /// terminated). Called on AssistantLineBreak / TurnComplete.
1725    /// Also drains any trailing markdown block buffer (tables that
1726    /// ended without a following non-table line).
1727    fn flush_assistant_remainder(&mut self) {
1728        let md_width = (self.screen.width() as usize).saturating_sub(PAD_COL * 2);
1729        if !self.assistant_line_buf.is_empty() {
1730            let line = std::mem::take(&mut self.assistant_line_buf);
1731            if let Some(rendered) = crate::markdown::render_line_with_width(
1732                &line,
1733                &mut self.md_state,
1734                self.caps,
1735                md_width,
1736            ) {
1737                self.push_markdown_body(&rendered);
1738            }
1739        }
1740        if let Some(block) =
1741            crate::markdown::finalize_with_width(&mut self.md_state, self.caps, md_width)
1742        {
1743            self.push_markdown_body(&block);
1744        }
1745    }
1746
1747    /// Parse a markdown-rendered string (ANSI-tinted) into cells
1748    /// and push each wrapped line to body history. Wrap is done
1749    /// at cell level (not byte level) so wide glyphs and SGR
1750    /// state survive the split.
1751    fn push_markdown_body(&mut self, rendered: &str) {
1752        let w = (self.screen.width() as usize).saturating_sub(PAD_COL * 2);
1753        if w == 0 {
1754            return;
1755        }
1756        // Collapse consecutive blank assistant lines. Some models
1757        // (MiniMax-M2.7 in particular) emit `\n\n\n…` between tool
1758        // calls and paragraphs; verbatim rendering produces multi-row
1759        // vertical gaps that feel "unfinished". Allow at most one
1760        // blank row in a row — enough for paragraph separation,
1761        // nothing more.
1762        //
1763        // Special case: when the live spinner is the tail row, also
1764        // skip blank pushes. Many models emit a leading `\n` warm-up
1765        // before the first real reply chunk. Without this, that
1766        // leading blank evicts the spinner + leaves a ghost blank
1767        // row that the NEXT (non-blank) chunk then scrolls above
1768        // the real content — producing a visible double-blank
1769        // between the user message and the assistant reply. The
1770        // spinner itself is transient (not a historical paragraph),
1771        // so there's no paragraph boundary here worth marking with
1772        // a blank.
1773        let is_blank = rendered.trim().is_empty();
1774        if is_blank {
1775            let tail_blank = self
1776                .body_lines
1777                .last()
1778                .map(|r| r.iter().all(|c| c.ch == ' '))
1779                .unwrap_or(true);
1780            if tail_blank || self.live_spinner_active {
1781                return;
1782            }
1783        }
1784        let lines_of_cells = parse_markdown_to_cells(rendered);
1785        for line_cells in lines_of_cells {
1786            let chunks = wrap_cells_to_width(&line_cells, w);
1787            for chunk in chunks {
1788                let mut row = Vec::new();
1789                let pad = CellStyle::default();
1790                push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
1791                row.extend(chunk);
1792                self.push_body_row(row);
1793            }
1794        }
1795    }
1796
1797    fn flush_frame(&mut self) {
1798        let bytes = self.screen.render_diff();
1799        let _ = self.out.write_all(&bytes);
1800    }
1801
1802    fn build_prefixed_wrapped_rows(
1803        &self,
1804        prefix: &str,
1805        prefix_style: &CellStyle,
1806        continuation_prefix: &str,
1807        continuation_style: &CellStyle,
1808        content: Vec<Cell>,
1809        content_width: usize,
1810    ) -> Vec<Vec<Cell>> {
1811        let prefix_w = crate::width::display_width(prefix);
1812        let cont_prefix_w = crate::width::display_width(continuation_prefix);
1813        let first_budget = content_width.saturating_sub(prefix_w).max(1);
1814        let cont_budget = content_width.saturating_sub(cont_prefix_w).max(1);
1815
1816        let first_chunks = wrap_cells_to_width(&content, first_budget);
1817        let mut rows = Vec::with_capacity(first_chunks.len().max(1));
1818        for (idx, chunk) in first_chunks.into_iter().enumerate() {
1819            let mut row = Vec::new();
1820            let pad = CellStyle::default();
1821            push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
1822            if idx == 0 {
1823                push_str_cells(&mut row, prefix, prefix_style);
1824            } else {
1825                push_str_cells(&mut row, continuation_prefix, continuation_style);
1826            }
1827            row.extend(chunk);
1828            rows.push(row);
1829        }
1830        if rows.len() <= 1 {
1831            return rows;
1832        }
1833
1834        let mut normalized = Vec::new();
1835        let mut first = true;
1836        for row in rows {
1837            if first {
1838                normalized.push(row);
1839                first = false;
1840                continue;
1841            }
1842
1843            let mut content_only = row;
1844            let strip = PAD_COL + cont_prefix_w;
1845            content_only.drain(..strip.min(content_only.len()));
1846
1847            let mut wrapped = wrap_cells_to_width(&content_only, cont_budget);
1848            for chunk in wrapped.drain(..) {
1849                let mut next = Vec::new();
1850                let pad = CellStyle::default();
1851                push_str_cells(&mut next, &" ".repeat(PAD_COL), &pad);
1852                push_str_cells(&mut next, continuation_prefix, continuation_style);
1853                next.extend(chunk);
1854                normalized.push(next);
1855            }
1856        }
1857        normalized
1858    }
1859
1860    fn build_wrapped_text_rows(
1861        &self,
1862        parts: &[(&str, CellStyle)],
1863        content_width: usize,
1864    ) -> Vec<Vec<Cell>> {
1865        let mut content = Vec::new();
1866        for (text, style) in parts {
1867            push_str_cells(&mut content, text, style);
1868        }
1869        let chunks = wrap_cells_to_width(&content, content_width.max(1));
1870        let mut rows = Vec::with_capacity(chunks.len().max(1));
1871        for chunk in chunks {
1872            let mut row = Vec::new();
1873            let pad = CellStyle::default();
1874            push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
1875            row.extend(chunk);
1876            rows.push(row);
1877        }
1878        rows
1879    }
1880
1881    fn build_welcome_rows(&self, model: &str, working_dir: &str) -> Vec<Vec<Cell>> {
1882        // Mirror AnsiRenderer::render_welcome, but allow narrow terminals
1883        // to reflow path/model/tips instead of truncating or colliding.
1884        let w = self.screen.width() as usize;
1885        let content_w = w.saturating_sub(PAD_COL * 2).max(1);
1886        // Row 1: brand left + version · license right
1887        let left_txt = "◆ AtomCode";
1888        let right_ver = concat!("v", env!("CARGO_PKG_VERSION"));
1889        let right_lic = "MIT";
1890        let left_w = crate::width::display_width(left_txt);
1891        let right_txt = format!("{}  ·  {}", right_ver, right_lic);
1892        let right_w = crate::width::display_width(&right_txt);
1893        let mut rows = Vec::with_capacity(6);
1894        let pad = CellStyle::default();
1895        if content_w > left_w + right_w {
1896            let gap = content_w.saturating_sub(left_w + right_w);
1897            let mut row1 = Vec::new();
1898            push_str_cells(&mut row1, &" ".repeat(PAD_COL), &pad);
1899            push_str_cells(&mut row1, left_txt, &self.style_bold(Role::Brand));
1900            for _ in 0..gap {
1901                row1.push(Cell::blank());
1902            }
1903            push_str_cells(&mut row1, right_ver, &self.style_for(Role::Secondary));
1904            push_str_cells(&mut row1, "  ·  ", &self.style_for(Role::Muted));
1905            push_str_cells(&mut row1, right_lic, &self.style_for(Role::Muted));
1906            rows.push(row1);
1907        } else {
1908            let mut row1 = Vec::new();
1909            push_str_cells(&mut row1, &" ".repeat(PAD_COL), &pad);
1910            push_str_cells(&mut row1, left_txt, &self.style_bold(Role::Brand));
1911            rows.push(row1);
1912
1913            let right_gap = content_w.saturating_sub(right_w);
1914            let mut row1b = Vec::new();
1915            push_str_cells(&mut row1b, &" ".repeat(PAD_COL), &pad);
1916            for _ in 0..right_gap {
1917                row1b.push(Cell::blank());
1918            }
1919            push_str_cells(&mut row1b, right_ver, &self.style_for(Role::Secondary));
1920            push_str_cells(&mut row1b, "  ·  ", &self.style_for(Role::Muted));
1921            push_str_cells(&mut row1b, right_lic, &self.style_for(Role::Muted));
1922            rows.push(row1b);
1923        }
1924
1925        let bullet_style = self.style_for(Role::AccentDim);
1926        let secondary_style = self.style_for(Role::Secondary);
1927        let path_cells = {
1928            let mut cells = Vec::new();
1929            push_str_cells(&mut cells, working_dir, &secondary_style);
1930            cells
1931        };
1932        rows.extend(self.build_prefixed_wrapped_rows(
1933            "∙ ",
1934            &bullet_style,
1935            "  ",
1936            &CellStyle::default(),
1937            path_cells,
1938            content_w,
1939        ));
1940
1941        let model_cells = {
1942            let mut cells = Vec::new();
1943            push_str_cells(&mut cells, model, &secondary_style);
1944            cells
1945        };
1946        rows.extend(self.build_prefixed_wrapped_rows(
1947            "∙ ",
1948            &bullet_style,
1949            "  ",
1950            &CellStyle::default(),
1951            model_cells,
1952            content_w,
1953        ));
1954
1955        // Blank separator.
1956        rows.push(Vec::new());
1957
1958        // Hint rows. The prose around the slash shortcuts is onboarding-
1959        // critical text — first thing a new user reads. Use faint
1960        // (SGR 2) over the terminal's default fg so the hint reads as
1961        // subordinate to primary content without picking a fixed gray
1962        // (DarkGrey would vanish on some iTerm2 light presets, default
1963        // fg unmuted competes with the user's input on dark presets).
1964        // Slash shortcuts stay accent_bold (cyan) for visual emphasis.
1965        // Hint row(s): input prompt + /provider + /codingplan.
1966        //
1967        // Wide enough to fit on one visual row → emit a single combined
1968        // line (user's preferred shape on standard 100+ col terminals).
1969        // Narrower → fall back to three separate rows; the alternative
1970        // is a single line that `build_wrapped_text_rows` would
1971        // hard-break mid-token (`/provider` → `/provi`+`der`), which
1972        // looks worse than three short rows on a small terminal.
1973        let hint_text = self.style_faint(Role::Secondary);
1974        let accent_bold = self.style_bold(Role::Accent);
1975        let idle_prefix = t(Msg::IdleHintPrefix);
1976        let idle_slash = t(Msg::IdleHintSlash);
1977        let idle_suffix = t(Msg::IdleHintSuffix);
1978        let provider_cmd = t(Msg::IdleHintProvider);
1979        let provider_suffix = t(Msg::IdleHintProviderSuffix);
1980        let codingplan_cmd = t(Msg::IdleHintCodingplan);
1981        let codingplan_suffix = t(Msg::IdleHintCodingplanSuffix);
1982        let combined_width: usize = [
1983            idle_prefix.as_ref(),
1984            idle_slash.as_ref(),
1985            idle_suffix.as_ref(),
1986            "   ",
1987            provider_cmd.as_ref(),
1988            "  ",
1989            provider_suffix.as_ref(),
1990            "   ",
1991            codingplan_cmd.as_ref(),
1992            "  ",
1993            codingplan_suffix.as_ref(),
1994        ]
1995        .iter()
1996        .map(|s| unicode_width::UnicodeWidthStr::width(*s))
1997        .sum();
1998        if combined_width <= content_w {
1999            rows.extend(self.build_wrapped_text_rows(
2000                &[
2001                    (&idle_prefix, hint_text.clone()),
2002                    (&idle_slash, accent_bold.clone()),
2003                    (&idle_suffix, hint_text.clone()),
2004                    ("   ", hint_text.clone()),
2005                    (&provider_cmd, accent_bold.clone()),
2006                    ("  ", hint_text.clone()),
2007                    (&provider_suffix, hint_text.clone()),
2008                    ("   ", hint_text.clone()),
2009                    (&codingplan_cmd, accent_bold),
2010                    ("  ", hint_text.clone()),
2011                    (&codingplan_suffix, hint_text),
2012                ],
2013                content_w,
2014            ));
2015        } else {
2016            rows.extend(self.build_wrapped_text_rows(
2017                &[
2018                    (&idle_prefix, hint_text.clone()),
2019                    (&idle_slash, accent_bold.clone()),
2020                    (&idle_suffix, hint_text.clone()),
2021                ],
2022                content_w,
2023            ));
2024            rows.extend(self.build_wrapped_text_rows(
2025                &[
2026                    (&provider_cmd, accent_bold.clone()),
2027                    ("  ", hint_text.clone()),
2028                    (&provider_suffix, hint_text.clone()),
2029                ],
2030                content_w,
2031            ));
2032            rows.extend(self.build_wrapped_text_rows(
2033                &[
2034                    (&codingplan_cmd, accent_bold),
2035                    ("  ", hint_text.clone()),
2036                    (&codingplan_suffix, hint_text),
2037                ],
2038                content_w,
2039            ));
2040        }
2041
2042        // Trailing blank so subsequent async events (MCP "已连接",
2043        // upgrade hints, etc.) don't butt up against the hint row.
2044        // Mirrors alt_screen's push_welcome trailing blank.
2045        rows.push(Vec::new());
2046
2047        rows
2048    }
2049
2050    fn push_welcome(&mut self, model: &str, working_dir: &str) {
2051        let rows = self.build_welcome_rows(model, working_dir);
2052        self.welcome_banner = Some((model.to_string(), working_dir.to_string()));
2053        self.welcome_line_count = rows.len();
2054        for row in rows {
2055            self.push_body_row(row);
2056        }
2057    }
2058
2059    fn reflow_welcome_prefix(&mut self) {
2060        let Some((ref model, ref working_dir)) = self.welcome_banner else {
2061            return;
2062        };
2063        if self.welcome_line_count == 0 || self.body_lines.len() < self.welcome_line_count {
2064            return;
2065        }
2066        let rows = self.build_welcome_rows(model, working_dir);
2067        let new_len = rows.len();
2068        self.body_lines
2069            .splice(0..self.welcome_line_count, rows.into_iter());
2070        self.welcome_line_count = new_len;
2071    }
2072}
2073
2074impl<W: Write + Send> Renderer for RetainedRenderer<W> {
2075    fn render(&mut self, line: UiLine) {
2076        match line {
2077            // ── footer-only variants ──
2078            UiLine::InputPrompt {
2079                buf,
2080                cursor_byte,
2081                menu,
2082                status,
2083                attachments,
2084            } => {
2085                // Returning to idle input: the spinner row served its
2086                // purpose — clear it from both body history and the
2087                // terminal so the user sees a clean input prompt, not
2088                // a stale `⠋ Pondering…` row above the input box.
2089                self.clear_live_spinner();
2090                self.input_buf = buf;
2091                self.input_cursor_byte = cursor_byte;
2092                self.menu = menu;
2093                self.status = status;
2094                self.input_attachments = attachments;
2095            }
2096            UiLine::StreamingBox {
2097                buf,
2098                cursor_byte,
2099                frame,
2100                label,
2101                status,
2102                menu,
2103                attachments,
2104            } => {
2105                // Input box / status / menu still belong in the footer.
2106                self.input_buf = buf;
2107                self.input_cursor_byte = cursor_byte;
2108                self.menu = menu;
2109                self.status = status;
2110                self.input_attachments = attachments;
2111                // Spinner (frame + label) goes into body as a live
2112                // paragraph header. Each tick replaces the previous
2113                // wrapped rows via render_inflight_tool so long
2114                // commands wrap properly (same as committed rows).
2115                //
2116                // When a tool call is in flight, the live rows
2117                // carry the tool-call shape (`<frame> Bash(cmd)`)
2118                // with the animation driving the icon frame. The
2119                // spinner label here was built by `format_spinner_label`
2120                // and carries the ` · 12s · N queued` metadata; pluck
2121                // that suffix off and forward it to render_inflight_tool
2122                // so the user gets a time anchor on long bashes.
2123                if let Some((_id, name, detail)) = self.inflight_tool.clone() {
2124                    let meta = spinner_meta_suffix(&label);
2125                    self.render_inflight_tool(frame, &name, &detail, meta);
2126                } else {
2127                    let cells = self.build_spinner_body_row(frame, &label);
2128                    self.push_or_update_live_spinner(cells);
2129                }
2130            }
2131            UiLine::Spinner { frame, label } => {
2132                if let Some((_id, name, detail)) = self.inflight_tool.clone() {
2133                    let meta = spinner_meta_suffix(&label);
2134                    self.render_inflight_tool(frame, &name, &detail, meta);
2135                } else {
2136                    let cells = self.build_spinner_body_row(frame, &label);
2137                    self.push_or_update_live_spinner(cells);
2138                }
2139            }
2140            UiLine::ClearTransient | UiLine::InputCommit => {
2141                // No-op in retained mode.
2142                return;
2143            }
2144
2145            // ── body: welcome / turn events ──
2146            UiLine::Welcome { model, working_dir } => {
2147                let model_scrubbed = scrub_controls(&model);
2148                let wd_scrubbed = scrub_controls(&working_dir);
2149                self.push_welcome(&model_scrubbed, &wd_scrubbed);
2150            }
2151            UiLine::User(text) => {
2152                let safe = scrub_controls(&text);
2153                let accent = self.style_bold(Role::Accent);
2154                let plain = CellStyle::default();
2155                self.push_body_prefixed(self.caps.prompt_chevron(), &accent, &safe, &plain);
2156                // Blank spacer row.
2157                self.push_body_row(Vec::new());
2158                // New user turn — reset markdown parser so code-block
2159                // / table state from previous turn doesn't bleed.
2160                self.md_state.reset();
2161            }
2162            UiLine::TurnSeparator { label } => {
2163                let w = (self.screen.width() as usize).saturating_sub(PAD_COL * 2);
2164                let safe = scrub_controls(&label);
2165                let lw = crate::width::display_width(&safe);
2166                let padded = 1 + lw + 1;
2167                let remaining = w.saturating_sub(padded);
2168                let left = remaining / 2;
2169                let right = remaining - left;
2170                let mut row = Vec::new();
2171                let pad = CellStyle::default();
2172                push_str_cells(&mut row, &" ".repeat(PAD_COL), &pad);
2173                // Muted gray (SGR 90 / DarkGrey) for the per-turn rule
2174                // and summary text. The input box border below uses full
2175                // cyan; making this rule cyan too produced three
2176                // identical bright-cyan rules in one viewport (see
2177                // screenshot regression). Gray is the historically
2178                // expected look — quiet historical separator that
2179                // doesn't compete with the live input chrome.
2180                let rule = self.style_for(Role::Muted);
2181                for _ in 0..left {
2182                    row.push(Cell {
2183                        ch: '─',
2184                        style: rule.clone(),
2185                        width: 1,
2186                    });
2187                }
2188                push_str_cells(&mut row, " ", &pad);
2189                push_str_cells(&mut row, &safe, &self.style_for(Role::Muted));
2190                push_str_cells(&mut row, " ", &pad);
2191                for _ in 0..right {
2192                    row.push(Cell {
2193                        ch: '─',
2194                        style: rule.clone(),
2195                        width: 1,
2196                    });
2197                }
2198                self.push_body_row(Vec::new());
2199                self.push_body_row(row);
2200                self.push_body_row(Vec::new());
2201            }
2202
2203            // ── body: streaming assistant ──
2204            UiLine::AssistantText(text) => {
2205                self.assistant_line_buf.push_str(&scrub_controls(&text));
2206                self.flush_assistant_lines();
2207            }
2208            UiLine::ReasoningText(text) => {
2209                // Display reasoning in gray/dimmed style with word wrapping
2210                let text = scrub_controls(&text);
2211                // Use ANSI dim/gray escape codes
2212                let dimmed = format!("\x1b[2m{}\x1b[0m", text);
2213                self.push_body_text(&dimmed, &CellStyle::default());
2214            }
2215            UiLine::AssistantLineBreak => {
2216                self.flush_assistant_remainder();
2217            }
2218            UiLine::TurnComplete => {
2219                self.flush_assistant_remainder();
2220                // Defense in depth: a turn that ended without a
2221                // matching ToolCallCommit (interrupted, forced stop,
2222                // protocol bug) would otherwise leave inflight_tool
2223                // set and the next user turn's spinner would mistake
2224                // the stale tool detail for the in-flight payload.
2225                // Use push_body_prefixed for proper line wrapping.
2226                self.commit_inflight_tool();
2227            }
2228            UiLine::TurnCancelled => {
2229                self.flush_assistant_remainder();
2230                self.commit_inflight_tool();
2231                // (cancelled) is a state-change marker — must remain
2232                // visible. Default fg, not Muted.
2233                let style = self.style_for(Role::Secondary);
2234                let label = t(Msg::Cancelled);
2235                self.push_body_text(&label, &style);
2236            }
2237
2238            // ── body: tools & diffs ──
2239            UiLine::ToolCallInFlight { id, name, detail } => {
2240                self.flush_assistant_remainder();
2241                // Parallel tool calls are rare but not impossible. If
2242                // one is already animating, freeze it before starting
2243                // a new one — single-at-a-time animation is a deliberate
2244                // simplification (see field doc).
2245                if self.inflight_tool.is_some() {
2246                    // Commit the previous tool (freezes it as ▸ in
2247                    // the body transcript) before starting a new one.
2248                    self.commit_inflight_tool();
2249                }
2250                // Use a plausible "still" frame for the initial paint;
2251                // the next Spinner / StreamingBox tick (within ~80ms)
2252                // overwrites with the real frame, picking up the
2253                // animation seamlessly.
2254                let initial = if self.caps.unicode_symbols {
2255                    "\u{2819}"
2256                } else {
2257                    "*"
2258                };
2259                self.inflight_tool = Some((id, name.clone(), detail.clone()));
2260                // Initial paint — no spinner tick has fired yet so no
2261                // elapsed-time suffix to forward. The next Spinner /
2262                // StreamingBox tick (~80ms later) supplies the meta.
2263                self.render_inflight_tool(initial, &name, &detail, "");
2264            }
2265            UiLine::ToolCallCommit { call_id } => {
2266                // Only commit if the inflight_tool matches the expected call_id,
2267                // or if no call_id was provided (legacy behavior).
2268                let should_commit = match (call_id, &self.inflight_tool) {
2269                    (Some(expected_id), Some((actual_id, _, _))) => &expected_id == actual_id,
2270                    (None, Some(_)) => true,
2271                    _ => false,
2272                };
2273                if should_commit {
2274                    self.commit_inflight_tool();
2275                }
2276            }
2277            UiLine::ToolGroupRender {
2278                batch_id,
2279                header,
2280                children,
2281            } => {
2282                self.flush_assistant_remainder();
2283                // Push header + N child rows as single-line rows so
2284                // body_lines indices map 1:1 with terminal positions.
2285                // push_body_row clears any prior live_group, including
2286                // ours mid-loop, so we set live_group AFTER the loop.
2287                //
2288                // Style:
2289                // Style:
2290                // - header: bold, terminal default fg. SGR Color::White
2291                //   was tried for "亮白" emphasis but on iTerm2's light
2292                //   preset the terminal maps it to the same shade as
2293                //   the background — the entire `● Running 3 read_file
2294                //   calls in parallel` line went invisible (user
2295                //   screenshot: child rows visible, header line blank).
2296                //   Same root cause as the inline-code bright-white→
2297                //   invisible bug fixed in commit 25e9e41 for markdown
2298                //   code, but unfixed for batch headers until now.
2299                //   Switching to Role::Secondary (fg=None = `\x1b[39m`
2300                //   terminal default) means the row picks up whatever
2301                //   foreground the user's theme set for regular text
2302                //   — black on light themes, white-ish on dark themes
2303                //   — and bold supplies the emphasis on both.
2304                // - children: muted (high-frequency rows, not anchors)
2305                // - summary: same fix as header (see Summary arm below)
2306                let header_style = self.style_bold(Role::Secondary);
2307                let muted = self.style_for(Role::Muted);
2308                let screen_w = self.screen.width();
2309                let header_row = build_one_row(&header, &header_style, screen_w);
2310                self.push_body_row(header_row);
2311                let header_idx = self.body_lines.len() - 1;
2312
2313                let mut child_indices: std::collections::HashMap<String, usize> =
2314                    std::collections::HashMap::new();
2315                for c in &children {
2316                    let row = build_one_row(&c.text, &muted, screen_w);
2317                    self.push_body_row(row);
2318                    child_indices.insert(c.call_id.clone(), self.body_lines.len() - 1);
2319                }
2320                self.live_group = Some(LiveGroup {
2321                    batch_id,
2322                    header_idx,
2323                    child_indices,
2324                });
2325            }
2326            UiLine::ToolGroupChildUpdate {
2327                batch_id,
2328                call_id,
2329                new_text,
2330            } => {
2331                // CRITICAL: do NOT call flush_assistant_remainder here.
2332                // It would push pending assistant text via push_body_row,
2333                // which clears live_group (per the freeze invariant), and
2334                // the lookup below would silent-return → child never gets
2335                // its `→ N lines` data. ToolGroupChildUpdate only does a
2336                // CUP rewrite on an EXISTING body row; it does not create
2337                // new rows, so there is nothing to flush against. Pending
2338                // streaming text stays in assistant_line_buf for whoever
2339                // legitimately pushes a new row next.
2340                //
2341                // Bug seen in 5-8 atomgr session: batch 2 had two bash
2342                // calls; assistant_line_buf had leftover streamed text
2343                // ("工具响应持续被截断"-style prose from prior turn). The
2344                // first ToolCallResult flushed that text → push_body_row
2345                // → live_group=None → both children's updates silent
2346                // no-opped. Visual: children stuck without `→ N lines`,
2347                // user (and model) thought tool results were truncated.
2348
2349                // Resolve via the active live-group. Three guards:
2350                // 1. live_group still active (no foreign push happened)
2351                // 2. batch_id matches (defensive — shouldn't ever
2352                //    mismatch, but guard against event-order glitches)
2353                // 3. call_id is in the child map
2354                // Any miss = silent no-op; the model still got the full
2355                // ToolResult through the conversation, only the visual
2356                // ✓ light-up is dropped.
2357                let group = match self.live_group.as_ref() {
2358                    Some(g) if g.batch_id == batch_id => g.clone(),
2359                    _ => return,
2360                };
2361                let row_idx = match group.child_indices.get(&call_id) {
2362                    Some(&i) => i,
2363                    None => return,
2364                };
2365
2366                let muted = self.style_for(Role::Muted);
2367                let new_row = build_one_row(&new_text, &muted, self.screen.width());
2368
2369                // Update in-memory.
2370                if let Some(slot) = self.body_lines.get_mut(row_idx) {
2371                    *slot = new_row.clone();
2372                }
2373
2374                // Compute terminal row position. body_bottom_row is the
2375                // bottom of the visible body strip; the live-group
2376                // children sit just above it. body_lines maps to
2377                // terminal rows from `body_bottom - (len-1)` upwards.
2378                self.ensure_scroll_region();
2379                let bottom = self.body_bottom_row();
2380                if bottom == 0 {
2381                    return;
2382                }
2383                let n = self.body_lines.len();
2384                let offset_from_bottom = (n - 1).saturating_sub(row_idx);
2385                if (bottom as usize) <= offset_from_bottom {
2386                    // Row has scrolled past the visible body strip
2387                    // into native scrollback — can't rewrite.
2388                    return;
2389                }
2390                let target_row = (bottom as usize) - offset_from_bottom;
2391                let seq = format!("\x1b[{};1H\x1b[K", target_row);
2392                let _ = self.out.write_all(seq.as_bytes());
2393                let bytes = serialize_row(&new_row);
2394                let _ = self.out.write_all(&bytes);
2395            }
2396            UiLine::ToolGroupSummary { text } => {
2397                self.flush_assistant_remainder();
2398                // Terminal default fg, NOT bold — distinguishable from
2399                // the muted children (which apply faint), but quieter
2400                // than the bold header. Three-tier emphasis: bold
2401                // header → plain summary → faint children. Was
2402                // bold-bright-white before; same iTerm2-light invisible
2403                // bug as the header (see header_style comment above for
2404                // the full rationale and screenshot).
2405                let style = self.style_for(Role::Secondary);
2406                let row = build_one_row(&text, &style, self.screen.width());
2407                self.push_body_row(row);
2408            }
2409            UiLine::ToolCall { name, detail } => {
2410                self.flush_assistant_remainder();
2411                let muted = self.style_for(Role::Muted);
2412                let tool_name_style = self.style_bold(Role::ToolName);
2413                let safe_name = scrub_controls(&name);
2414                let safe_detail = scrub_controls(&detail);
2415                let body_str = if safe_detail.is_empty() {
2416                    safe_name.clone()
2417                } else {
2418                    format!("{}({})", safe_name, safe_detail)
2419                };
2420                // Safety cap: prevent degenerate bodies (e.g. multi-KB bash
2421                // commands) from producing hundreds of terminal lines.
2422                let body_str = truncate_body_str(&body_str, 500);
2423                // only NAME is bolded; retained uses a uniform style
2424                // for the tool-call line (acceptable in Phase 4,
2425                // tightens in Phase 5/6).
2426                let _ = muted;
2427                // ● (U+25CF, Geometric Shapes block) replaces the
2428                // earlier ▸ (U+25B8). ▸ ships in Cascadia Code / SF
2429                // Mono but is missing from Consolas / NSimSun /
2430                // legacy conhost defaults — Windows users saw the
2431                // tool-call row prefixed by `□` tofu (screenshot
2432                // bug report). ● has near-universal monospace
2433                // coverage, same reason state.tick_spinner picked
2434                // half-moons over Braille (state.rs:528-544). Bonus:
2435                // unifies the visual anchor with the parallel-batch
2436                // header (also ●), matching Claude Code's single-glyph
2437                // model for tool-call entries.
2438                self.push_body_prefixed(
2439                    "● ",
2440                    &self.style_for(Role::Muted),
2441                    &body_str,
2442                    &tool_name_style,
2443                );
2444            }
2445            UiLine::ToolResult { success, summary } => {
2446                self.flush_assistant_remainder();
2447                // Defense in depth: if the event loop didn't send
2448                // ToolCallCommit before this Result (error path /
2449                // merge collapse), freeze the in-flight row now so
2450                // the upcoming `⎿ ...` body push doesn't itself become
2451                // the next animation target on the next spinner tick.
2452                // Use commit_inflight_tool for proper line wrapping
2453                // (see method doc).
2454                self.commit_inflight_tool();
2455                // Style policy (header line of a failure body):
2456                //   * `Error: ...` — bold red. Tool-dispatch failures
2457                //     (bad JSON args, unknown tool name, etc.) are real
2458                //     bugs that need attention.
2459                //   * `[elapsed: ...exit: N...]` — bold yellow. Bash
2460                //     exit-code failures are frequently recovered by
2461                //     the agent on the next turn (e.g. `git push`
2462                //     rejected → next turn `git pull --rebase &&
2463                //     git push`). Painting them red made transient
2464                //     hiccups visually identical to real failures.
2465                // Continuation lines (and success bodies) — default fg.
2466                //
2467                // Why split header vs continuation: when an edit_file
2468                // error includes quoted code (e.g. "Partial match at
2469                // lines 760-779" + actual file lines), painting the
2470                // whole block red made it visually identical to a Diff
2471                // block. Header keeps the urgency signal; body reverts
2472                // to default fg so quoted code reads like normal output.
2473                // Three style buckets:
2474                //   * summary_style — line 0 of a success body, e.g.
2475                //     `⎿ [elapsed: 0.0s, exit: 0] (4 lines)`. Muted gray
2476                //     because it's per-call metadata, visually
2477                //     subordinate to assistant text and tool-call
2478                //     headers above.
2479                //   * continuation_style — line ≥ 1 of any body and any
2480                //     line of multi-line success output. Default fg so
2481                //     quoted code (edit_file errors) and stderr (bash
2482                //     failure body) stay readable.
2483                //   * error_header / warn_header — line 0 of a failure
2484                //     body, see B-discriminated logic below.
2485                let summary_style = self.style_for(Role::Muted);
2486                let continuation_style = self.style_for(Role::Secondary);
2487                let error_header = self.style_bold(Role::Error);
2488                let warn_header = self.style_bold(Role::Warning);
2489                let safe = scrub_controls(&summary);
2490                // Discriminate before `safe` is moved into body_str.
2491                // Bash exit-code failures always start with the
2492                // `format_exit_marker` prefix from bash.rs:578.
2493                let is_exit_code_failure = !success && safe.starts_with("[elapsed:");
2494                let body_str = if success {
2495                    safe
2496                } else {
2497                    format!("✗ {}", safe)
2498                };
2499                // Align the `└` glyph with the `B` of the `Bash` (or
2500                // any tool name) in the row above: the tool-call row is
2501                // `● Bash(...)` with `●` at col 0 and the tool name at
2502                // col 2, so the result prefix `"  └ "` (2 spaces +
2503                // glyph + space) lands `└` at col 2 — visually anchored
2504                // under the tool name. Width reserves PAD_COL for
2505                // the right gutter + 4 for the prefix `"  └ "`. Was
2506                // `⎿` (U+23BF, dental symbols block) but Cascadia Code
2507                // and other Windows monospace defaults render it as a
2508                // backslash-shaped fallback glyph (user screenshot
2509                // showed `\` instead of corner). `└` (U+2514, Box
2510                // Drawing block) ships in every monospace font.
2511                let row_w = (self.screen.width() as usize).saturating_sub(PAD_COL + 4);
2512                // Muted (dim gray) for the result prefix — visually subordinate
2513                // to the tool-call header above (● ToolName).
2514                let prefix_style = self.style_for(Role::Muted);
2515                for (line_idx, phys) in body_str.split('\n').enumerate() {
2516                    // First physical line of a failure body is the
2517                    // header. Wrapped continuation chunks of that same
2518                    // physical line stay header-styled (a long error
2519                    // message like "✗ no rows matched: ...stuff..."
2520                    // shouldn't fade to default mid-sentence).
2521                    let line_style = if line_idx == 0 {
2522                        if !success {
2523                            if is_exit_code_failure {
2524                                &warn_header
2525                            } else {
2526                                &error_header
2527                            }
2528                        } else {
2529                            &summary_style
2530                        }
2531                    } else {
2532                        &continuation_style
2533                    };
2534                    for chunk in crate::width::wrap_line_to_width(phys, row_w.max(1)) {
2535                        let mut row = Vec::new();
2536                        push_str_cells(&mut row, "  └ ", &prefix_style);
2537                        push_str_cells(&mut row, &chunk, line_style);
2538                        self.push_body_row(row);
2539                    }
2540                }
2541                // No trailing spacer — tool chains stay compact. A
2542                // following assistant paragraph provides its own
2543                // breathing room via a single blank line at most
2544                // (see `push_markdown_body`'s blank-run collapse).
2545            }
2546            UiLine::DiffLine { added, text } => {
2547                let style = self.style_for(if added {
2548                    Role::DiffAdd
2549                } else {
2550                    Role::DiffRemove
2551                });
2552                let sign = if added { '+' } else { '-' };
2553                let body = format!("       {} {}", sign, scrub_controls(&text));
2554                self.push_body_text(&body, &style);
2555            }
2556            UiLine::DiffBlock(entries) => {
2557                for entry in &entries {
2558                    let style = self.style_for(if entry.added {
2559                        Role::DiffAdd
2560                    } else {
2561                        Role::DiffRemove
2562                    });
2563                    let sign = if entry.added { '+' } else { '-' };
2564                    let body = format!("       {} {}", sign, scrub_controls(&entry.text));
2565                    self.push_body_text(&body, &style);
2566                }
2567            }
2568
2569            // ── body: approval / errors / command output ──
2570            UiLine::ApprovalPrompt { tool, detail } => {
2571                let warn = self.style_bold(Role::Warning);
2572                let plain = CellStyle::default();
2573                let chip = |c: Color| CellStyle {
2574                    fg: Some(c),
2575                    bold: true,
2576                    reverse: true,
2577                    faint: false,
2578                };
2579                let chip_y = chip(Color::Green);
2580                let chip_a = chip(Color::Cyan);
2581                let chip_n = chip(Color::Red);
2582
2583                // Build tool label so user knows which specific action
2584                // they're approving (issue #439: parallel batch approvals
2585                // showed identical prompts with no way to tell which file).
2586                let tool_label = if detail.is_empty() {
2587                    format!("{}: ", tool)
2588                } else {
2589                    format!("{}({}): ", tool, detail)
2590                };
2591
2592                let waiting = t(Msg::ApprovalWaitingLabel);
2593                let prefix_w = crate::width::display_width(&waiting);
2594                let cont_pad: String = " ".repeat(prefix_w);
2595
2596                let allow = t(Msg::ApprovalAllow);
2597                let always = t(Msg::ApprovalAlways);
2598                let deny = t(Msg::ApprovalDeny);
2599
2600                // Build the Y/A/N chips cells once — reused whether
2601                // we place them inline or on a separate line.
2602                let mut chips_cells: Vec<Cell> = Vec::new();
2603                push_str_cells(&mut chips_cells, " Y ", &chip_y);
2604                push_str_cells(&mut chips_cells, &allow, &plain);
2605                push_str_cells(&mut chips_cells, " A ", &chip_a);
2606                push_str_cells(&mut chips_cells, &always, &plain);
2607                push_str_cells(&mut chips_cells, " N ", &chip_n);
2608                push_str_cells(&mut chips_cells, &deny, &plain);
2609                let chips_width: usize = chips_cells.iter().map(|c| c.width as usize).sum();
2610
2611                // Build the label rows, then decide: if the last label
2612                // row + chips fit within the screen width, append chips
2613                // inline (issue #454). Otherwise, emit chips on a
2614                // separate line so they remain visible.
2615                let safe_tool_label = crate::sanitize::scrub_controls(&tool_label);
2616                let mut prefixed_rows = self.build_prefixed_rows(&waiting, &warn, &safe_tool_label, &warn);
2617                let screen_w = self.screen.width() as usize;
2618                let last_row_w: usize = prefixed_rows
2619                    .last()
2620                    .map(|r| r.iter().map(|c| c.width as usize).sum())
2621                    .unwrap_or(0);
2622
2623                if last_row_w + chips_width <= screen_w {
2624                    // Everything fits on one line — append chips directly
2625                    // after the label.  issue #454: users reported that
2626                    // splitting into two lines was unnecessary when the
2627                    // terminal is wide enough.
2628                    if let Some(last_row) = prefixed_rows.last_mut() {
2629                        last_row.extend(chips_cells);
2630                    }
2631                    for row in prefixed_rows {
2632                        self.push_body_row(row);
2633                    }
2634                } else {
2635                    // Label too long — keep chips on a separate line so
2636                    // they remain visible even when the label wraps.
2637                    for row in prefixed_rows {
2638                        self.push_body_row(row);
2639                    }
2640                    let mut chips_row = Vec::new();
2641                    push_str_cells(&mut chips_row, &cont_pad, &plain);
2642                    chips_row.extend(chips_cells);
2643                    self.push_body_row(chips_row);
2644                }
2645            }
2646            UiLine::Error(msg) => {
2647                let err_style = self.style_for(Role::Error);
2648                let safe = scrub_controls(&msg);
2649                let body = t(Msg::ErrorPrefix { msg: &safe });
2650                self.push_body_text(&body, &err_style);
2651            }
2652            UiLine::Warning(msg) => {
2653                // Yellow advisory — distinct from Error (red) so users
2654                // can tell "noticed something" from "turn died". Renders
2655                // with a `!` glyph + bold yellow body. Always-visible:
2656                // we deliberately don't dim it because the whole point
2657                // is to put a truncating-proxy or similar provider
2658                // pathology in front of the user immediately.
2659                let warn_style = CellStyle {
2660                    fg: Some(crossterm::style::Color::Yellow),
2661                    bold: true,
2662                    ..CellStyle::default()
2663                };
2664                let body = format!("! {}", scrub_controls(&msg));
2665                self.push_body_text(&body, &warn_style);
2666            }
2667            UiLine::CommandOutput(text) => {
2668                // CommandOutput is trusted internal text — let SGR
2669                // through the sanitizer so colour / bold / faint
2670                // attributes survive (e.g. the `/codingplan` red
2671                // locked-model row). `push_body_text_sgr` parses
2672                // those escapes into `CellStyle` mutations so the
2673                // cell pipeline renders the same colours alt_screen
2674                // and plain do.
2675                let safe = crate::sanitize::scrub_controls_keep_sgr(&text);
2676                self.push_body_text_sgr(&safe);
2677            }
2678            UiLine::ImageAttachment(n) => {
2679                // `└` at col 2, under the `[` of `[Image #N]` in the
2680                // user-message echo above. push_body_text auto-prefixes
2681                // PAD_COL (2 spaces), so emitting "└ [Image #N]" lands
2682                // the glyph at col 2. Muted style — visually
2683                // subordinate to the user message it's anchoring.
2684                //
2685                // Tight grouping: `UiLine::User` already wrote a trailing
2686                // blank spacer to the terminal (LF + EL at body_bottom)
2687                // and pushed an empty row to body_lines. To make the
2688                // attachment sit flush under the user message we have to
2689                // physically REPLACE that visible blank row, not just
2690                // pop it from memory — popping body_lines leaves the LF
2691                // already in scrollback and the gap on screen.
2692                //
2693                // Mirror the `clear_live_spinner` pattern (see line
2694                // ~1167): pop body_lines, EL-erase the row at
2695                // body_bottom, then arm `skip_body_scroll_count` so the
2696                // next push_body_row overwrites in-place (no LF) instead
2697                // of scrolling. After the attachment row, push a fresh
2698                // trailing blank so the next turn's content still has
2699                // paragraph separation.
2700                if self.body_lines.last().map_or(false, |r| r.is_empty()) {
2701                    self.body_lines.pop();
2702                    self.ensure_scroll_region();
2703                    let bottom = self.body_bottom_row();
2704                    if bottom > 0 {
2705                        let seq = format!("\x1b[{};1H\x1b[K", bottom);
2706                        let _ = self.out.write_all(seq.as_bytes());
2707                    }
2708                    self.skip_body_scroll_count = self.skip_body_scroll_count.saturating_add(1);
2709                }
2710                let body = format!("└ [Image #{}]", n);
2711                self.push_body_text(&body, &self.style_for(Role::Muted));
2712                self.push_body_row(Vec::new());
2713            }
2714            UiLine::VisionPreprocessSuccess { msg, model } => {
2715                // `{msg}  ` in default text style; `{model}` in Muted
2716                // (gray) so the model identity reads as metadata, not
2717                // as part of the success sentence. push_body_prefixed
2718                // handles the two styles in a single visual line and
2719                // continues onto wrapped rows with the prefix's display
2720                // width as continuation pad.
2721                //
2722                // Trailing blank: without it the next event's row (e.g.
2723                // `● Pondering…` spinner or assistant text) butts right
2724                // up against the success notice — user reported it felt
2725                // too cramped. The blank lets the success line breathe
2726                // as its own paragraph.
2727                let default_style = CellStyle::default();
2728                let muted_style = self.style_for(Role::Muted);
2729                let prefix = format!("{msg}  ");
2730                self.push_body_prefixed(&prefix, &default_style, &model, &muted_style);
2731                self.push_body_row(Vec::new());
2732            }
2733        }
2734        // Phase 5: widget state updated → mark frame dirty. No
2735        // paint, no emit. The event loop's 5ms tick (via
2736        // flush_deferred) will coalesce any further state
2737        // changes that arrive in the same window into a single
2738        // paint+emit pass.
2739        self.dirty = true;
2740    }
2741
2742    fn flush(&mut self) {
2743        let _ = self.out.flush();
2744    }
2745
2746    fn pop_approval_prompt(&mut self) {
2747        // The approval prompt spans one or more body rows:
2748        //   - When label + chips fit on one line: a single row
2749        //     starting with '▶' containing both the label and
2750        //     the Y/A/N chips.
2751        //   - When the label is long: 1+ label rows (first starts
2752        //     with '▶', continuation rows start with spaces) plus
2753        //     1 chips row (also starting with spaces).
2754        // We need to pop all of them. Strategy: walk backwards
2755        // from the tail, popping every row until we find the ▶
2756        // header row (which we also pop). Other symbol rows hold
2757        // '●' (tool call) or '❯' (user turn) at col 0 — distinct
2758        // glyphs — so the first ▶ we encounter must be ours.
2759        // Safe because the agent doesn't append further body rows
2760        // between `ApprovalNeeded` and the user's Y/A/N reply.
2761        let mut popped_count: u16 = 0;
2762        loop {
2763            let action = match self.body_lines.last() {
2764                None => break,
2765                Some(last) => last.get(0).map(|c| c.ch),
2766            };
2767            match action {
2768                // ▶ header: pop it and stop (we've found the start).
2769                Some('▶') => {
2770                    self.body_lines.pop();
2771                    popped_count = popped_count.saturating_add(1);
2772                    break;
2773                }
2774                // Space-padded continuation / chips row: pop and keep going.
2775                Some(' ') => {
2776                    self.body_lines.pop();
2777                    popped_count = popped_count.saturating_add(1);
2778                }
2779                // Any other glyph (● tool-call, ❯ user turn, etc.):
2780                // not part of the approval block — stop without popping.
2781                _ => break,
2782            }
2783        }
2784        if popped_count == 0 {
2785            return;
2786        }
2787        // Physically wipe the popped rows for instant visual feedback
2788        // on Y/A/N. The popped rows sat at the BOTTOM of the body
2789        // region — terminal rows `bottom - popped_count + 1 ..= bottom`
2790        // (1-indexed). Erase them row-by-row with `\x1b[K` (EL).
2791        //
2792        // Why per-row EL and not `\x1b[J` (ED from cursor): the cursor
2793        // sits at `bottom` (the LAST popped row), and `\x1b[J` erases
2794        // FROM cursor TO end-of-screen — i.e. that one body row plus
2795        // every footer row below it. That wipes the input box / top
2796        // rule / status bar from the terminal. The cell-diff cache
2797        // (`self.screen.prev_cells`) still holds the prior footer
2798        // content, so the next `paint_footer` → `render_diff` produces
2799        // an empty patch (cells == prev_cells, no diff) and the
2800        // footer never gets redrawn — user sees "input box vanished
2801        // after approving a tool". EL is row-local, never touches the
2802        // footer area, and leaves prev_cells consistent. Then flag the
2803        // next body emit to overwrite in place (no scroll) so
2804        // `⎿ result` lands directly below the `● Tool` row with no
2805        // gap.
2806        let bottom = self.body_bottom_row();
2807        if bottom > 0 {
2808            // Erase the popped body rows (may span multiple terminal
2809            // lines). Use per-row \x1b[K instead of \x1b[J to avoid
2810            // erasing the footer rows below the body strip.
2811            // screen.prev_cells still holds the old footer content,
2812            // so without invalidation the next render_diff() would
2813            // see identical prev/current footer cells and skip the
2814            // repaint — leaving the footer permanently blank.
2815            // invalidate() below ensures the next flush_deferred()
2816            // emits a full repaint of every non-blank cell.
2817            let start_row = bottom.saturating_sub(popped_count - 1).max(1);
2818            let mut seq = String::with_capacity((bottom - start_row + 1) as usize * 8);
2819            use std::fmt::Write as _;
2820            for row in start_row..=bottom {
2821                let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
2822            }
2823            let _ = self.out.write_all(seq.as_bytes());
2824            let _ = self.out.flush();
2825            self.skip_body_scroll_count = popped_count;
2826            self.screen.invalidate();
2827        }
2828        self.dirty = true;
2829    }
2830
2831    fn refresh_welcome_banner(&mut self, model: &str, working_dir: &str) {
2832        // Body rows are written directly to the terminal during
2833        // push_body_row — paint_frame only repaints the footer, so a
2834        // body_lines edit alone doesn't change the bytes already
2835        // on-screen. To make the new model/working_dir visible we:
2836        //   1. update the cached banner + splice body_lines, and
2837        //   2. compute the terminal-row position of each welcome line
2838        //      that's still in the viewport (anything above viewport
2839        //      top has already entered native scrollback and is no
2840        //      longer reachable), then CUP+EL+write each row.
2841        // Cursor is saved/restored via DECSC/DECRC so the surgical
2842        // update doesn't disturb whatever the active footer/spinner
2843        // path expects on its next paint.
2844        if self.welcome_banner.is_none() {
2845            return;
2846        }
2847        let model_scrubbed = scrub_controls(model);
2848        let wd_scrubbed = scrub_controls(working_dir);
2849        self.welcome_banner = Some((model_scrubbed, wd_scrubbed));
2850        self.reflow_welcome_prefix();
2851
2852        let bottom = self.body_bottom_row() as usize;
2853        if bottom == 0 || self.welcome_line_count == 0 {
2854            return;
2855        }
2856        let n = self.body_lines.len();
2857        if n == 0 {
2858            return;
2859        }
2860        // body_lines tail is bottom-anchored: body_lines[i] sits at
2861        // terminal row `bottom - n + i + 1` (1-indexed). Rows whose
2862        // computed position would be <= 0 are already in scrollback.
2863        let mut seq: Vec<u8> = Vec::with_capacity(self.welcome_line_count * 64);
2864        seq.extend_from_slice(b"\x1b7");
2865        let mut wrote = false;
2866        for i in 0..self.welcome_line_count.min(n) {
2867            // Saturating math: avoid underflow when n > bottom and i
2868            // falls in the off-screen prefix. We *want* the result to
2869            // be 0 in that case so the row is skipped below.
2870            let abs = (bottom + i + 1).checked_sub(n).unwrap_or(0);
2871            if abs == 0 {
2872                continue;
2873            }
2874            use std::io::Write as _;
2875            let _ = write!(&mut seq, "\x1b[{};1H\x1b[K", abs);
2876            let bytes = serialize_row(&self.body_lines[i]);
2877            seq.extend_from_slice(&bytes);
2878            wrote = true;
2879        }
2880        seq.extend_from_slice(b"\x1b8");
2881        if wrote {
2882            let _ = self.out.write_all(&seq);
2883            let _ = self.out.flush();
2884            // Cells on those rows now hold the new content — invalidate
2885            // the diff cache so the next frame doesn't decide the row
2886            // is unchanged based on the stale snapshot.
2887            self.screen.invalidate();
2888        }
2889        self.dirty = true;
2890    }
2891
2892    fn shutdown(&mut self) {
2893        // Drain any pending frame before exit so the user sees the
2894        // latest widget state (typically a final prompt or an error
2895        // line) rather than a frame that dirty-flagged too late.
2896        if self.dirty {
2897            self.paint_frame();
2898            let bytes = self.screen.render_diff();
2899            let _ = self.out.write_all(&bytes);
2900            self.dirty = false;
2901        }
2902        self.promote_visible_body_to_scrollback();
2903        // Be defensive: re-enable autowrap, release any DECSTBM, then
2904        // wipe the visible viewport and home the cursor. Without the
2905        // wipe, the welcome banner + input box survive as garbage that
2906        // the shell's new prompt overwrites from the top, leaving the
2907        // bottom half visible.
2908        //
2909        // Per-row CUP+EL instead of `\x1b[2J` for the same reason as
2910        // `reset()` / `on_resize()` — iTerm2 3.5+ ignores ED under
2911        // certain states (see `reset()` rationale). EL is row-local
2912        // and unambiguous. Scrollback is preserved either way.
2913        //
2914        // Also force-restore cursor visibility — if we exit while a
2915        // spinner is hidden (e.g. SIGINT mid-turn), DECTCEM off would
2916        // persist into the parent shell and break their prompt cursor.
2917        let _ = self.out.write_all(b"\x1b[?25h\x1b[?7h\x1b[r");
2918        let h = self.screen.height() as usize;
2919        let mut seq = String::with_capacity(h * 8 + 8);
2920        for row in 1..=h {
2921            use std::fmt::Write;
2922            let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
2923        }
2924        seq.push_str("\x1b[H");
2925        let _ = self.out.write_all(seq.as_bytes());
2926        self.scroll_region_bottom = None;
2927        let _ = self.out.flush();
2928    }
2929
2930    fn reset(&mut self) {
2931        // Terminal-side wipe + full state reset. `body_lines` is
2932        // also dropped so post-reset the screen truly starts clean
2933        // (old transcript stays in the terminal's own scrollback).
2934        //
2935        // Why per-row CUP+EL instead of `\x1b[2J`: ED behaviour is
2936        // inconsistent across terminals — iTerm2 3.5+ was reported
2937        // to leave pre-reset rows visible after `\x1b[2J` (trace
2938        // shows `Ack Reset` fires and body_lines is cleared, but
2939        // the old assistant response + Done separator + user echo
2940        // stayed on screen while the freshly re-rendered welcome
2941        // sat below them, leaving `/session` to produce a torn
2942        // layout). ED also interacts badly with DECSTBM on some
2943        // builds and can promote visible rows to scrollback rather
2944        // than clearing. EL (`\x1b[K`) is row-local with no scroll
2945        // or scrollback semantics, so a CUP+EL per row is
2946        // unambiguous everywhere (same technique as
2947        // `ensure_scroll_region`'s resize path).
2948        //
2949        // Release DECSTBM first so EL isn't constrained by the
2950        // prior scroll region.
2951        let _ = self.out.write_all(b"\x1b[r");
2952        let h = self.screen.height() as usize;
2953        let mut seq = String::with_capacity(h * 8 + 8);
2954        for row in 1..=h {
2955            use std::fmt::Write;
2956            let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
2957        }
2958        seq.push_str("\x1b[H");
2959        let _ = self.out.write_all(seq.as_bytes());
2960        self.screen = Screen::new(self.screen.width(), self.screen.height());
2961        self.body_lines.clear();
2962        self.assistant_line_buf.clear();
2963        self.md_state.reset();
2964        self.last_painted_footer_rows = 0;
2965        self.scroll_region_bottom = None;
2966        let _ = self.out.flush();
2967    }
2968
2969    fn clear_screen(&mut self) {
2970        // Same as reset for retained mode — Screen IS our model, so
2971        // wiping the terminal requires wiping the model too. The
2972        // old AnsiRenderer had a distinction because its cache was
2973        // a leaky abstraction; retained mode closes that hole.
2974        self.reset();
2975    }
2976
2977    fn suspend_for_external(&mut self) {
2978        // Position cursor at the top of where the footer (input box +
2979        // status + menu) used to be, then clear from there to end of
2980        // screen. Without this, cursor stays wherever the last paint
2981        // left it — usually inside the footer area — and the child's
2982        // first stdout write lands ON TOP of footer rows, with later
2983        // writes scrolling existing body content up through the
2984        // overlap. Symptom: `/login`'s OAuth URL printed at row 1
2985        // overlapping prior scrollback ("Press ESC to cancelh lines?"
2986        // — our line glued onto an old conversation row).
2987        //
2988        // Sequence: release DECSTBM, CUP to (body_bottom+1, col 1),
2989        // ED 0 (cursor → end of screen), enable autowrap. After this
2990        // the child writes into a clean rectangle below the body,
2991        // and as it produces more lines the terminal scrolls naturally
2992        // (no scroll region active, autowrap on) — which is exactly
2993        // the cooked-mode shell experience users expect.
2994        let body_bottom = self.body_bottom_row();
2995        let position_row = body_bottom.saturating_add(1);
2996        let seq = format!("\x1b[r\x1b[{};1H\x1b[J\x1b[?7h", position_row);
2997        let _ = self.out.write_all(seq.as_bytes());
2998        self.scroll_region_bottom = None;
2999        // Footer is wiped — record that so the next paint after
3000        // resume doesn't try to diff against stale footer state.
3001        self.last_painted_footer_rows = 0;
3002        let _ = self.out.flush();
3003        // Pop Kitty keyboard enhancement flags if they were pushed at
3004        // startup. Without this, the child (OAuth browser output, a
3005        // shell prompt) runs in a terminal whose key-reporting mode
3006        // was modified by us — and on some terminals the non-standard
3007        // CSI u sequences bleed through as unexpected bytes on stdin
3008        // that the cooked-mode child process then echoes back as
3009        // gibberish. `execute!` is best-effort — terminals that never
3010        // accepted the push silently ignore the pop.
3011        if self.caps.tty {
3012            let _ = execute!(self.out, PopKeyboardEnhancementFlags);
3013        }
3014        if self.caps.bracketed_paste {
3015            let _ = execute!(self.out, DisableBracketedPaste);
3016        }
3017        if self.caps.raw_mode {
3018            let _ = crossterm::terminal::disable_raw_mode();
3019        }
3020    }
3021
3022    fn resume_from_external(&mut self) {
3023        if self.caps.raw_mode {
3024            let _ = crossterm::terminal::enable_raw_mode();
3025        }
3026        if self.caps.bracketed_paste {
3027            let _ = execute!(self.out, EnableBracketedPaste);
3028        }
3029        // Re-push Kitty keyboard enhancement flags (mirror of the pop in
3030        // suspend_for_external, and the initial push in TerminalGuard).
3031        // Without this, post-OAuth the terminal is in a different
3032        // key-reporting mode than we initialised with — autorepeat stops
3033        // coming as `Repeat`, Shift+Enter stops carrying SHIFT, and any
3034        // other logic that depended on CSI u event types silently
3035        // degrades. Same flag set as `TerminalGuard::activate`.
3036        if self.caps.tty {
3037            let _ = execute!(
3038                self.out,
3039                PushKeyboardEnhancementFlags(
3040                    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
3041                        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
3042                )
3043            );
3044        }
3045        // Wipe terminal + invalidate Screen + reset region state so
3046        // the next widget draw is a cold-start full repaint and the
3047        // next body emit resets DECSTBM. Scrollback is preserved.
3048        //
3049        // Per-row CUP+EL instead of `\x1b[2J` for the same reason as
3050        // `reset()` / `on_resize()` — iTerm2 3.5+ ignores ED under
3051        // certain states, which after resume would leave the external
3052        // process's output (shell, OAuth browser messages) overlaid
3053        // with atomcode's re-painted UI.
3054        let h = self.screen.height() as usize;
3055        let mut seq = String::with_capacity(h * 8 + 8);
3056        for row in 1..=h {
3057            use std::fmt::Write;
3058            let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
3059        }
3060        seq.push_str("\x1b[H");
3061        let _ = self.out.write_all(seq.as_bytes());
3062        self.screen.invalidate();
3063        self.scroll_region_bottom = None;
3064        let _ = self.out.flush();
3065        // Re-emit body tail so the view matches `body_lines` again.
3066        // Cold-start the region by cloning the tail first (avoid the
3067        // borrow clash with `emit_body_line_inner(&mut self, ...)`).
3068        let bottom = self.body_bottom_row();
3069        if bottom > 0 {
3070            let tail: Vec<Vec<Cell>> = {
3071                let n = self.body_lines.len().min(bottom as usize);
3072                self.body_lines[self.body_lines.len() - n..]
3073                    .iter()
3074                    .cloned()
3075                    .collect()
3076            };
3077            // Set region up front so each LF scrolls within the body
3078            // strip rather than the whole viewport.
3079            let _ = write!(self.out, "\x1b[1;{}r", bottom);
3080            self.scroll_region_bottom = Some(bottom);
3081            for row in &tail {
3082                self.emit_body_line_inner(row, bottom);
3083            }
3084        }
3085        let _ = self.out.flush();
3086    }
3087
3088    fn flush_deferred(&mut self) {
3089        // The coalesce point. Called every 5ms by the event loop
3090        // tick. If widget state has changed since the last tick,
3091        // paint one full frame, diff it against the previous
3092        // frame, and emit the patch stream. Multiple `render()`
3093        // calls in the same 5ms window are absorbed into a single
3094        // paint here.
3095        if self.dirty {
3096            let t0 = std::time::Instant::now();
3097            let footer_rows = self.current_footer_rows();
3098            // Track footer_rows for diagnostic / resize code paths.
3099            // We DON'T call `screen.invalidate()` here — invalidate
3100            // blanks prev_cells, so the diff sees "blank → blank"
3101            // for every row whose new cells happen to be blank and
3102            // skips the emit. That's wrong whenever the previous
3103            // frame had non-blank content at those rows (e.g. menu
3104            // close: welcome moves down a few rows, leaving the
3105            // top rows of the old welcome position with no erase
3106            // patch against them → ghost text on screen). Letting
3107            // the real prev→current diff run produces the correct
3108            // erase patches naturally.
3109            if footer_rows != self.last_painted_footer_rows {
3110                self.last_painted_footer_rows = footer_rows;
3111            }
3112            let has_status = !self.status.model.is_empty()
3113                || !self.status.cwd.is_empty()
3114                || self.status.hint.is_some();
3115            let middle_rows = footer_rows.saturating_sub(
3116                1 /* spinner */
3117                + 1 /* top rule */
3118                + 1 /* bot rule */
3119                + self.menu.as_ref().map(|m| m.items.len().min(4)).unwrap_or(0)
3120                + if has_status { 1 } else { 0 },
3121            );
3122            let menu_rows = self
3123                .menu
3124                .as_ref()
3125                .map(|m| m.items.len().min(4))
3126                .unwrap_or(0);
3127            let buf_display_w = crate::width::display_width(&self.input_buf);
3128            self.paint_frame();
3129            let bytes = self.screen.render_diff();
3130            let emit_len = bytes.len();
3131            // Chunked emit: Mac Terminal.app has been observed to drop
3132            // bytes mid-sequence when a single write carries ~1KB+ of
3133            // mixed CSI+SGR+UTF-8 — the bot_rule "shortens" bug. Split
3134            // into 512-byte chunks with a flush in between so each
3135            // chunk reaches the terminal as its own parse cycle.
3136            // Trade-off: +N syscalls per frame. Typical frame 50-200B
3137            // fits in one chunk; only wrap / menu / cold-start frames
3138            // (~1-2KB) incur 2-4 chunks. Still single-digit ms.
3139            const CHUNK: usize = 512;
3140            let mut offset = 0;
3141            while offset < bytes.len() {
3142                let end = (offset + CHUNK).min(bytes.len());
3143                let _ = self.out.write_all(&bytes[offset..end]);
3144                if end < bytes.len() {
3145                    // Inter-chunk flush; the final-chunk flush is at
3146                    // the end of this method.
3147                    let _ = self.out.flush();
3148                }
3149                offset = end;
3150            }
3151            self.dirty = false;
3152            // Diagnostic: count how many cells on the bot_rule row
3153            // (screen_h - 2, 0-indexed) actually hold '─'. bot_rule
3154            // sits at a constant absolute row regardless of middle
3155            // row count — if this goes to zero while middle_rows > 1,
3156            // some path (body overwrite, diff skip, draw_row truncate)
3157            // is blanking out the rule.
3158            let screen_h = self.screen.height() as usize;
3159            let bot_rule_row = screen_h.saturating_sub(2);
3160            let bot_rule_dashes = self
3161                .screen
3162                .prev_cells_for_test()
3163                .get(bot_rule_row)
3164                .map(|r| r.iter().filter(|c| c.ch == '─').count())
3165                .unwrap_or(0);
3166            crate::tuix_trace!(
3167                "FOOT",
3168                "paint screen={}x{} rows=footer{}(mid={} menu={}) body={} buf_w={} emit={}B botrule_row={} botrule_dashes={} dur={}µs",
3169                self.screen.width(),
3170                self.screen.height(),
3171                footer_rows,
3172                middle_rows,
3173                menu_rows,
3174                self.body_lines.len(),
3175                buf_display_w,
3176                emit_len,
3177                bot_rule_row,
3178                bot_rule_dashes,
3179                t0.elapsed().as_micros()
3180            );
3181        }
3182        let _ = self.out.flush();
3183    }
3184
3185    fn on_resize(&mut self, cols: u16, rows: u16) {
3186        // No-op if size unchanged. Some terminals fire `Resize` for
3187        // shape changes that don't actually alter the cell grid (tab
3188        // toggles, font-size cycles, focus events on multiplexers);
3189        // the per-row CUP+EL wipe below is visible flicker even when
3190        // the result would be byte-identical, so skip the work
3191        // entirely. Pairs with the burst coalescing in
3192        // `event_loop::handle_input` — together they collapse a
3193        // window-drag's 30+ same-size tail events into a single paint.
3194        if cols == self.screen.width() && rows == self.screen.height() {
3195            return;
3196        }
3197        // Terminal-side wipe: resize leaves pre-resize chars at old
3198        // absolute positions. Use per-row CUP+EL instead of `\x1b[2J`
3199        // for the same reason as `reset()` — iTerm2 3.5+ has been
3200        // observed to ignore ED under certain states, leaving the
3201        // pre-resize welcome + footer on screen while the body
3202        // repaint below stamps a second copy. EL is row-local and
3203        // unambiguous across terminals.
3204        //
3205        // Release DECSTBM first so EL isn't constrained by the
3206        // stale (pre-resize) scroll region.
3207        let _ = self.out.write_all(b"\x1b[r");
3208        let mut seq = String::with_capacity((rows as usize) * 8 + 8);
3209        for row in 1..=(rows as usize) {
3210            use std::fmt::Write;
3211            let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
3212        }
3213        seq.push_str("\x1b[H");
3214        let _ = self.out.write_all(seq.as_bytes());
3215        self.scroll_region_bottom = None;
3216        self.screen.resize(cols, rows);
3217        // Rebuild the semantic welcome banner against the new width so
3218        // its right-aligned version/license pair stays adaptive after
3219        // terminal resize instead of replaying stale gap cells.
3220        self.reflow_welcome_prefix();
3221        // Re-emit body tail into the new region so the view matches
3222        // memory. Set region first so LFs scroll only within body.
3223        //
3224        // Cached `body_lines` cells were built against the OLD screen
3225        // width — after a resize-smaller drag, rows may exceed the new
3226        // terminal width. `serialize_row` writes every real cell, so
3227        // overflow would trigger the terminal's own auto-wrap; the
3228        // wrapped remainder lands on the next row, which on a fresh
3229        // DECSTBM region is either the footer strip or the next body
3230        // slot. Symptom the user sees: content shifted by a column and
3231        // junk in the footer strip. Clip each row to the new width
3232        // before handing it to `emit_body_line_inner` so we never
3233        // rely on the terminal to hide our overflow.
3234        let bottom = self.body_bottom_row();
3235        if bottom > 0 {
3236            let screen_w = self.screen.width() as usize;
3237            let tail: Vec<Vec<Cell>> = {
3238                let n = self.body_lines.len().min(bottom as usize);
3239                self.body_lines[self.body_lines.len() - n..]
3240                    .iter()
3241                    .map(|row| clip_cells_to_width(row, screen_w))
3242                    .collect()
3243            };
3244            let _ = write!(self.out, "\x1b[1;{}r", bottom);
3245            self.scroll_region_bottom = Some(bottom);
3246            // Direct CUP per row instead of `emit_body_line_inner`'s
3247            // LF-at-bottom scroll. LF inside the DECSTBM `[1, bottom]`
3248            // region pushes the top row out — and since we just erased
3249            // every row, that top row is blank. A full tail-repaint
3250            // would therefore inject `tail.len() - 1` blank rows into
3251            // scrollback. User symptom: after resizing smaller, the
3252            // scrollback above the current page fills with empty rows
3253            // for every resize event. Positioning absolutely with
3254            // `\x1b[row;1H` skips the scroll entirely and leaves
3255            // scrollback untouched.
3256            let n = tail.len() as u16;
3257            let first_row = bottom.saturating_sub(n) + 1;
3258            for (i, row) in tail.iter().enumerate() {
3259                let seq = format!("\x1b[{};1H\x1b[K", first_row + i as u16);
3260                let _ = self.out.write_all(seq.as_bytes());
3261                let bytes = serialize_row(row);
3262                let _ = self.out.write_all(&bytes);
3263            }
3264        }
3265        self.paint_frame();
3266        self.flush_frame();
3267        let _ = self.out.flush();
3268        self.last_painted_footer_rows = self.current_footer_rows();
3269        self.dirty = false;
3270    }
3271}
3272
3273/// Build a single-line row from `text`, flush-left at col 0, truncated
3274/// with `…` when the text overflows the screen width. Used by the
3275/// live-group rendering path (ToolGroupRender header / children /
3276/// summary, ToolGroupChildUpdate) where each child must be exactly
3277/// one terminal row so child indices map 1:1 with terminal positions
3278/// for in-place CUP rewrites.
3279///
3280/// Flush-left, no leading PAD_COL: header glyph (●) sits at col 0
3281/// aligned with the user-message ❯ chevron and the single tool-call
3282/// ● glyph (push_body_prefixed paths). Children carry a 2-space
3283/// prefix in their own text (event_loop builds `"  └ Bash(...)"`),
3284/// so they still indent under the header without extra padding here.
3285/// The previous PAD_COL leading pad pushed the header glyph to col 2
3286/// and the children to col 4, breaking visual alignment with the
3287/// rest of the body which lives at col 0 (user messages, single
3288/// tool calls).
3289fn build_one_row(text: &str, style: &CellStyle, screen_w: u16) -> Vec<Cell> {
3290    let avail = (screen_w as usize).saturating_sub(PAD_COL);
3291    let safe = scrub_controls(text);
3292    let truncated = if safe.chars().count() > avail.max(1) {
3293        let take_n = avail.saturating_sub(1).max(1);
3294        let mut s: String = safe.chars().take(take_n).collect();
3295        s.push('…');
3296        s
3297    } else {
3298        safe
3299    };
3300    let mut row = Vec::new();
3301    push_str_cells(&mut row, &truncated, style);
3302    row
3303}
3304
3305/// Truncate `body_str` to at most `max_chars` display-width characters,
3306/// preserving whole characters (not splitting multi-byte sequences).
3307/// This is a rendering safeguard to prevent degenerate bodies
3308/// (e.g. multi-KB bash commands) from producing hundreds of terminal lines.
3309fn truncate_body_str(body_str: &str, max_chars: usize) -> String {
3310    if let Some((idx, _)) = body_str.char_indices().nth(max_chars) {
3311        format!("{}… (truncated)", &body_str[..idx])
3312    } else {
3313        body_str.to_string()
3314    }
3315}
3316
3317/// Pluck the metadata suffix (` · 12s` and/or ` · N queued`) out of a
3318/// spinner label built by `format_spinner_label`. Labels have the
3319/// shape `{base}{ellipsis}[ · {elapsed}][ · {n} queued]`, so the first
3320/// ` · ` marks where the base ends and the metadata begins. Returns
3321/// the slice **including** its leading ` · ` separator so callers can
3322/// concatenate it directly, or `""` if the label has no metadata yet
3323/// (no phase clock has ticked).
3324fn spinner_meta_suffix(label: &str) -> &str {
3325    label.find(" · ").map(|i| &label[i..]).unwrap_or("")
3326}
3327
3328#[cfg(test)]
3329mod tests {
3330    use super::*;
3331    use crate::terminal::{EnvView, TerminalCaps};
3332    use std::sync::{
3333        atomic::{AtomicU64, Ordering},
3334        Arc, Mutex,
3335    };
3336
3337    #[test]
3338    fn ctx_usage_with_known_window_shows_ratio() {
3339        // The user's actual ask: "10.4k tokens" alone is uninformative —
3340        // they want to see how close to the limit the context is. With a
3341        // window, render `used/window tok` so saturation is visible.
3342        assert_eq!(format_ctx_usage(10_400, 131_000), "10.4k/131k tok");
3343    }
3344
3345    #[test]
3346    fn ctx_usage_keeps_round_window_clean() {
3347        // 128k window is the common default — render as `128k`, not `128.0k`.
3348        assert_eq!(format_ctx_usage(50_000, 128_000), "50.0k/128k tok");
3349    }
3350
3351    #[test]
3352    fn ctx_usage_without_window_shows_used_only() {
3353        // Pre-first-turn / unknown-provider fallback — window unknown.
3354        // Better to show the count alone than a misleading "/0".
3355        assert_eq!(format_ctx_usage(10_400, 0), "10.4k tok");
3356    }
3357
3358    #[test]
3359    fn ctx_usage_under_one_thousand_keeps_raw_count() {
3360        assert_eq!(format_ctx_usage(523, 131_000), "523/131k tok");
3361        assert_eq!(format_ctx_usage(523, 0), "523 tok");
3362    }
3363
3364    #[test]
3365    fn ctx_usage_non_round_window_rounds_to_nearest_k() {
3366        // GLM-5.1 endpoint ships a 131_072 window; we display 131k, not 131.072k.
3367        assert_eq!(format_ctx_usage(50_000, 131_072), "50.0k/131k tok");
3368    }
3369
3370    fn caps_with_color() -> TerminalCaps {
3371        TerminalCaps::from_env(EnvView {
3372            is_stdout_tty: true,
3373            term: Some("xterm-256color".into()),
3374            colorterm: Some("truecolor".into()),
3375            lang: Some("en_US.UTF-8".into()),
3376            ..Default::default()
3377        })
3378    }
3379
3380    /// Writer that tallies byte count — for assert-byte-budget tests.
3381    struct CountingSink(Arc<AtomicU64>);
3382    impl Write for CountingSink {
3383        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
3384            self.0.fetch_add(b.len() as u64, Ordering::Relaxed);
3385            Ok(b.len())
3386        }
3387        fn flush(&mut self) -> std::io::Result<()> {
3388            Ok(())
3389        }
3390    }
3391
3392    /// Writer that tracks every individual `write` call — for tests
3393    /// that assert emit is split into N chunks (Mac Terminal byte-drop
3394    /// workaround).
3395    #[derive(Clone)]
3396    struct ChunkCountingSink {
3397        chunks: Arc<Mutex<Vec<usize>>>,
3398    }
3399    impl Write for ChunkCountingSink {
3400        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
3401            self.chunks.lock().unwrap().push(b.len());
3402            Ok(b.len())
3403        }
3404        fn flush(&mut self) -> std::io::Result<()> {
3405            Ok(())
3406        }
3407    }
3408
3409    fn new_chunk_counting(
3410        w: u16,
3411        h: u16,
3412    ) -> (RetainedRenderer<ChunkCountingSink>, Arc<Mutex<Vec<usize>>>) {
3413        let chunks = Arc::new(Mutex::new(Vec::<usize>::new()));
3414        let sink = ChunkCountingSink {
3415            chunks: chunks.clone(),
3416        };
3417        let r = RetainedRenderer::with_writer(sink, caps_with_color(), w, h);
3418        (r, chunks)
3419    }
3420
3421    /// Writer that captures the ANSI byte stream — lets us inspect
3422    /// structure (e.g. "all three wide chars emitted consecutively").
3423    #[derive(Clone)]
3424    struct CapturingSink(Arc<Mutex<Vec<u8>>>);
3425    impl Write for CapturingSink {
3426        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
3427            self.0.lock().unwrap().extend_from_slice(b);
3428            Ok(b.len())
3429        }
3430        fn flush(&mut self) -> std::io::Result<()> {
3431            Ok(())
3432        }
3433    }
3434
3435    fn new_counting(w: u16, h: u16) -> (RetainedRenderer<CountingSink>, Arc<AtomicU64>) {
3436        let counter = Arc::new(AtomicU64::new(0));
3437        let sink = CountingSink(counter.clone());
3438        let r = RetainedRenderer::with_writer(sink, caps_with_color(), w, h);
3439        (r, counter)
3440    }
3441
3442    fn new_capturing(w: u16, h: u16) -> (RetainedRenderer<CapturingSink>, Arc<Mutex<Vec<u8>>>) {
3443        let buf = Arc::new(Mutex::new(Vec::new()));
3444        let sink = CapturingSink(buf.clone());
3445        let r = RetainedRenderer::with_writer(sink, caps_with_color(), w, h);
3446        (r, buf)
3447    }
3448
3449    /// Phase 7 harness: drain the capture sink's accumulated
3450    /// ANSI bytes into the virtual terminal so `vterm.cell_at` /
3451    /// `row_text` / `dump` reflect the post-paint on-screen state.
3452    /// The sink is left empty afterwards so subsequent renders
3453    /// accumulate their own bytes for another feed cycle.
3454    fn drain_into_vterm(buf: &Arc<Mutex<Vec<u8>>>, vterm: &mut crate::test_term::VirtualTerminal) {
3455        let bytes: Vec<u8> = std::mem::take(&mut *buf.lock().unwrap());
3456        vterm.feed(&bytes);
3457    }
3458
3459    fn sample(c: &Arc<AtomicU64>) -> u64 {
3460        c.load(Ordering::Relaxed)
3461    }
3462
3463    fn status_basic() -> StatusLine {
3464        StatusLine {
3465            model: "glm-5".into(),
3466            cwd: "~/project/atomcode".into(),
3467            ctx_used: 0,
3468                ctx_window: 0,
3469            hint: None,
3470            mode_indicator: None,
3471            session_name: None,
3472        }
3473    }
3474
3475    /// Mode indicator (Plan badge) renders BEFORE the model · cwd · tokens
3476    /// run. Default Build mode (`mode_indicator = None`) keeps the row
3477    /// unchanged so existing layout / byte-budget tests stay valid.
3478    #[test]
3479    fn build_status_row_renders_mode_badge_before_left_run() {
3480        let (mut r, _counter) = new_counting(80, 24);
3481        // Force unicode + colors so the brand SGR is reachable; without
3482        // this the test target (CI sometimes) drops the SGR and we can't
3483        // distinguish badge cells from body cells.
3484        r.caps.colors = true;
3485        r.caps.unicode_symbols = true;
3486        let status = StatusLine {
3487            model: "glm-5".into(),
3488            cwd: "~/proj".into(),
3489            ctx_used: 0,
3490                ctx_window: 0,
3491            hint: None,
3492            mode_indicator: Some("PLAN".into()),
3493            session_name: None,
3494        };
3495        let row = r.build_status_row(&status, 60);
3496        // Concatenate visible chars from the cells. `PAD_COL` of leading
3497        // spaces, then the badge, then a separator space, then the body.
3498        let visible: String = row.iter().map(|c| c.ch).collect();
3499        let trimmed = visible.trim_start();
3500        assert!(
3501            trimmed.starts_with("PLAN "),
3502            "badge must precede the model run; got: {:?}",
3503            visible
3504        );
3505        assert!(
3506            visible.contains("glm-5"),
3507            "model name must still appear in the row; got: {:?}",
3508            visible
3509        );
3510    }
3511
3512    /// Default Build mode produces no badge — row is identical to the
3513    /// pre-mode-indicator layout. Guards against accidental "PLAN" leak
3514    /// when no mode is active.
3515    #[test]
3516    fn build_status_row_default_mode_emits_no_badge() {
3517        let (mut r, _counter) = new_counting(80, 24);
3518        r.caps.colors = true;
3519        r.caps.unicode_symbols = true;
3520        let row = r.build_status_row(&status_basic(), 60);
3521        let visible: String = row.iter().map(|c| c.ch).collect();
3522        assert!(
3523            !visible.contains("PLAN"),
3524            "no mode indicator should produce no PLAN badge; got: {:?}",
3525            visible
3526        );
3527    }
3528
3529    /// Session-name pill: the top rule must overlay ` {name} ` in
3530    /// reverse-cyan cells on the right side. Mirrors CC's per-
3531    /// conversation badge so the user sees which session they're
3532    /// typing into without opening the picker.
3533    #[test]
3534    fn build_top_rule_with_badge_renders_session_name_in_reverse_cyan() {
3535        let (mut r, _counter) = new_counting(80, 24);
3536        r.caps.colors = true;
3537        r.caps.unicode_symbols = true;
3538        let row = r.build_top_rule_with_badge(60, Some("atomcode加解密"));
3539        // Skip continuation cells (width 0 placeholders that follow a
3540        // wide glyph) — they carry `ch = ' '` and would break a naive
3541        // substring check on a CJK name.
3542        let visible: String = row.iter().filter(|c| c.width > 0).map(|c| c.ch).collect();
3543        assert!(
3544            visible.contains("atomcode加解密"),
3545            "session name must appear in the top rule cells. got: {:?}",
3546            visible
3547        );
3548        let any_reverse = row.iter().any(|c| c.style.reverse);
3549        assert!(
3550            any_reverse,
3551            "at least one cell of the pill must carry reverse-video style"
3552        );
3553    }
3554
3555    /// `None` session_name keeps the top rule pristine — no reverse
3556    /// cells, no text overlay. Guards against the badge leaking onto
3557    /// auto-named or default sessions.
3558    #[test]
3559    fn build_top_rule_with_badge_none_emits_plain_rule() {
3560        let (mut r, _counter) = new_counting(80, 24);
3561        r.caps.colors = true;
3562        r.caps.unicode_symbols = true;
3563        let row = r.build_top_rule_with_badge(60, None);
3564        assert_eq!(row.len(), 60, "rule width must be preserved");
3565        assert!(
3566            row.iter().all(|c| c.ch == '─'),
3567            "without a session name every cell must be a bare ─"
3568        );
3569        assert!(
3570            row.iter().all(|c| !c.style.reverse),
3571            "no reverse-video cells allowed when session_name is None"
3572        );
3573    }
3574
3575    /// Overlong names get truncated with `…` so the rule width is
3576    /// preserved and at least a minimum stretch of ─ stays visible on
3577    /// the left as a visual anchor for the input box border.
3578    #[test]
3579    fn build_top_rule_with_badge_truncates_long_name() {
3580        let (mut r, _counter) = new_counting(40, 24);
3581        r.caps.colors = true;
3582        r.caps.unicode_symbols = true;
3583        let long = "这是一个非常非常非常非常长的会话名字应当被截断省略";
3584        let row = r.build_top_rule_with_badge(40, Some(long));
3585        // Same continuation-cell filter rationale as the badge-render
3586        // test above: width-0 cells carry ' ' and would obscure the
3587        // substring assertions on CJK names.
3588        let visible: String = row.iter().filter(|c| c.width > 0).map(|c| c.ch).collect();
3589        assert!(
3590            visible.contains('…'),
3591            "overlong name must be ellipsised. got: {:?}",
3592            visible
3593        );
3594        assert!(
3595            !visible.contains(long),
3596            "full overlong name must NOT appear verbatim. got: {:?}",
3597            visible
3598        );
3599    }
3600
3601    /// Keystroke steady-state: only the middle row's last cell
3602    /// changes between frames. AnsiRenderer hit 26 B; retained
3603    /// should be in the same ballpark. Budget: < 60 B.
3604    #[test]
3605    fn retained_keystroke_byte_cost_steady_state() {
3606        let (mut r, counter) = new_counting(80, 24);
3607        let status = status_basic();
3608        // Warm: render one frame so prev_cells matches terminal.
3609        r.render(UiLine::InputPrompt {
3610            buf: "h".into(),
3611            cursor_byte: 1,
3612            menu: None,
3613            status: status.clone(),
3614            attachments: Vec::new(),
3615        });
3616        r.flush_deferred();
3617        let before = sample(&counter);
3618        for i in 1..=10 {
3619            let s = "h".repeat(i + 1);
3620            r.render(UiLine::InputPrompt {
3621                buf: s.clone(),
3622                cursor_byte: s.len(),
3623                menu: None,
3624                status: status.clone(),
3625                attachments: Vec::new(),
3626            });
3627        }
3628        r.flush_deferred();
3629        let avg = (sample(&counter) - before) / 10;
3630        eprintln!("[RETAINED BYTE] keystroke avg = {} B", avg);
3631        assert!(
3632            avg < 60,
3633            "retained keystroke regressed: avg={} B (budget < 60)",
3634            avg
3635        );
3636    }
3637
3638    /// Menu open/close: footer height changes 5↔9 → cell-diff must
3639    /// emit only changed positions. AnsiRenderer hit 880 B at 80
3640    /// col; retained should match. Budget: < 1000 B.
3641    #[test]
3642    fn retained_menu_toggle_byte_cost() {
3643        let (mut r, counter) = new_counting(80, 24);
3644        let status = status_basic();
3645        let items: Vec<(String, String)> = vec![
3646            ("model".into(), "Switch model".into()),
3647            ("provider".into(), "Add provider".into()),
3648            ("session".into(), "New session".into()),
3649            ("resume".into(), "Resume session".into()),
3650        ];
3651        r.render(UiLine::InputPrompt {
3652            buf: "".into(),
3653            cursor_byte: 0,
3654            menu: None,
3655            status: status.clone(),
3656            attachments: Vec::new(),
3657        });
3658        r.flush_deferred();
3659
3660        let before_open = sample(&counter);
3661        r.render(UiLine::InputPrompt {
3662            buf: "/".into(),
3663            cursor_byte: 1,
3664            menu: Some(MenuPayload {
3665                items: items.clone(),
3666                selected: 0,
3667                    kind: crate::render::MenuKind::SlashCommand,
3668            }),
3669            status: status.clone(),
3670            attachments: Vec::new(),
3671        });
3672        r.flush_deferred();
3673        let open_cost = sample(&counter) - before_open;
3674
3675        let before_close = sample(&counter);
3676        r.render(UiLine::InputPrompt {
3677            buf: "".into(),
3678            cursor_byte: 0,
3679            menu: None,
3680            status: status.clone(),
3681            attachments: Vec::new(),
3682        });
3683        r.flush_deferred();
3684        let close_cost = sample(&counter) - before_close;
3685
3686        // Nav: 3 Up/Down changes.
3687        r.render(UiLine::InputPrompt {
3688            buf: "/".into(),
3689            cursor_byte: 1,
3690            menu: Some(MenuPayload {
3691                items: items.clone(),
3692                selected: 0,
3693                    kind: crate::render::MenuKind::SlashCommand,
3694            }),
3695            status: status.clone(),
3696            attachments: Vec::new(),
3697        });
3698        r.flush_deferred();
3699        let before_nav = sample(&counter);
3700        for sel in 1..=3 {
3701            r.render(UiLine::InputPrompt {
3702                buf: "/".into(),
3703                cursor_byte: 1,
3704                menu: Some(MenuPayload {
3705                    items: items.clone(),
3706                    selected: sel,
3707                    kind: crate::render::MenuKind::SlashCommand,
3708                }),
3709                status: status.clone(),
3710                attachments: Vec::new(),
3711            });
3712        }
3713        r.flush_deferred();
3714        let nav_avg = (sample(&counter) - before_nav) / 3;
3715
3716        eprintln!(
3717            "[RETAINED BYTE] menu open={} B, close={} B, nav avg={} B",
3718            open_cost, close_cost, nav_avg
3719        );
3720        assert!(open_cost < 1000, "retained open: {} B", open_cost);
3721        assert!(close_cost < 1000, "retained close: {} B", close_cost);
3722        assert!(nav_avg < 300, "retained nav: {} B", nav_avg);
3723    }
3724
3725    /// Streaming delta byte cost: scenario mirrors agent_events
3726    /// emitting `AssistantText` + `StreamingBox` repeatedly. Each
3727    /// iteration appends a short line to the body + re-paints the
3728    /// footer spinner. Budget: < 200 B/iteration (AnsiRenderer was
3729    /// 41 B for streaming-only, but retained pays an extra
3730    /// full-frame cost for the trailing StreamingBox re-paint).
3731    #[test]
3732    fn retained_streaming_delta_byte_cost() {
3733        let (mut r, counter) = new_counting(80, 24);
3734        let status = status_basic();
3735        // Initial spinner footer.
3736        r.render(UiLine::StreamingBox {
3737            buf: String::new(),
3738            cursor_byte: 0,
3739            frame: "⠋",
3740            label: "Thinking".into(),
3741            status: status.clone(),
3742            menu: None,
3743            attachments: Vec::new(),
3744        });
3745        r.flush_deferred();
3746        let before_burst = sample(&counter);
3747        for i in 0..20 {
3748            r.render(UiLine::AssistantText(format!("line {}\n", i)));
3749            r.render(UiLine::StreamingBox {
3750                buf: String::new(),
3751                cursor_byte: 0,
3752                frame: "⠹",
3753                label: "Thinking".into(),
3754                status: status.clone(),
3755                menu: None,
3756                attachments: Vec::new(),
3757            });
3758        }
3759        r.flush_deferred();
3760        let avg_per_delta = (sample(&counter) - before_burst) / 20;
3761        eprintln!(
3762            "[RETAINED BYTE] streaming avg per (delta + box redraw) = {} B",
3763            avg_per_delta
3764        );
3765        assert!(
3766            avg_per_delta < 250,
3767            "retained streaming regressed: {} B/iter (budget < 250)",
3768            avg_per_delta
3769        );
3770    }
3771
3772    /// Phase 5 coalesce contract: N render() calls followed by a
3773    /// single flush_deferred() must produce exactly ONE emit (or
3774    /// zero, if nothing visibly changed since the last frame).
3775    /// Without coalesce, Phase 4 would emit N times. Regression
3776    /// target: IME burst of 40 chars = 1 terminal repaint, not 40.
3777    #[test]
3778    fn retained_coalesce_many_renders_one_emit() {
3779        let (mut r, counter) = new_counting(80, 24);
3780        let status = status_basic();
3781        // Establish initial frame so subsequent diffs are small.
3782        r.render(UiLine::InputPrompt {
3783            buf: "".into(),
3784            cursor_byte: 0,
3785            menu: None,
3786            status: status.clone(),
3787            attachments: Vec::new(),
3788        });
3789        r.flush_deferred();
3790
3791        let before_burst = sample(&counter);
3792        // Simulate IME burst: 40 keystrokes in zero time.
3793        let mut buf = String::new();
3794        for ch in
3795            "你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁你是谁".chars()
3796        {
3797            buf.push(ch);
3798            r.render(UiLine::InputPrompt {
3799                buf: buf.clone(),
3800                cursor_byte: buf.len(),
3801                menu: None,
3802                status: status.clone(),
3803                attachments: Vec::new(),
3804            });
3805        }
3806        // Zero byte count so far — coalesce should hold every
3807        // render() as dirty-flag updates only.
3808        assert_eq!(
3809            sample(&counter) - before_burst,
3810            0,
3811            "render() must not emit bytes before flush_deferred fires"
3812        );
3813
3814        // The tick fires → ONE paint+emit covering all 40 state
3815        // changes at once.
3816        r.flush_deferred();
3817        let burst_bytes = sample(&counter) - before_burst;
3818        eprintln!(
3819            "[RETAINED BYTE] coalesce: 40 renders + 1 tick = {} B total",
3820            burst_bytes
3821        );
3822        // Upper bound: cold start (first paint after session init)
3823        // re-emits every non-blank cell + UTF-8 CJK + rule + cursor
3824        // moves. Budget 1200 B; typical observed ~700 B.
3825        assert!(
3826            burst_bytes > 0 && burst_bytes < 1200,
3827            "coalesce should produce exactly one modest emit: {} B",
3828            burst_bytes
3829        );
3830
3831        // Second tick with no state change → truly zero emit.
3832        let before_idle = sample(&counter);
3833        r.flush_deferred();
3834        let idle_bytes = sample(&counter) - before_idle;
3835        assert_eq!(idle_bytes, 0, "idle tick should emit 0 bytes");
3836    }
3837
3838    /// Regression: user reported that after resizing the terminal
3839    /// smaller, scrolling up in the terminal revealed many blank rows
3840    /// above the current page. Root cause: `on_resize` repainted the
3841    /// body tail via `emit_body_line_inner`, which uses `\n` inside
3842    /// the DECSTBM `[1, bottom]` region to place each row. Since the
3843    /// just-cleared top-row of that region gets pushed to scrollback
3844    /// on every `\n`, a full tail-repaint injected `tail.len() - 1`
3845    /// blank rows into scrollback for every resize event.
3846    ///
3847    /// `on_resize` is a no-op when geometry is unchanged. Some
3848    /// terminals fire spurious `Resize` events on tab/focus/pane
3849    /// shuffles where the cell grid doesn't actually change; the
3850    /// per-row CUP+EL wipe inside `on_resize` is a visible flash even
3851    /// when the outcome would be byte-identical. Pairs with the
3852    /// burst-coalesce in `event_loop::handle_input` to collapse a
3853    /// window-drag's same-size tail into a single paint.
3854    #[test]
3855    fn retained_resize_same_size_emits_nothing() {
3856        let (mut r, buf) = new_capturing(80, 24);
3857        let status = status_basic();
3858        r.render(UiLine::User("hi".into()));
3859        r.render(UiLine::InputPrompt {
3860            buf: String::new(),
3861            cursor_byte: 0,
3862            menu: None,
3863            status: status.clone(),
3864            attachments: Vec::new(),
3865        });
3866        r.flush_deferred();
3867        let bytes_before = buf.lock().unwrap().len();
3868        r.on_resize(80, 24);
3869        let bytes_after = buf.lock().unwrap().len();
3870        assert_eq!(
3871            bytes_before, bytes_after,
3872            "same-size on_resize must not emit any bytes (flicker source)"
3873        );
3874    }
3875
3876    /// Fix: position each tail row with absolute CUP + EL instead of
3877    /// LF-scrolling, so scrollback is never touched during resize.
3878    #[test]
3879    fn retained_resize_does_not_pollute_scrollback_with_blanks() {
3880        let (mut r, buf) = new_capturing(80, 24);
3881        let status = status_basic();
3882
3883        // Seed some body content so there's a tail to re-emit.
3884        r.render(UiLine::User("first".into()));
3885        r.render(UiLine::User("second".into()));
3886        r.render(UiLine::InputPrompt {
3887            buf: String::new(),
3888            cursor_byte: 0,
3889            menu: None,
3890            status: status.clone(),
3891            attachments: Vec::new(),
3892        });
3893        r.flush_deferred();
3894
3895        // Baseline: feed everything so far into the vterm and record
3896        // how many rows have scrolled off the top.
3897        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
3898        drain_into_vterm(&buf, &mut vterm);
3899        let baseline_scrollback = vterm.scrollback_len();
3900
3901        // Now trigger resize-smaller. All bytes emitted by the resize
3902        // path go to `buf`; feed them alone into the vterm to measure
3903        // the resize's contribution to scrollback in isolation.
3904        r.on_resize(60, 16);
3905        r.render(UiLine::InputPrompt {
3906            buf: String::new(),
3907            cursor_byte: 0,
3908            menu: None,
3909            status: status.clone(),
3910            attachments: Vec::new(),
3911        });
3912        r.flush_deferred();
3913        let mut vterm_after = crate::test_term::VirtualTerminal::new(60, 16);
3914        drain_into_vterm(&buf, &mut vterm_after);
3915
3916        // Scrollback from the RESIZE alone (vterm_after starts fresh).
3917        // Before the fix, on_resize emitted `tail.len() - 1` blank
3918        // rows into scrollback; after the fix it must emit zero.
3919        assert_eq!(
3920            vterm_after.scrollback_len(),
3921            0,
3922            "resize pushed {} rows into scrollback; expected 0 \
3923             (baseline before resize: {})",
3924            vterm_after.scrollback_len(),
3925            baseline_scrollback
3926        );
3927    }
3928
3929    /// Regression: user showed a 5-column CJK table with long cells
3930    /// overflowing past the terminal's right edge — `flush_aligned_table`
3931    /// was ignoring terminal width. This test verifies the full pipeline
3932    /// (streamed assistant text → `render_line_with_width` → body_lines)
3933    /// keeps every rendered body row within screen width.
3934    #[test]
3935    fn retained_wide_table_truncated_to_screen_width() {
3936        let term_w: u16 = 100;
3937        let (mut r, _buf) = new_capturing(term_w, 30);
3938        let status = status_basic();
3939
3940        let table = "\
3941| 特性 | 免费版 | 专业版 | 企业版 | 旗舰版 |
3942|------|--------|--------|--------|--------|
3943| 价格 | 完全免费,适合个人开发者和学生群体使用 | 每月 $9.9,适合小型团队和独立开发者 | 每月 $49,适合中型企业和专业团队 | 每月 $199,适合大型企业和需要高级功能的用户 |
3944| 支持语言 | 支持 Python、JavaScript、TypeScript 三种主流编程语言 | 支持所有主流编程语言,包括但不限于 Python、JavaScript、TypeScript、Java、Kotlin、Swift、Rust、Go 等 20+ 种语言 | 支持所有编程语言,无任何限制 | 支持所有已知编程语言 |
3945
3946尾部文本触发表格 flush。
3947";
3948        for line in table.lines() {
3949            r.render(UiLine::AssistantText(format!("{}\n", line)));
3950        }
3951        r.render(UiLine::AssistantLineBreak);
3952        r.render(UiLine::InputPrompt {
3953            buf: String::new(),
3954            cursor_byte: 0,
3955            menu: None,
3956            status,
3957            attachments: Vec::new(),
3958        });
3959        r.flush_deferred();
3960
3961        // Body rows carry styling + 2-col PAD_COL indent. Strip ANSI and
3962        // check the display width of each cached body row.
3963        for (i, row) in r.body_lines.iter().enumerate() {
3964            let w: usize = row.iter().map(|c| c.width as usize).sum();
3965            assert!(
3966                w <= term_w as usize,
3967                "body row {} has display width {} > terminal {}; \
3968                 table rendered without width-aware truncation",
3969                i,
3970                w,
3971                term_w
3972            );
3973        }
3974    }
3975
3976    /// Regression (datalog symptom: the screen filled with ~35 rows of
3977    /// `<spinner-glyph> Bash(cd /Users/.../cargo metadata...|python3 -c …`
3978    /// stacking up). Root cause: a wide tool name+detail row, repainted
3979    /// every spinner tick, would auto-wrap on the bottom row of the
3980    /// DECSTBM region and the upper portion would scroll up into body
3981    /// history — accumulating residue.
3982    ///
3983    /// Fix (post-merge): `render_inflight_tool` wraps the body via
3984    /// `push_body_prefixed` so each pushed row fits the terminal width,
3985    /// AND tracks `inflight_tool_rows` so the next call removes the
3986    /// previously rendered rows before re-rendering — body_lines no
3987    /// longer accumulates across ticks.
3988    #[test]
3989    fn retained_inflight_tool_row_wraps_and_replaces_in_place() {
3990        let term_w: u16 = 80;
3991        let (mut r, _buf) = new_capturing(term_w, 24);
3992        // A real bash command from the failure datalog — well over 80
3993        // columns — drives the regression.
3994        let detail = "cd /Users/yubangxu/project/atomgr && cargo metadata --format-version 1 \
3995                      2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); \
3996                      print([p['name'] for p in d['packages']])\"";
3997        r.render_inflight_tool("⠋", "bash", detail, "");
3998        // Every wrapped row must fit the terminal — otherwise DECSTBM
3999        // auto-wrap on subsequent repaints turns into scroll residue.
4000        for (i, row) in r.body_lines.iter().enumerate() {
4001            let w: usize = row.iter().map(|c| c.width as usize).sum();
4002            assert!(
4003                w <= term_w as usize,
4004                "body_lines[{}] width {} exceeds terminal {}",
4005                i,
4006                w,
4007                term_w
4008            );
4009        }
4010        // Simulated spinner ticks: body_lines must not grow — each tick
4011        // removes the prior inflight rows before re-rendering.
4012        let after_first = r.body_lines.len();
4013        for _ in 0..10 {
4014            r.render_inflight_tool("⠙", "bash", detail, "");
4015        }
4016        assert_eq!(
4017            r.body_lines.len(),
4018            after_first,
4019            "body_lines grew across spinner ticks — render_inflight_tool \
4020             must remove previous inflight rows before re-rendering"
4021        );
4022    }
4023
4024    /// Regression (datalog 2026-05-08_02-39-44 + screenshots 40.png/41.jpeg):
4025    /// the model emitted ONE `cargo build 2>&1 | tail -5` call that ran
4026    /// for 39.6s, but the user's terminal ended up with 30+ identical
4027    /// `▸ Bash(...)` rows stacked in scrollback. Root cause was
4028    /// `render_inflight_tool` calling `push_body_row` →
4029    /// `emit_body_line_inner` whose default branch issues a `\n` to
4030    /// scroll new content into the DECSTBM body region. Each spinner
4031    /// tick (~80ms) emitted a fresh copy of the inflight row, scrolling
4032    /// the previous tick's row up — those rows STAY in the terminal's
4033    /// scrollback even after the renderer truncates them out of
4034    /// `body_lines`. The pre-existing `retained_inflight_tool_row_*`
4035    /// test only checked `body_lines.len()`; the actual leak was on
4036    /// the terminal output stream.
4037    ///
4038    /// Fix: when re-rendering on top of a prior inflight render with
4039    /// matching row count, write each row in-place via cursor-position +
4040    /// erase-line (no `\n`, no scroll), so the terminal's scrollback
4041    /// stays clean across ticks. This test captures the output bytes
4042    /// and asserts their length doesn't blow up — a stream of N ticks
4043    /// must produce at most O(N) bytes of update sequences, not O(N)
4044    /// full row scrolls of accumulated content.
4045    #[test]
4046    fn retained_inflight_tool_does_not_grow_terminal_output_across_ticks() {
4047        let term_w: u16 = 80;
4048        let (mut r, buf) = new_capturing(term_w, 24);
4049        let detail = "cd /Users/theo/Documents/workspace/atomcode && cargo build 2>&1 | tail -5";
4050
4051        // First render: pushes scroll-style (prev_rows=0 → fallback path).
4052        r.render_inflight_tool("⠋", "bash", detail, "");
4053        let bytes_after_first = buf.lock().unwrap().len();
4054        assert!(
4055            bytes_after_first > 0,
4056            "first render must emit some bytes"
4057        );
4058
4059        // Drain so subsequent measurements are tick-only.
4060        buf.lock().unwrap().clear();
4061
4062        // Simulate 50 spinner ticks (~4 seconds at 80ms cadence). Each
4063        // must take the in-place branch — no `\n`, no scroll, no
4064        // accumulation. We bound the total bytes by the per-tick budget
4065        // (~80 bytes for cursor-pos + erase + serialised row) times
4066        // tick count + headroom for SGR resets and wrapped continuation
4067        // rows. A scroll-leak would emit hundreds of bytes per tick
4068        // (full row content + SGR + position) and blow this bound by
4069        // an order of magnitude.
4070        for i in 0..50 {
4071            // Cycle through the standard braille spinner glyphs so the
4072            // icon arg actually changes each call. Same display width,
4073            // so prev_rows == new_rows and the in-place branch fires.
4074            let icon = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][i % 10];
4075            r.render_inflight_tool(icon, "bash", detail, "");
4076        }
4077        let bytes_per_tick = buf.lock().unwrap().len() / 50;
4078        // ~150 bytes/tick is a comfortable upper bound for the in-place
4079        // path (CUP + EL + serialised row + SGR resets, per wrapped row).
4080        // The pre-fix scroll path emitted ~600+ bytes/tick on this input
4081        // because each push_body_row scrolled and re-styled a fresh full
4082        // row at body_bottom, plus DECSTBM scroll + cursor reposition.
4083        assert!(
4084            bytes_per_tick < 300,
4085            "per-tick byte budget exceeded ({} bytes/tick, 50 ticks total \
4086             {} bytes) — render_inflight_tool is scrolling fresh rows in \
4087             instead of overwriting the existing ones",
4088            bytes_per_tick,
4089            buf.lock().unwrap().len()
4090        );
4091
4092        // body_lines stays bounded too (existing invariant).
4093        assert!(
4094            r.body_lines.len() <= 4,
4095            "body_lines grew to {} rows across 50 ticks — should stay at \
4096             prev_rows count for in-place path",
4097            r.body_lines.len()
4098        );
4099    }
4100
4101    /// User report (long `cargo install` looked stuck): the inflight
4102    /// tool row is `<spinner> Bash(cmd)` with no elapsed indicator,
4103    /// while the regular thinking spinner shows `Pondering… · 12s`.
4104    /// After ~30s of waiting the user can't tell whether bash is
4105    /// running or hung. Fix: forward the spinner-label metadata
4106    /// (` · 12s · N queued`) into `render_inflight_tool` so the same
4107    /// time anchor appears next to the tool row.
4108    #[test]
4109    fn retained_inflight_tool_renders_elapsed_meta_suffix() {
4110        let (mut r, _buf) = new_capturing(80, 24);
4111        // Seed an inflight tool so the Spinner branch routes through
4112        // render_inflight_tool (mirrors the real call path).
4113        r.render(UiLine::ToolCallInFlight {
4114            id: "call-1".into(),
4115            name: "Bash".into(),
4116            detail: "cargo install cargo-udeps --locked".into(),
4117        });
4118        r.render(UiLine::Spinner {
4119            frame: "⠋".into(),
4120            label: "Running Bash… · 12s".into(),
4121        });
4122        let last = r.body_lines.last().expect("inflight row expected");
4123        let text: String = last.iter().map(|c| c.ch).collect();
4124        assert!(
4125            text.contains("· 12s"),
4126            "inflight tool row missing elapsed meta suffix; got: {:?}",
4127            text
4128        );
4129        assert!(
4130            text.contains("Bash(cargo install"),
4131            "inflight tool row missing command detail; got: {:?}",
4132            text
4133        );
4134    }
4135
4136    #[test]
4137    fn spinner_meta_suffix_extracts_after_first_separator() {
4138        assert_eq!(spinner_meta_suffix("Running Bash… · 12s"), " · 12s");
4139        assert_eq!(
4140            spinner_meta_suffix("Running Bash… · 12s · 2 queued"),
4141            " · 12s · 2 queued"
4142        );
4143        // No metadata yet (no phase clock tick) → empty suffix.
4144        assert_eq!(spinner_meta_suffix("Pondering…"), "");
4145        assert_eq!(spinner_meta_suffix(""), "");
4146    }
4147
4148    /// Regression (screenshot 42.png): user reported a stray blinking
4149    /// caret at the right edge of the active `▸ Bash(...)` row, sitting
4150    /// alongside the legitimate input-box caret. Root cause: the
4151    /// in-place path in `render_inflight_tool` writes raw cursor-position
4152    /// bytes via `self.out.write_all` to overwrite each row, leaving the
4153    /// terminal cursor at end-of-row. `paint_footer` repositions the
4154    /// cell-model cursor to the input box but `set_cursor_visible(true)`
4155    /// keeps the terminal blinking — so for every 5ms paint window
4156    /// before the next CUP lands, the user saw two carets.
4157    ///
4158    /// Fix: hide the cursor whenever an inflight tool is active, in
4159    /// addition to the existing live-spinner gate. `inflight_tool.is_none()`
4160    /// flips back at commit time, so the cursor reappears at the input
4161    /// box on the next paint without a leftover blink.
4162    #[test]
4163    fn retained_inflight_tool_hides_terminal_cursor() {
4164        let term_w: u16 = 80;
4165        let (mut r, buf) = new_capturing(term_w, 24);
4166        let detail = "cd /Users/theo/Documents/workspace/atomcode && cargo check 2>&1 | tail -80";
4167
4168        // Seed input prompt + ToolCallInFlight so paint_footer has a
4169        // sensible cursor position to consult.
4170        r.render(UiLine::InputPrompt {
4171            buf: String::new(),
4172            cursor_byte: 0,
4173            menu: None,
4174            status: status_basic(),
4175            attachments: Vec::new(),
4176        });
4177        r.render(UiLine::ToolCallInFlight {
4178            id: "call_1".into(),
4179            name: "Bash".into(),
4180            detail: detail.into(),
4181        });
4182        // A spinner tick to exercise the in-place branch.
4183        r.render(UiLine::Spinner {
4184            frame: "⠙".into(),
4185            label: "Running Bash".into(),
4186        });
4187        r.flush_deferred();
4188        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4189        drain_into_vterm(&buf, &mut vterm);
4190        assert!(
4191            !vterm.cursor_visible(),
4192            "terminal cursor must be hidden while a tool call is in flight \
4193             (otherwise it blinks at end-of-row alongside the input caret)"
4194        );
4195
4196        // Commit the inflight tool — cursor must come back at the next
4197        // paint so the user sees their input-box caret again.
4198        r.render(UiLine::ToolCallCommit {
4199            call_id: Some("call_1".into()),
4200        });
4201        r.render(UiLine::InputPrompt {
4202            buf: String::new(),
4203            cursor_byte: 0,
4204            menu: None,
4205            status: status_basic(),
4206            attachments: Vec::new(),
4207        });
4208        r.flush_deferred();
4209        drain_into_vterm(&buf, &mut vterm);
4210        assert!(
4211            vterm.cursor_visible(),
4212            "terminal cursor must be visible again after the inflight tool \
4213             commits — `inflight_tool.is_none()` flips the gate back"
4214        );
4215    }
4216
4217    /// Regression: user reported that after a terminal resize two
4218    /// footers appeared stacked on screen — old footer at pre-resize
4219    /// absolute rows kept its chars, new footer painted at new rows,
4220    /// both visible. Root cause: `Screen::resize` rebuilds both
4221    /// frames blank, so the next diff vs all-blank prev has nothing
4222    /// to erase — but the terminal still holds pre-resize glyphs at
4223    /// the old absolute positions.
4224    ///
4225    /// Fix: `on_resize` emits per-row CUP+EL for every row of the new
4226    /// viewport before repainting, so the terminal's own display
4227    /// clears and the new frame owns every visible column. (Uses EL
4228    /// instead of `\x1b[2J` because iTerm2 3.5+ has been observed to
4229    /// ignore ED under certain states — see `reset()` rationale.)
4230    #[test]
4231    fn retained_resize_clears_old_footer_via_vterm() {
4232        let (mut r, buf) = new_capturing(80, 24);
4233        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4234        let status = status_basic();
4235
4236        // Frame 1: paint initial footer at 80x24 with distinctive
4237        // string "originaltag". After drain, the sink is empty.
4238        r.render(UiLine::InputPrompt {
4239            buf: "originaltag".into(),
4240            cursor_byte: 11,
4241            menu: None,
4242            status: status.clone(),
4243            attachments: Vec::new(),
4244        });
4245        r.flush_deferred();
4246        drain_into_vterm(&buf, &mut vterm);
4247        assert!(vterm.row_text(21).contains("originaltag"));
4248
4249        // Resize + then push a frame with EMPTY input so the new
4250        // layout has no legitimate reason to contain "originaltag".
4251        // Any occurrence post-resize is ghost content from before.
4252        r.on_resize(60, 16);
4253        r.render(UiLine::InputPrompt {
4254            buf: String::new(),
4255            cursor_byte: 0,
4256            menu: None,
4257            status: status.clone(),
4258            attachments: Vec::new(),
4259        });
4260        r.flush_deferred();
4261
4262        // New vterm matching post-resize dimensions, feed only the
4263        // bytes emitted AFTER the resize (drain was called above
4264        // at line "assert row_text 21").
4265        let mut vterm = crate::test_term::VirtualTerminal::new(60, 16);
4266        drain_into_vterm(&buf, &mut vterm);
4267
4268        for r_idx in 0..16 {
4269            let row = vterm.row_text(r_idx);
4270            assert!(
4271                !row.contains("originaltag"),
4272                "stale pre-resize content leaked to row {}: {:?}\n\
4273                 dump:\n{}",
4274                r_idx,
4275                row,
4276                vterm.dump()
4277            );
4278        }
4279    }
4280
4281    /// Phase 7 exemplar: end-to-end render through VirtualTerminal.
4282    /// Verifies the same bot_rule invariant as the sibling test
4283    /// below — but asserts on the grid the terminal would actually
4284    /// paint (derived from our ANSI byte stream), not on the cell
4285    /// buffer we emitted from. This is the shape of test that
4286    /// catches "cells right, screen wrong" bugs like the Mac
4287    /// Terminal byte-drop issue.
4288    #[test]
4289    fn retained_bot_rule_full_width_after_wrap_via_vterm() {
4290        let (mut r, buf) = new_capturing(40, 24);
4291        let mut vterm = crate::test_term::VirtualTerminal::new(40, 24);
4292        let status = status_basic();
4293
4294        // Frame 1: short input → 1-row middle.
4295        r.render(UiLine::InputPrompt {
4296            buf: "hi".into(),
4297            cursor_byte: 2,
4298            menu: None,
4299            status: status.clone(),
4300            attachments: Vec::new(),
4301        });
4302        r.flush_deferred();
4303        drain_into_vterm(&buf, &mut vterm);
4304
4305        // Frame 2: long input → 2-row middle. Footer grows from 5
4306        // to 6, bot_rule moves from row H-2 to row H-2 (same), but
4307        // top_rule's emit path passes through rows that previously
4308        // held body content.
4309        let long: String = std::iter::repeat('中').take(40).collect();
4310        r.render(UiLine::InputPrompt {
4311            buf: long.clone(),
4312            cursor_byte: long.len(),
4313            menu: None,
4314            status: status.clone(),
4315            attachments: Vec::new(),
4316        });
4317        r.flush_deferred();
4318        drain_into_vterm(&buf, &mut vterm);
4319
4320        // bot_rule is always at absolute row H-2 = 22 (0-indexed).
4321        // Input box is now flush-left/right (no PAD_COL) — every col
4322        // 0..w should be '─' on the screen.
4323        let bot_rule_row = 22;
4324        for col in 0..40usize {
4325            let cell = vterm.cell_at(bot_rule_row, col);
4326            assert_eq!(
4327                cell.ch,
4328                '─',
4329                "bot_rule col {} (expected '─') shows {:?}\n\
4330                 full grid dump:\n{}",
4331                col,
4332                cell,
4333                vterm.dump()
4334            );
4335        }
4336    }
4337
4338    /// Wide CJK input via vterm: render "你是谁" from empty, then
4339    /// walk the grid and confirm all three wide glyphs landed on
4340    /// their expected absolute columns. This is the bug class
4341    /// where the cell model and the byte stream disagree — here
4342    /// we assert the terminal's view (post-parse grid) is right.
4343    #[test]
4344    fn retained_wide_char_lands_on_screen_via_vterm() {
4345        let (mut r, buf) = new_capturing(80, 24);
4346        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4347        let status = status_basic();
4348        // Start with empty input (frame baseline).
4349        r.render(UiLine::InputPrompt {
4350            buf: String::new(),
4351            cursor_byte: 0,
4352            menu: None,
4353            status: status.clone(),
4354            attachments: Vec::new(),
4355        });
4356        r.flush_deferred();
4357        drain_into_vterm(&buf, &mut vterm);
4358
4359        // Type "你是谁" in one shot.
4360        r.render(UiLine::InputPrompt {
4361            buf: "你是谁".into(),
4362            cursor_byte: 9,
4363            menu: None,
4364            status: status.clone(),
4365            attachments: Vec::new(),
4366        });
4367        r.flush_deferred();
4368        drain_into_vterm(&buf, &mut vterm);
4369
4370        // Screen h=24, footer 5 rows = [19, 23]:
4371        //   row 19: spinner blank, row 20: top rule,
4372        //   row 21: middle, row 22: bot rule, row 23: status.
4373        // "你是谁" in middle row (col 0-indexed, flush-left now):
4374        //   col 0 '❯', col 1 ' ',
4375        //   col 2 '你' (cols 2-3, right half blank), col 4 '是',
4376        //   col 6 '谁'.
4377        //   (caps_with_color has unicode_symbols=true so prompt_chevron() is "❯ ".)
4378        let middle_row = 21;
4379        assert_eq!(vterm.cell_at(middle_row, 0).ch, '\u{276f}');
4380        assert_eq!(vterm.cell_at(middle_row, 1).ch, ' ');
4381        assert_eq!(
4382            vterm.cell_at(middle_row, 2).ch,
4383            '你',
4384            "dump:\n{}",
4385            vterm.dump()
4386        );
4387        assert_eq!(vterm.cell_at(middle_row, 4).ch, '是');
4388        assert_eq!(vterm.cell_at(middle_row, 6).ch, '谁');
4389    }
4390
4391    /// Menu open via vterm: the slash-command palette (4 rows)
4392    /// must appear on its own rows with the selected item visibly
4393    /// distinct (reverse video). This catches "menu item didn't
4394    /// paint" / "selected highlight is on wrong row" bugs on the
4395    /// actual screen, not just in our cell buffer.
4396    #[test]
4397    fn retained_menu_open_renders_via_vterm() {
4398        let (mut r, buf) = new_capturing(80, 24);
4399        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4400        let status = status_basic();
4401        let items: Vec<(String, String)> = vec![
4402            ("model".into(), "Switch model".into()),
4403            ("provider".into(), "Add provider".into()),
4404            ("session".into(), "New session".into()),
4405            ("resume".into(), "Resume session".into()),
4406        ];
4407
4408        // Baseline: no menu.
4409        r.render(UiLine::InputPrompt {
4410            buf: String::new(),
4411            cursor_byte: 0,
4412            menu: None,
4413            status: status.clone(),
4414            attachments: Vec::new(),
4415        });
4416        r.flush_deferred();
4417        drain_into_vterm(&buf, &mut vterm);
4418
4419        // Open menu with selection on row 0 ('/model').
4420        r.render(UiLine::InputPrompt {
4421            buf: "/".into(),
4422            cursor_byte: 1,
4423            menu: Some(MenuPayload {
4424                items: items.clone(),
4425                selected: 0,
4426                    kind: crate::render::MenuKind::SlashCommand,
4427            }),
4428            status: status.clone(),
4429            attachments: Vec::new(),
4430        });
4431        r.flush_deferred();
4432        drain_into_vterm(&buf, &mut vterm);
4433
4434        // Footer with menu = 1 spinner + 2 rules + 1 middle + 4 menu
4435        // + 1 status = 9 rows. Layout from screen_h=24:
4436        //   row 15: spinner blank
4437        //   row 16: top rule
4438        //   row 17: middle ("  > /")
4439        //   row 18: bot rule
4440        //   rows 19-22: menu rows (selected @ 19)
4441        //   row 23: status
4442        //
4443        // Inspect menu row 0 (row 19): reverse-video strip starting
4444        // from PAD_COL, with "▸" marker present.
4445        let menu0_row = 19;
4446        let row_text = vterm.row_text(menu0_row);
4447        assert!(
4448            row_text.contains("▸"),
4449            "selected marker missing on menu row 0: {:?}\ndump:\n{}",
4450            row_text,
4451            vterm.dump()
4452        );
4453        assert!(
4454            row_text.contains("/model"),
4455            "menu entry text missing: {:?}",
4456            row_text
4457        );
4458        // The marker cell itself should carry reverse-video.
4459        let arrow_col = row_text.find('▸').unwrap();
4460        let cell = vterm.cell_at(menu0_row, arrow_col);
4461        assert!(
4462            cell.reverse,
4463            "selected menu row should be reverse-video at col {} (cell={:?})",
4464            arrow_col, cell
4465        );
4466
4467        // Non-selected row (menu row 1 = screen row 20) must NOT be
4468        // reverse-video.
4469        let row1_text = vterm.row_text(20);
4470        assert!(
4471            row1_text.contains("/provider"),
4472            "menu row 1 missing: {:?}",
4473            row1_text
4474        );
4475        let provider_col = row1_text.find('/').unwrap();
4476        assert!(
4477            !vterm.cell_at(20, provider_col).reverse,
4478            "non-selected menu row should not be reverse-video"
4479        );
4480    }
4481
4482    /// Welcome via vterm: after receiving UiLine::Welcome, the
4483    /// six welcome lines (brand / cwd / model / blank / type hint
4484    /// / provider hint) must all appear on the screen above the
4485    /// footer.
4486    #[test]
4487    fn retained_welcome_lines_render_via_vterm() {
4488        let (mut r, buf) = new_capturing(80, 24);
4489        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4490        let status = status_basic();
4491
4492        r.render(UiLine::Welcome {
4493            model: "glm-5".into(),
4494            working_dir: "~/p/a".into(),
4495        });
4496        // Empty input prompt so the footer has something to paint.
4497        r.render(UiLine::InputPrompt {
4498            buf: String::new(),
4499            cursor_byte: 0,
4500            menu: None,
4501            status: status.clone(),
4502            attachments: Vec::new(),
4503        });
4504        r.flush_deferred();
4505        drain_into_vterm(&buf, &mut vterm);
4506
4507        // Body bottom-anchored: 7 welcome rows (title + cwd + model
4508        // + blank + 3 hint rows) + footer 5 rows on a 24-row screen →
4509        // body occupies rows 12-18, footer 19-23. Verify each
4510        // expected piece exists somewhere in the body region.
4511        let found_brand = (12..=18).any(|r| vterm.row_text(r).contains("AtomCode"));
4512        let found_cwd = (12..=18).any(|r| vterm.row_text(r).contains("~/p/a"));
4513        let found_model = (12..=18).any(|r| vterm.row_text(r).contains("glm-5"));
4514        let found_hint = (12..=18).any(|r| vterm.row_text(r).contains("browse commands"));
4515        assert!(
4516            found_brand && found_cwd && found_model && found_hint,
4517            "welcome rows missing (brand={} cwd={} model={} hint={})\ndump:\n{}",
4518            found_brand,
4519            found_cwd,
4520            found_model,
4521            found_hint,
4522            vterm.dump()
4523        );
4524    }
4525
4526    /// Regression for user report: "Mac resize 后欢迎页的内容丢了".
4527    /// Before this fix, on_resize cleared body_lines so the welcome
4528    /// transcript disappeared. Now body is preserved — resizing
4529    /// smaller may clip content on the right (draw_row truncates
4530    /// at screen.width), but "AtomCode" / cwd / model lines still
4531    /// read. User keeps their chat history across resize.
4532    ///
4533    /// Same issue applies on Windows identically (same code path),
4534    /// so the fix covers both platforms.
4535    #[test]
4536    fn retained_resize_preserves_welcome_via_vterm() {
4537        let (mut r, buf) = new_capturing(80, 24);
4538        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4539        let status = status_basic();
4540
4541        r.render(UiLine::Welcome {
4542            model: "glm-5".into(),
4543            working_dir: "~/p/a".into(),
4544        });
4545        r.render(UiLine::InputPrompt {
4546            buf: String::new(),
4547            cursor_byte: 0,
4548            menu: None,
4549            status: status.clone(),
4550            attachments: Vec::new(),
4551        });
4552        r.flush_deferred();
4553        drain_into_vterm(&buf, &mut vterm);
4554
4555        // Sanity: welcome is visible pre-resize (above footer).
4556        let pre_has = (0..24).any(|r| vterm.row_text(r).contains("AtomCode"));
4557        assert!(
4558            pre_has,
4559            "welcome missing before resize\ndump:\n{}",
4560            vterm.dump()
4561        );
4562
4563        // Resize smaller — welcome must still be on the new grid.
4564        r.on_resize(50, 16);
4565        r.flush_deferred();
4566        let mut vterm = crate::test_term::VirtualTerminal::new(50, 16);
4567        drain_into_vterm(&buf, &mut vterm);
4568
4569        let post_has = (0..16).any(|r| vterm.row_text(r).contains("AtomCode"));
4570        assert!(
4571            post_has,
4572            "welcome disappeared after resize (regression of pre-fix behaviour)\n\
4573             dump:\n{}",
4574            vterm.dump()
4575        );
4576    }
4577
4578    #[test]
4579    fn retained_resize_reflows_welcome_brand_row_when_expanding() {
4580        let (mut r, buf) = new_capturing(40, 18);
4581
4582        r.render(UiLine::Welcome {
4583            model: "glm-5".into(),
4584            working_dir: "~/p/a".into(),
4585        });
4586        r.render(UiLine::InputPrompt {
4587            buf: String::new(),
4588            cursor_byte: 0,
4589            menu: None,
4590            status: status_basic(),
4591            attachments: Vec::new(),
4592        });
4593        r.flush_deferred();
4594        let mut pre = crate::test_term::VirtualTerminal::new(40, 18);
4595        drain_into_vterm(&buf, &mut pre);
4596
4597        r.on_resize(80, 18);
4598        r.flush_deferred();
4599        let mut post = crate::test_term::VirtualTerminal::new(80, 18);
4600        drain_into_vterm(&buf, &mut post);
4601
4602        let brand_row = (0..18)
4603            .map(|row| post.row_text(row))
4604            .find(|row| row.contains("AtomCode"))
4605            .expect("brand row should remain visible after widening");
4606        let atom_idx = brand_row.find("AtomCode").unwrap();
4607        let ver_idx = brand_row
4608            .find(concat!("v", env!("CARGO_PKG_VERSION")))
4609            .unwrap();
4610        let lic_idx = brand_row.find("MIT").unwrap();
4611
4612        assert!(
4613            ver_idx > atom_idx + 20,
4614            "version should move right after widening, row={:?}",
4615            brand_row
4616        );
4617        assert!(
4618            lic_idx > ver_idx,
4619            "license should stay on the same row after widening, row={:?}",
4620            brand_row
4621        );
4622    }
4623
4624    #[test]
4625    fn retained_resize_reflows_welcome_brand_row_when_shrinking() {
4626        let (mut r, buf) = new_capturing(80, 18);
4627
4628        r.render(UiLine::Welcome {
4629            model: "glm-5".into(),
4630            working_dir: "~/p/a".into(),
4631        });
4632        r.render(UiLine::InputPrompt {
4633            buf: String::new(),
4634            cursor_byte: 0,
4635            menu: None,
4636            status: status_basic(),
4637            attachments: Vec::new(),
4638        });
4639        r.flush_deferred();
4640        let mut pre = crate::test_term::VirtualTerminal::new(80, 18);
4641        drain_into_vterm(&buf, &mut pre);
4642
4643        r.on_resize(24, 18);
4644        r.flush_deferred();
4645        let mut post = crate::test_term::VirtualTerminal::new(24, 18);
4646        drain_into_vterm(&buf, &mut post);
4647
4648        let brand_row = (0..18)
4649            .map(|row| post.row_text(row))
4650            .find(|row| row.contains("AtomCode"))
4651            .expect("brand row should remain visible after shrinking");
4652        let version_row = (0..18)
4653            .map(|row| post.row_text(row))
4654            .find(|row| row.contains(concat!("v", env!("CARGO_PKG_VERSION"))))
4655            .expect("version row should remain visible after shrinking");
4656        assert!(
4657            version_row.contains(concat!("v", env!("CARGO_PKG_VERSION"))),
4658            "version should remain visible after shrinking, brand_row={:?}, version_row={:?}",
4659            brand_row,
4660            version_row
4661        );
4662        assert!(
4663            version_row.contains("MIT"),
4664            "license should remain visible after shrinking, brand_row={:?}, version_row={:?}",
4665            brand_row,
4666            version_row
4667        );
4668    }
4669
4670    /// Regression: after a resize-smaller drag, cached `body_lines` rows
4671    /// built against the OLD terminal width were re-emitted verbatim. Rows
4672    /// wider than the new width triggered the real terminal's auto-wrap;
4673    /// the wrapped tail spilled into footer / scroll-region rows, producing
4674    /// the visible "everything shifted and the footer has garbage in it"
4675    /// glitch users reported after dragging the window narrower.
4676    ///
4677    /// `VirtualTerminal::put_char` silently drops cells past the grid's
4678    /// right edge (no auto-wrap modelled), so we can't observe the bug
4679    /// at the grid level. Assert on the emitted byte stream instead:
4680    /// between any two cursor-positioning CSIs, the printable payload
4681    /// must fit within the new `screen.width()`.
4682    #[test]
4683    fn retained_resize_clips_wide_body_rows_to_new_width() {
4684        let (mut r, buf) = new_capturing(120, 24);
4685
4686        // Seed body with a long tool call: a `▸ Name(payload)` row whose
4687        // display width far exceeds any sane "shrink-to" target.
4688        r.render(UiLine::ToolCall {
4689            name: "Bash".into(),
4690            detail: "X".repeat(100),
4691        });
4692        r.render(UiLine::InputPrompt {
4693            buf: String::new(),
4694            cursor_byte: 0,
4695            menu: None,
4696            status: status_basic(),
4697            attachments: Vec::new(),
4698        });
4699        r.flush_deferred();
4700        // Discard pre-resize bytes — this test only asserts on what
4701        // `on_resize` emits at the narrower width.
4702        buf.lock().unwrap().clear();
4703
4704        let new_w: u16 = 40;
4705        r.on_resize(new_w, 16);
4706
4707        // Parse the emitted stream: CSI sequences delimit "runs" of
4708        // printable bytes. Every run must fit within the new width.
4709        // `\n` also delimits (emit_body_line_inner uses raw LF to scroll
4710        // the DECSTBM region).
4711        let bytes = buf.lock().unwrap().clone();
4712        let text = String::from_utf8_lossy(&bytes);
4713        let mut runs: Vec<String> = vec![String::new()];
4714        let mut chars = text.chars().peekable();
4715        while let Some(c) = chars.next() {
4716            if c == '\x1b' {
4717                // CSI / ESC dispatch — eat until the final byte. The
4718                // final byte delimits the current run from the next.
4719                runs.push(String::new());
4720                if chars.peek() == Some(&'[') {
4721                    chars.next();
4722                    while let Some(&p) = chars.peek() {
4723                        chars.next();
4724                        if p.is_ascii_alphabetic() || p == '~' {
4725                            break;
4726                        }
4727                    }
4728                } else if chars.peek() == Some(&']') {
4729                    // OSC — eat until ST (BEL or ESC\)
4730                    while let Some(&p) = chars.peek() {
4731                        chars.next();
4732                        if p == '\x07' {
4733                            break;
4734                        }
4735                    }
4736                }
4737                continue;
4738            }
4739            if c == '\n' || c == '\r' {
4740                runs.push(String::new());
4741                continue;
4742            }
4743            runs.last_mut().unwrap().push(c);
4744        }
4745
4746        for run in &runs {
4747            let w = crate::width::display_width(run);
4748            assert!(
4749                w <= new_w as usize,
4750                "body re-emit produced a {}-col run on a {}-col terminal: {:?}\n\
4751                 (clip_cells_to_width should have trimmed this before emit)",
4752                w,
4753                new_w,
4754                run,
4755            );
4756        }
4757    }
4758
4759    #[test]
4760    fn retained_welcome_reflows_path_model_and_hints_on_narrow_terminal() {
4761        // 22-col WIDTH is the test's actual subject (column reflow).
4762        // Use 26-row HEIGHT — large enough that the reflowed banner
4763        // (title × 2 + path × 4 + model × 2 + blank + hint_a × 3 +
4764        // hint_b × 2 + hint_c × 3 = 17 body rows, plus 4 footer rows)
4765        // fits entirely in the viewport with headroom. With a 20-row
4766        // viewport the brand line scrolled into scrollback and made the
4767        // assertion brittle to small additions to the hint block.
4768        let (mut r, buf) = new_capturing(22, 26);
4769        let mut vterm = crate::test_term::VirtualTerminal::new(22, 26);
4770
4771        r.render(UiLine::Welcome {
4772            model: "MiniMax-M2.7-long".into(),
4773            working_dir: "~/workspace/gitcode_project/atomcode_family/atomcode".into(),
4774        });
4775        r.render(UiLine::InputPrompt {
4776            buf: String::new(),
4777            cursor_byte: 0,
4778            menu: None,
4779            status: status_basic(),
4780            attachments: Vec::new(),
4781        });
4782        r.flush_deferred();
4783        drain_into_vterm(&buf, &mut vterm);
4784
4785        assert!(
4786            (0..26).any(|row| vterm.row_text(row).contains("AtomCode")),
4787            "brand missing on narrow terminal\n{}",
4788            vterm.dump()
4789        );
4790        assert!(
4791            (0..26).any(|row| vterm.row_text(row).contains("workspace")),
4792            "path should wrap instead of disappearing on narrow terminal\n{}",
4793            vterm.dump()
4794        );
4795        assert!(
4796            (0..26).any(|row| vterm.row_text(row).contains("MiniMax")),
4797            "model should wrap instead of disappearing on narrow terminal\n{}",
4798            vterm.dump()
4799        );
4800        assert!(
4801            (0..26).any(|row| vterm.row_text(row).contains("type something")),
4802            "welcome input hint should remain visible on narrow terminal\n{}",
4803            vterm.dump()
4804        );
4805        assert!(
4806            (0..26).any(|row| vterm.row_text(row).contains("commands")),
4807            "welcome commands hint should remain visible on narrow terminal\n{}",
4808            vterm.dump()
4809        );
4810        assert!(
4811            (0..26).any(|row| vterm.row_text(row).contains("/provider")),
4812            "provider hint should remain visible on narrow terminal\n{}",
4813            vterm.dump()
4814        );
4815    }
4816
4817    /// User echo: `UiLine::User("hi")` produces a body row with
4818    /// `> hi` accent prefix + a blank spacer. Grid-verified at
4819    /// absolute rows right above the footer (body bottom-anchored).
4820    #[test]
4821    fn retained_user_echo_renders_via_vterm() {
4822        let (mut r, buf) = new_capturing(80, 24);
4823        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4824        let status = status_basic();
4825        r.render(UiLine::User("你好 world".into()));
4826        r.render(UiLine::InputPrompt {
4827            buf: String::new(),
4828            cursor_byte: 0,
4829            menu: None,
4830            status: status.clone(),
4831            attachments: Vec::new(),
4832        });
4833        r.flush_deferred();
4834        drain_into_vterm(&buf, &mut vterm);
4835        // User line + blank spacer = 2 body rows somewhere in the
4836        // body area (scrollback-push layout is stack-like, exact
4837        // row depends on how many rows have been pushed).
4838        // Prompt glyph depends on caps.unicode_symbols; caps_with_color
4839        // is UTF-8 + non-dumb so `prompt_chevron()` returns `❯ `.
4840        let found = vterm.any_row(|row| {
4841            row.contains('\u{276f}')
4842                && row.contains('你')
4843                && row.contains('好')
4844                && row.contains("world")
4845        });
4846        assert!(found, "user echo missing\ndump:\n{}", vterm.dump());
4847    }
4848
4849    /// User-echo chevron must sit at col 0 — the same column as the
4850    /// input-box chevron below — so history symbols align with the
4851    /// live prompt.
4852    #[test]
4853    fn retained_user_echo_chevron_at_col_0() {
4854        let (mut r, buf) = new_capturing(80, 24);
4855        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4856        let status = status_basic();
4857        r.render(UiLine::User("hello".into()));
4858        r.render(UiLine::InputPrompt {
4859            buf: String::new(),
4860            cursor_byte: 0,
4861            menu: None,
4862            status: status.clone(),
4863            attachments: Vec::new(),
4864        });
4865        r.flush_deferred();
4866        drain_into_vterm(&buf, &mut vterm);
4867
4868        let row_idx = (0..vterm.height() as usize)
4869            .find(|&i| {
4870                vterm.row_text(i).contains('\u{276f}') && vterm.row_text(i).contains("hello")
4871            })
4872            .unwrap_or_else(|| panic!("user echo row missing\ndump:\n{}", vterm.dump()));
4873        assert_eq!(
4874            vterm.cell_at(row_idx, 0).ch,
4875            '\u{276f}',
4876            "user-echo chevron must land at col 0, got row: {:?}\ndump:\n{}",
4877            vterm.row_text(row_idx),
4878            vterm.dump()
4879        );
4880    }
4881
4882    /// ToolCall: `● name(detail)` formatted. Grid-verifies the
4883    /// marker + name + parens appear together on one row.
4884    #[test]
4885    fn retained_tool_call_renders_via_vterm() {
4886        let (mut r, buf) = new_capturing(80, 24);
4887        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4888        let status = status_basic();
4889        r.render(UiLine::ToolCall {
4890            name: "bash".into(),
4891            detail: "ls -la".into(),
4892        });
4893        r.render(UiLine::InputPrompt {
4894            buf: String::new(),
4895            cursor_byte: 0,
4896            menu: None,
4897            status: status.clone(),
4898            attachments: Vec::new(),
4899        });
4900        r.flush_deferred();
4901        drain_into_vterm(&buf, &mut vterm);
4902        let found = vterm
4903            .any_row(|row| row.contains("●") && row.contains("bash") && row.contains("ls -la"));
4904        assert!(found, "tool call missing\ndump:\n{}", vterm.dump());
4905    }
4906
4907    /// ToolCall glyph `●` must sit at col 0, same baseline as user
4908    /// echo and input chevron.
4909    #[test]
4910    fn retained_tool_call_arrow_at_col_0() {
4911        let (mut r, buf) = new_capturing(80, 24);
4912        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4913        let status = status_basic();
4914        r.render(UiLine::ToolCall {
4915            name: "bash".into(),
4916            detail: "ls -la".into(),
4917        });
4918        r.render(UiLine::InputPrompt {
4919            buf: String::new(),
4920            cursor_byte: 0,
4921            menu: None,
4922            status: status.clone(),
4923            attachments: Vec::new(),
4924        });
4925        r.flush_deferred();
4926        drain_into_vterm(&buf, &mut vterm);
4927
4928        let row_idx = (0..vterm.height() as usize)
4929            .find(|&i| vterm.row_text(i).contains("●") && vterm.row_text(i).contains("bash"))
4930            .unwrap_or_else(|| panic!("tool call row missing\ndump:\n{}", vterm.dump()));
4931        assert_eq!(
4932            vterm.cell_at(row_idx, 0).ch,
4933            '●',
4934            "tool-call glyph must land at col 0, got row: {:?}\ndump:\n{}",
4935            vterm.row_text(row_idx),
4936            vterm.dump()
4937        );
4938    }
4939
4940    /// ToolResult success: `⎿ summary` + blank spacer; failure
4941    /// prepends `✗ `. We test success path here; the error styling
4942    /// (Role::Error red) is a cell-style detail not asserted in
4943    /// this grid check.
4944    #[test]
4945    fn retained_tool_result_renders_via_vterm() {
4946        let (mut r, buf) = new_capturing(80, 24);
4947        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4948        let status = status_basic();
4949        r.render(UiLine::ToolResult {
4950            success: true,
4951            summary: "3 files changed".into(),
4952        });
4953        r.render(UiLine::InputPrompt {
4954            buf: String::new(),
4955            cursor_byte: 0,
4956            menu: None,
4957            status: status.clone(),
4958            attachments: Vec::new(),
4959        });
4960        r.flush_deferred();
4961        drain_into_vterm(&buf, &mut vterm);
4962        let found = vterm.any_row(|row| row.contains("└") && row.contains("3 files changed"));
4963        assert!(found, "tool result missing\ndump:\n{}", vterm.dump());
4964    }
4965
4966    /// ToolResult `⎿` glyph sits at col 2 — directly under the tool
4967    /// name's leading character (a `▸ Bash(...)` row puts `▸` at col 0
4968    /// and `B` at col 2, so the result body's `⎿` aligns vertically
4969    /// with the `B`). Matches Claude Code's tool-result layout
4970    /// (screenshot 46) and reads tighter than the previous 4-space
4971    /// indent which left `⎿` floating two columns past the tool name.
4972    #[test]
4973    fn retained_tool_result_arrow_at_col_2() {
4974        let (mut r, buf) = new_capturing(80, 24);
4975        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
4976        let status = status_basic();
4977        r.render(UiLine::ToolResult {
4978            success: true,
4979            summary: "3 files changed".into(),
4980        });
4981        r.render(UiLine::InputPrompt {
4982            buf: String::new(),
4983            cursor_byte: 0,
4984            menu: None,
4985            status: status.clone(),
4986            attachments: Vec::new(),
4987        });
4988        r.flush_deferred();
4989        drain_into_vterm(&buf, &mut vterm);
4990
4991        let row_idx = (0..vterm.height() as usize)
4992            .find(|&i| vterm.row_text(i).contains("└") && vterm.row_text(i).contains("3 files"))
4993            .unwrap_or_else(|| panic!("tool result row missing\ndump:\n{}", vterm.dump()));
4994        assert_eq!(
4995            vterm.cell_at(row_idx, 2).ch,
4996            '└',
4997            "tool-result glyph must land at col 2, got row: {:?}\ndump:\n{}",
4998            vterm.row_text(row_idx),
4999            vterm.dump()
5000        );
5001        for c in 0..2 {
5002            assert_eq!(
5003                vterm.cell_at(row_idx, c).ch,
5004                ' ',
5005                "cols 0..2 before ⎿ must be blank, col {} is {:?}",
5006                c,
5007                vterm.cell_at(row_idx, c).ch,
5008            );
5009        }
5010    }
5011
5012    /// End-to-end alignment pin: the `⎿` glyph of a `ToolResult` must
5013    /// land in the same column as the first character of the tool
5014    /// name in the `▸ Tool(...)` row directly above it. Catches future
5015    /// drift in either the tool-call prefix (`"▸ "`) or the result
5016    /// prefix (`"  ⎿ "`) — they have to stay coupled or the visual
5017    /// "tool name ↔ ⎿ (its result)" anchor breaks.
5018    ///
5019    /// Iterates over a representative cross-section of tool types
5020    /// (Bash, Grep, Glob, ReadFile, EditFile) — the result-row prefix
5021    /// is dispatched from a single generic `UiLine::ToolResult` arm,
5022    /// not branched on tool name, so any drift would surface here for
5023    /// every tool simultaneously. Test names that are NOT verified
5024    /// here (e.g. WriteFile, SearchReplace, TraceCallers) all share
5025    /// the same code path — covering the cross-section is enough to
5026    /// prove universality.
5027    #[test]
5028    fn retained_tool_result_arrow_aligns_for_every_tool_type() {
5029        // Each entry: tool name + a sample summary. The first
5030        // character of `name` is the alignment anchor on the tool-call
5031        // row; the `⎿` on the result row must sit in the same column.
5032        let cases: &[(&str, &str)] = &[
5033            ("Bash", "[elapsed: 0.0s, exit: 0] (1 line)"),
5034            ("Grep", "203 matches in 18 files"),
5035            ("Glob", "12 files found:"),
5036            ("ReadFile", "1| use anyhow::Result;"),
5037            ("EditFile", "Edited /tmp/foo.rs (3 lines changed)"),
5038        ];
5039
5040        for (tool_name, summary) in cases {
5041            let (mut r, buf) = new_capturing(120, 24);
5042            let mut vterm = crate::test_term::VirtualTerminal::new(120, 24);
5043            let status = status_basic();
5044            r.render(UiLine::ToolCall {
5045                name: (*tool_name).into(),
5046                detail: "args".into(),
5047            });
5048            r.render(UiLine::ToolResult {
5049                success: true,
5050                summary: (*summary).into(),
5051            });
5052            r.render(UiLine::InputPrompt {
5053                buf: String::new(),
5054                cursor_byte: 0,
5055                menu: None,
5056                status: status.clone(),
5057                attachments: Vec::new(),
5058            });
5059            r.flush_deferred();
5060            drain_into_vterm(&buf, &mut vterm);
5061
5062            let tool_row = (0..vterm.height() as usize)
5063                .find(|&i| {
5064                    vterm.row_text(i).contains("●") && vterm.row_text(i).contains(tool_name)
5065                })
5066                .unwrap_or_else(|| {
5067                    panic!("[{tool_name}] tool call row missing\ndump:\n{}", vterm.dump())
5068                });
5069            let result_row = (0..vterm.height() as usize)
5070                .find(|&i| vterm.row_text(i).contains("└"))
5071                .unwrap_or_else(|| {
5072                    panic!("[{tool_name}] tool result row missing\ndump:\n{}", vterm.dump())
5073                });
5074
5075            let first_char = tool_name.chars().next().unwrap();
5076            let name_col = (0..vterm.width() as usize)
5077                .find(|&c| vterm.cell_at(tool_row, c).ch == first_char)
5078                .unwrap_or_else(|| {
5079                    panic!(
5080                        "[{tool_name}] first char {first_char:?} not found on tool row: {:?}",
5081                        vterm.row_text(tool_row)
5082                    )
5083                });
5084            let arrow_col = (0..vterm.width() as usize)
5085                .find(|&c| vterm.cell_at(result_row, c).ch == '└')
5086                .unwrap_or_else(|| {
5087                    panic!(
5088                        "[{tool_name}] '└' not found on result row: {:?}",
5089                        vterm.row_text(result_row)
5090                    )
5091                });
5092            assert_eq!(
5093                arrow_col, name_col,
5094                "[{tool_name}] result '└' col {} must match tool name {:?} col {} \
5095                 (tool row: {:?}, result row: {:?})",
5096                arrow_col,
5097                first_char,
5098                name_col,
5099                vterm.row_text(tool_row),
5100                vterm.row_text(result_row),
5101            );
5102        }
5103    }
5104
5105    /// Failure ToolResult: header line is bold red (so users still get
5106    /// the "this is bad" signal) but continuation lines fall back to
5107    /// default fg (so quoted code in error messages — common with
5108    /// edit_file's "old_string not found" path — doesn't blend visually
5109    /// with diff-remove blocks. See retained.rs UiLine::ToolResult arm.
5110    #[test]
5111    fn retained_tool_result_failure_header_red_body_default() {
5112        let (mut r, buf) = new_capturing(120, 24);
5113        let mut vterm = crate::test_term::VirtualTerminal::new(120, 24);
5114        let status = status_basic();
5115        // Multi-line failure body: header + quoted-code detail.
5116        r.render(UiLine::ToolResult {
5117            success: false,
5118            summary: "old_string not found in foo.rs\n759| line content\n760| more code".into(),
5119        });
5120        r.render(UiLine::InputPrompt {
5121            buf: String::new(),
5122            cursor_byte: 0,
5123            menu: None,
5124            status: status.clone(),
5125            attachments: Vec::new(),
5126        });
5127        r.flush_deferred();
5128        drain_into_vterm(&buf, &mut vterm);
5129
5130        // Header row: contains the ✗ glyph, cells must be bold + red.
5131        let header_idx = (0..vterm.height() as usize)
5132            .find(|&i| vterm.row_text(i).contains("✗") && vterm.row_text(i).contains("not found"))
5133            .unwrap_or_else(|| panic!("header row missing\ndump:\n{}", vterm.dump()));
5134        let header_text = vterm.row_text(header_idx);
5135        let glyph_col = header_text.find('✗').unwrap();
5136        let header_cell = vterm.cell_at(header_idx, glyph_col);
5137        assert_eq!(
5138            header_cell.fg,
5139            Some(crossterm::style::Color::Red),
5140            "header `✗` must be red, got {:?}",
5141            header_cell,
5142        );
5143        assert!(
5144            header_cell.bold,
5145            "header `✗` must be bold, got {:?}",
5146            header_cell,
5147        );
5148
5149        // Continuation row: contains the quoted code "759|"; must NOT
5150        // be red (so it stops looking like a diff-remove block).
5151        let cont_idx = (0..vterm.height() as usize)
5152            .find(|&i| vterm.row_text(i).contains("759|"))
5153            .unwrap_or_else(|| panic!("continuation row missing\ndump:\n{}", vterm.dump()));
5154        let cont_text = vterm.row_text(cont_idx);
5155        let digit_col = cont_text.find("759|").unwrap();
5156        let cont_cell = vterm.cell_at(cont_idx, digit_col);
5157        assert_ne!(
5158            cont_cell.fg,
5159            Some(crossterm::style::Color::Red),
5160            "continuation row must NOT be red (would alias visually with diff-remove): {:?}",
5161            cont_cell,
5162        );
5163    }
5164
5165    /// DiffBlock: multiple added/removed lines, each with its own
5166    /// marker. Grid-verifies `+` and `-` both appear in the
5167    /// respective rows at the correct indent (7-space prefix).
5168    #[test]
5169    fn retained_diff_block_renders_via_vterm() {
5170        let (mut r, buf) = new_capturing(80, 24);
5171        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5172        let status = status_basic();
5173        r.render(UiLine::DiffBlock(vec![
5174            super::super::DiffEntry {
5175                added: true,
5176                text: "new line".into(),
5177            },
5178            super::super::DiffEntry {
5179                added: false,
5180                text: "old line".into(),
5181            },
5182        ]));
5183        r.render(UiLine::InputPrompt {
5184            buf: String::new(),
5185            cursor_byte: 0,
5186            menu: None,
5187            status: status.clone(),
5188            attachments: Vec::new(),
5189        });
5190        r.flush_deferred();
5191        drain_into_vterm(&buf, &mut vterm);
5192        let has_added = vterm.any_row(|r| r.contains("+") && r.contains("new line"));
5193        let has_removed = vterm.any_row(|r| r.contains("-") && r.contains("old line"));
5194        assert!(has_added, "added row missing\ndump:\n{}", vterm.dump());
5195        assert!(has_removed, "removed row missing\ndump:\n{}", vterm.dump());
5196    }
5197
5198    /// TurnSeparator: blank + `──── Label ────` + blank. The rule
5199    /// spans the full content width with the label centred.
5200    #[test]
5201    fn retained_turn_separator_renders_via_vterm() {
5202        let (mut r, buf) = new_capturing(80, 24);
5203        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5204        let status = status_basic();
5205        r.render(UiLine::TurnSeparator {
5206            label: "Sealed · 1 turn".into(),
5207        });
5208        r.render(UiLine::InputPrompt {
5209            buf: String::new(),
5210            cursor_byte: 0,
5211            menu: None,
5212            status: status.clone(),
5213            attachments: Vec::new(),
5214        });
5215        r.flush_deferred();
5216        drain_into_vterm(&buf, &mut vterm);
5217        let found = vterm
5218            .any_row(|row| row.contains("─") && row.contains("Sealed") && row.contains("1 turn"));
5219        assert!(found, "separator missing\ndump:\n{}", vterm.dump());
5220    }
5221
5222    /// Error line: `[Error: msg]` body row with red fg — we assert
5223    /// the text + the fg style on the '[' cell.
5224    #[test]
5225    fn retained_error_line_renders_via_vterm() {
5226        let (mut r, buf) = new_capturing(80, 24);
5227        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5228        let status = status_basic();
5229        r.render(UiLine::Error("connection lost".into()));
5230        r.render(UiLine::InputPrompt {
5231            buf: String::new(),
5232            cursor_byte: 0,
5233            menu: None,
5234            status: status.clone(),
5235            attachments: Vec::new(),
5236        });
5237        r.flush_deferred();
5238        drain_into_vterm(&buf, &mut vterm);
5239        // Find the row containing the error payload (layout-agnostic).
5240        let row_idx = (0..vterm.height() as usize)
5241            .find(|&r| {
5242                let t = vterm.row_text(r);
5243                t.contains("[Error:") && t.contains("connection lost")
5244            })
5245            .unwrap_or_else(|| panic!("error message missing\ndump:\n{}", vterm.dump()));
5246        let row_text = vterm.row_text(row_idx);
5247        let idx = row_text.find('[').unwrap();
5248        let cell = vterm.cell_at(row_idx, idx);
5249        assert!(
5250            cell.fg.is_some(),
5251            "error text should have a foreground color"
5252        );
5253    }
5254
5255    /// Regression (screenshot 47.png): adjacent bash blocks with NO
5256    /// blank line between them — the previous fix (screenshot 44)
5257    /// over-corrected by stripping the trailing `\n` from the Ctrl+O
5258    /// hint, removing the breathing-row separator. The `\n` IS
5259    /// load-bearing: callers append it to mean "give me one blank row
5260    /// after this for visual separation." Internal `\n`s split into
5261    /// multiple rows; a trailing `\n` adds a single blank tail row.
5262    #[test]
5263    fn retained_command_output_trailing_newline_pushes_blank_separator() {
5264        let (mut r, _buf) = new_capturing(80, 24);
5265        let before = r.body_lines.len();
5266        r.render(UiLine::CommandOutput(
5267            "  ○ Press Ctrl+O to show real-time output\n".into(),
5268        ));
5269        let pushed = r.body_lines.len() - before;
5270        assert_eq!(
5271            pushed, 2,
5272            "trailing \\n must push 1 content row + 1 blank separator — \
5273             expected 2 rows, got {}. Adjacent bash blocks rely on this \
5274             blank to visually break apart in scrollback.",
5275            pushed
5276        );
5277
5278        // Confirm the second row is actually blank (whitespace only),
5279        // so future drift in `wrap_line_to_width` for `""` would still
5280        // be caught here.
5281        let last = r.body_lines.last().unwrap();
5282        assert!(
5283            last.iter().all(|c| c.ch == ' '),
5284            "second row must be whitespace-only, got: {:?}",
5285            last.iter().map(|c| c.ch).collect::<String>()
5286        );
5287    }
5288
5289    /// Internal `\n`s split into rows (existing invariant — separate
5290    /// from the trailing-`\n` behavior above): `"a\nb\nc"` is three
5291    /// content rows, `"a\nb\nc\n"` is three content rows + one blank
5292    /// tail row.
5293    #[test]
5294    fn retained_command_output_internal_newlines_split_into_rows() {
5295        let (mut r, _buf) = new_capturing(80, 24);
5296        let before = r.body_lines.len();
5297        r.render(UiLine::CommandOutput("line one\nline two\nline three".into()));
5298        let pushed = r.body_lines.len() - before;
5299        assert_eq!(
5300            pushed, 3,
5301            "three internal lines, no trailing \\n → 3 rows, got {}",
5302            pushed
5303        );
5304
5305        // Trailing `\n` adds one blank to the existing three lines.
5306        let before = r.body_lines.len();
5307        r.render(UiLine::CommandOutput("a\nb\nc\n".into()));
5308        let pushed = r.body_lines.len() - before;
5309        assert_eq!(
5310            pushed, 4,
5311            "three internal lines + trailing \\n → 4 rows (3 content + 1 blank), got {}",
5312            pushed
5313        );
5314    }
5315
5316    /// CommandOutput: `/command` return string rendered as body.
5317    /// Used by /model, /login, /provider etc. to echo status lines.
5318    #[test]
5319    fn retained_command_output_renders_via_vterm() {
5320        let (mut r, buf) = new_capturing(80, 24);
5321        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5322        let status = status_basic();
5323        r.render(UiLine::CommandOutput(
5324            "Switched to glm5 · Pro/zai-org/GLM-5".into(),
5325        ));
5326        r.render(UiLine::InputPrompt {
5327            buf: String::new(),
5328            cursor_byte: 0,
5329            menu: None,
5330            status: status.clone(),
5331            attachments: Vec::new(),
5332        });
5333        r.flush_deferred();
5334        drain_into_vterm(&buf, &mut vterm);
5335        let found = vterm.any_row(|row| row.contains("Switched to glm5"));
5336        assert!(found, "command output missing\ndump:\n{}", vterm.dump());
5337    }
5338
5339    /// After moving ▶ to col 0, `pop_approval_prompt` must still
5340    /// detect the approval rows via col 0 and must NOT be fooled by
5341    /// an adjacent ● tool-call row (also at col 0, different glyph).
5342    /// In an 80-col terminal the label + chips fit on one line, so
5343    /// pop_approval_prompt removes a single row.
5344    #[test]
5345    fn retained_approval_pop_still_detects_glyph() {
5346        let (mut r, _buf) = new_capturing(80, 24);
5347
5348        r.render(UiLine::ToolCall {
5349            name: "bash".into(),
5350            detail: "ls".into(),
5351        });
5352        r.render(UiLine::ApprovalPrompt {
5353            tool: "bash".into(),
5354            detail: "ls".into(),
5355        });
5356        let before = r.body_lines.len();
5357        r.pop_approval_prompt();
5358        let after = r.body_lines.len();
5359        assert_eq!(
5360            before - after,
5361            1,
5362            "pop_approval_prompt should drop the single label+chips row"
5363        );
5364
5365        // Second call: last row is now the tool-call `●`, not `▶`.
5366        // Must be a no-op.
5367        let before2 = r.body_lines.len();
5368        r.pop_approval_prompt();
5369        let after2 = r.body_lines.len();
5370        assert_eq!(
5371            before2, after2,
5372            "pop_approval_prompt must not drop non-approval rows"
5373        );
5374    }
5375
5376    /// When the approval label wraps across multiple lines (narrow
5377    /// terminal), pop_approval_prompt must remove ALL of them: the
5378    /// wrapped label rows + the chips row.
5379    #[test]
5380    fn retained_approval_pop_multiline() {
5381        // 30-col terminal: "▶ 等待审批:Bash(a very long command)"
5382        // should wrap the label, producing 2+ label rows + 1 chips row.
5383        let (mut r, _buf) = new_capturing(30, 24);
5384
5385        r.render(UiLine::ToolCall {
5386            name: "bash".into(),
5387            detail: "a very long command".into(),
5388        });
5389        r.render(UiLine::ApprovalPrompt {
5390            tool: "bash".into(),
5391            detail: "a very long command".into(),
5392        });
5393        let before = r.body_lines.len();
5394        r.pop_approval_prompt();
5395        let after = r.body_lines.len();
5396        // Should pop at least the chips row + the ▶ header row.
5397        // If the label wrapped, it pops even more.
5398        assert!(
5399            before - after >= 2,
5400            "pop_approval_prompt should drop at least 2 rows (label + chips), got {}",
5401            before - after
5402        );
5403
5404        // Second call: no more approval rows — must be a no-op.
5405        let before2 = r.body_lines.len();
5406        r.pop_approval_prompt();
5407        let after2 = r.body_lines.len();
5408        assert_eq!(
5409            before2, after2,
5410            "pop_approval_prompt must not drop non-approval rows"
5411        );
5412    }
5413
5414    /// Regression: when the user approves a tool (presses Y/A/N),
5415    /// `pop_approval_prompt` must NOT erase the footer (input box,
5416    /// top/bot rules, status bar) from the terminal. Earlier versions
5417    /// used `\x1b[J` from `body_bottom;1` which erased to end-of-screen
5418    /// — i.e. through the footer — and the cell-diff cache then prevented
5419    /// the footer from being redrawn (cells unchanged → no diff →
5420    /// no emit), leaving the user with no visible input prompt.
5421    #[test]
5422    fn retained_pop_approval_preserves_footer() {
5423        let (mut r, buf) = new_capturing(80, 24);
5424        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5425        let status = status_basic();
5426
5427        // Paint a full frame with an active footer (status bar visible).
5428        r.render(UiLine::InputPrompt {
5429            buf: String::new(),
5430            cursor_byte: 0,
5431            menu: None,
5432            status: status.clone(),
5433            attachments: Vec::new(),
5434        });
5435        r.flush_deferred();
5436        drain_into_vterm(&buf, &mut vterm);
5437        // Confirm baseline: status row visible.
5438        assert!(
5439            vterm.any_row(|row| row.contains("glm-5")),
5440            "baseline: status row should be on screen\ndump:\n{}",
5441            vterm.dump()
5442        );
5443
5444        // Now render an approval prompt and pop it.
5445        r.render(UiLine::ToolCall {
5446            name: "bash".into(),
5447            detail: "ls".into(),
5448        });
5449        r.render(UiLine::ApprovalPrompt {
5450            tool: "bash".into(),
5451            detail: "ls".into(),
5452        });
5453        r.flush_deferred();
5454        drain_into_vterm(&buf, &mut vterm);
5455
5456        r.pop_approval_prompt();
5457        // Trigger a new paint cycle (mirrors what happens after the
5458        // user presses Y and the agent emits the next body event).
5459        r.render(UiLine::InputPrompt {
5460            buf: String::new(),
5461            cursor_byte: 0,
5462            menu: None,
5463            status: status.clone(),
5464            attachments: Vec::new(),
5465        });
5466        r.flush_deferred();
5467        drain_into_vterm(&buf, &mut vterm);
5468
5469        // Footer (status bar) must still be visible. Before the fix
5470        // this assertion failed: pop_approval_prompt's `\x1b[J`
5471        // erased the status row, and the diff cache stopped paint_footer
5472        // from re-emitting it.
5473        assert!(
5474            vterm.any_row(|row| row.contains("glm-5")),
5475            "input box / status row should still be on screen after \
5476             approval pop\ndump:\n{}",
5477            vterm.dump()
5478        );
5479    }
5480
5481    /// StreamingBox / Spinner: the `frame + label` pair now lives in
5482    /// the BODY (not the footer) as an animated "live" row at
5483    /// body_bottom. The emoji/frame is flush-left at col 0 — same
5484    /// gutter as `▸` tool calls and `❯` user echoes — because the
5485    /// previous footer position (col 2, inside PAD_COL margin) left
5486    /// it visually misaligned with surrounding body paragraphs.
5487    #[test]
5488    fn retained_spinner_renders_as_body_row_flush_left() {
5489        let (mut r, buf) = new_capturing(80, 24);
5490        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5491        let status = status_basic();
5492        r.render(UiLine::StreamingBox {
5493            buf: String::new(),
5494            cursor_byte: 0,
5495            frame: "⠋",
5496            label: "Thinking".into(),
5497            status: status.clone(),
5498            menu: None,
5499            attachments: Vec::new(),
5500        });
5501        r.flush_deferred();
5502        drain_into_vterm(&buf, &mut vterm);
5503
5504        // Spinner must appear on the LAST body row (just above the
5505        // footer's top_rule), with the frame at col 0.
5506        // Footer with 4 rows on h=24 → top_rule at row 20 (0-idx),
5507        // so last body row = 0-idx row 19.
5508        let spinner_row = vterm.row_text(19);
5509        assert!(
5510            spinner_row.contains("⠋") && spinner_row.contains("Thinking"),
5511            "spinner not found on last body row (got {:?}):\n{}",
5512            spinner_row,
5513            vterm.dump()
5514        );
5515        // Frame glyph at absolute col 0 — flush-left with body paragraphs.
5516        assert_eq!(
5517            vterm.cell_at(19, 0).ch,
5518            '⠋',
5519            "expected frame at col 0, found {:?}:\n{}",
5520            vterm.cell_at(19, 0).ch,
5521            vterm.dump()
5522        );
5523
5524        // Footer no longer hosts the spinner — the row right above
5525        // top_rule (which USED to be the spinner slot) must be empty
5526        // of any spinner glyphs. With the new footer geometry
5527        // (4 rows: top_rule / middle / bot_rule / status on h=24),
5528        // row 20 is top_rule and the ex-spinner slot no longer exists.
5529        let top_rule_row = vterm.row_text(20);
5530        assert!(
5531            !top_rule_row.contains("Thinking"),
5532            "footer row still carries spinner label: {:?}:\n{}",
5533            top_rule_row,
5534            vterm.dump()
5535        );
5536    }
5537
5538    /// Consecutive Spinner ticks must UPDATE the same body row
5539    /// in-place (animation), not push a new row each tick — otherwise
5540    /// 100ms of animation at 80ms/frame would accumulate 1 row per
5541    /// frame and scroll the user's actual history off-screen in
5542    /// seconds.
5543    #[test]
5544    fn retained_consecutive_spinner_ticks_update_same_body_row() {
5545        let (mut r, _buf) = new_capturing(80, 24);
5546        let status = status_basic();
5547        r.render(UiLine::StreamingBox {
5548            buf: String::new(),
5549            cursor_byte: 0,
5550            frame: "⠋",
5551            label: "Thinking".into(),
5552            status: status.clone(),
5553            menu: None,
5554            attachments: Vec::new(),
5555        });
5556        let after_first = r.body_lines.len();
5557        assert!(
5558            after_first >= 1,
5559            "spinner event must push at least 1 body row (got {})",
5560            after_first
5561        );
5562
5563        // 9 more spinner frames — the usual Braille cycle.
5564        for frame in ["⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] {
5565            r.render(UiLine::StreamingBox {
5566                buf: String::new(),
5567                cursor_byte: 0,
5568                frame,
5569                label: "Thinking".into(),
5570                status: status.clone(),
5571                menu: None,
5572                attachments: Vec::new(),
5573            });
5574        }
5575        assert_eq!(
5576            r.body_lines.len(),
5577            after_first,
5578            "spinner ticks grew body_lines from {} to {} — each tick \
5579            must update the same row, not append",
5580            after_first,
5581            r.body_lines.len()
5582        );
5583    }
5584
5585    /// AssistantText arriving after a live spinner COVERS the
5586    /// spinner row (it's a transient indicator, not a historical
5587    /// paragraph header). Answer text appears exactly where
5588    /// `⠋ Pondering…` was, no stacked ghost, no scrollback pollution.
5589    #[test]
5590    fn retained_assistant_text_covers_spinner_row() {
5591        let (mut r, buf) = new_capturing(80, 24);
5592        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5593        let status = status_basic();
5594        r.render(UiLine::StreamingBox {
5595            buf: String::new(),
5596            cursor_byte: 0,
5597            frame: "⠋",
5598            label: "Pondering".into(),
5599            status: status.clone(),
5600            menu: None,
5601            attachments: Vec::new(),
5602        });
5603        r.render(UiLine::AssistantText("Hello world\n".into()));
5604        r.render(UiLine::InputPrompt {
5605            buf: String::new(),
5606            cursor_byte: 0,
5607            menu: None,
5608            status: status.clone(),
5609            attachments: Vec::new(),
5610        });
5611        r.flush_deferred();
5612        drain_into_vterm(&buf, &mut vterm);
5613
5614        // Spinner must be GONE from the visible grid — assistant
5615        // text has overwritten its row.
5616        let has_spinner = vterm.any_row(|row| row.contains("⠋") && row.contains("Pondering"));
5617        let has_text = vterm.any_row(|row| row.contains("Hello world"));
5618        assert!(
5619            !has_spinner,
5620            "spinner still visible after AssistantText — it must be \
5621             covered, not frozen:\n{}",
5622            vterm.dump()
5623        );
5624        assert!(has_text, "assistant text missing:\n{}", vterm.dump());
5625
5626        // And removed from history: body_lines should not carry a
5627        // lingering spinner entry that would re-surface on
5628        // ensure_scroll_region repaints or resize.
5629        let spinner_in_history = r.body_lines.iter().any(|row| {
5630            let text: String = row.iter().map(|c| c.ch).collect();
5631            text.contains("Pondering")
5632        });
5633        assert!(
5634            !spinner_in_history,
5635            "spinner row still in body_lines — it must be popped when \
5636             covered"
5637        );
5638    }
5639
5640    /// Models commonly emit a leading `\n` (or several) before
5641    /// actual reply text — a warm-up that prior code treated as a
5642    /// paragraph-boundary blank because the tail was the live
5643    /// spinner (non-blank cells, fails `tail_blank` check). Result
5644    /// was a ghost blank row between the user message spacer and
5645    /// the first real content. Fix: treat "tail is live spinner"
5646    /// the same as "tail is blank" — the spinner is transient, not
5647    /// a paragraph we need to visually separate from.
5648    #[test]
5649    fn retained_leading_blank_assistant_text_does_not_add_ghost_row() {
5650        let (mut r, buf) = new_capturing(80, 24);
5651        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5652        let status = status_basic();
5653
5654        r.render(UiLine::User("hi-from-user".into()));
5655        r.flush_deferred();
5656        r.render(UiLine::StreamingBox {
5657            buf: String::new(),
5658            cursor_byte: 0,
5659            frame: "⠋",
5660            label: "Pondering".into(),
5661            status: status.clone(),
5662            menu: None,
5663            attachments: Vec::new(),
5664        });
5665        // Leading `\n` warm-up from the model — this is the case
5666        // that produces the ghost blank before the fix.
5667        r.render(UiLine::AssistantText("\n".into()));
5668        // Then the real content.
5669        r.render(UiLine::AssistantText("Hello world\n".into()));
5670        r.flush_deferred();
5671        drain_into_vterm(&buf, &mut vterm);
5672
5673        let user_row = (0..24)
5674            .find(|r| vterm.row_text(*r).contains("hi-from-user"))
5675            .unwrap_or_else(|| panic!("user echo missing:\n{}", vterm.dump()));
5676        let hello_row = (0..24)
5677            .find(|r| vterm.row_text(*r).contains("Hello world"))
5678            .unwrap_or_else(|| panic!("Hello world missing:\n{}", vterm.dump()));
5679
5680        // Exactly ONE blank between user and assistant (the
5681        // user-message spacer). A ghost blank would make it 2.
5682        assert_eq!(
5683            hello_row - user_row,
5684            2,
5685            "expected 1 blank row between user and assistant, got {} \
5686             blank row(s) — leading `\\n` from model created a ghost \
5687             spacer:\n{}",
5688            hello_row.saturating_sub(user_row).saturating_sub(1),
5689            vterm.dump()
5690        );
5691    }
5692
5693    /// Realistic flow: user sends a message → spinner shows →
5694    /// assistant text streams in. The assistant text must land on
5695    /// EXACTLY the spinner's row (no empty row between spinner's
5696    /// former slot and the new text). User-message blank spacer is
5697    /// still there (it lives above the spinner's slot), but no
5698    /// additional blank gets introduced by clear_live_spinner.
5699    #[test]
5700    fn retained_spinner_replacement_leaves_no_extra_blank() {
5701        let (mut r, buf) = new_capturing(80, 24);
5702        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5703        let status = status_basic();
5704
5705        r.render(UiLine::User("hi-from-user".into()));
5706        r.render(UiLine::StreamingBox {
5707            buf: String::new(),
5708            cursor_byte: 0,
5709            frame: "⠋",
5710            label: "Pondering".into(),
5711            status: status.clone(),
5712            menu: None,
5713            attachments: Vec::new(),
5714        });
5715        r.render(UiLine::AssistantText("Hello world\n".into()));
5716        r.render(UiLine::InputPrompt {
5717            buf: String::new(),
5718            cursor_byte: 0,
5719            menu: None,
5720            status: status.clone(),
5721            attachments: Vec::new(),
5722        });
5723        r.flush_deferred();
5724        drain_into_vterm(&buf, &mut vterm);
5725
5726        // Find the rows that carry our 3 markers.
5727        let user_row = (0..24)
5728            .find(|r| vterm.row_text(*r).contains("hi-from-user"))
5729            .unwrap_or_else(|| panic!("user echo row missing:\n{}", vterm.dump()));
5730        let hello_row = (0..24)
5731            .find(|r| vterm.row_text(*r).contains("Hello world"))
5732            .unwrap_or_else(|| panic!("assistant text row missing:\n{}", vterm.dump()));
5733
5734        // Expected layout (bottom-anchored):
5735        //   <user_row>:     "> 你好啊"
5736        //   <user_row + 1>: blank (UiLine::User's spacer)
5737        //   <user_row + 2>: "Hello world"  ← replaced spinner in-place
5738        //
5739        // Critical invariant: exactly ONE blank row between them.
5740        // No extra gap would mean 2 consecutive blanks.
5741        assert_eq!(
5742            hello_row - user_row,
5743            2,
5744            "expected 1 spacer row between user and assistant, got {} \
5745             rows gap:\n{}",
5746            hello_row.saturating_sub(user_row).saturating_sub(1),
5747            vterm.dump()
5748        );
5749    }
5750
5751    /// Diagnostic: realistic flow — User → idle InputPrompt (sent
5752    /// BEFORE the first spinner tick to mirror the on_submit
5753    /// transition) → multiple spinner ticks → assertion on grid
5754    /// layout. User reported TWO blanks between `> 你好` and
5755    /// `● Pondering` — spec says there should be exactly ONE.
5756    #[test]
5757    fn retained_user_then_spinner_has_exactly_one_blank_between() {
5758        let (mut r, buf) = new_capturing(80, 24);
5759        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5760        let status = status_basic();
5761
5762        r.render(UiLine::User("hi-from-user".into()));
5763        // on_submit in the real app triggers a render pass before
5764        // the first spinner tick lands — simulate that here.
5765        r.flush_deferred();
5766        r.render(UiLine::StreamingBox {
5767            buf: String::new(),
5768            cursor_byte: 0,
5769            frame: "⠋",
5770            label: "Pondering".into(),
5771            status: status.clone(),
5772            menu: None,
5773            attachments: Vec::new(),
5774        });
5775        // Several animation ticks, then a final flush.
5776        for frame in ["⠙", "⠹", "⠸", "⠼"] {
5777            r.render(UiLine::StreamingBox {
5778                buf: String::new(),
5779                cursor_byte: 0,
5780                frame,
5781                label: "Pondering".into(),
5782                status: status.clone(),
5783                menu: None,
5784                attachments: Vec::new(),
5785            });
5786        }
5787        r.flush_deferred();
5788        drain_into_vterm(&buf, &mut vterm);
5789
5790        let user_row = (0..24)
5791            .find(|r| vterm.row_text(*r).contains("hi-from-user"))
5792            .unwrap_or_else(|| panic!("user echo missing:\n{}", vterm.dump()));
5793        let spin_row = (0..24)
5794            .find(|r| vterm.row_text(*r).contains("Pondering"))
5795            .unwrap_or_else(|| panic!("spinner missing:\n{}", vterm.dump()));
5796
5797        assert_eq!(
5798            spin_row - user_row,
5799            2,
5800            "expected exactly 1 blank row between user message and \
5801            spinner, got {} blank row(s):\n{}",
5802            spin_row.saturating_sub(user_row).saturating_sub(1),
5803            vterm.dump()
5804        );
5805    }
5806
5807    /// If the turn ends with NO text output (just an empty input
5808    /// prompt arrives after the spinner), the spinner must also
5809    /// disappear. User's view: the in-progress indicator was
5810    /// transient; once the render state moves on, no residue remains.
5811    #[test]
5812    fn retained_input_prompt_clears_live_spinner() {
5813        let (mut r, buf) = new_capturing(80, 24);
5814        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5815        let status = status_basic();
5816        r.render(UiLine::StreamingBox {
5817            buf: String::new(),
5818            cursor_byte: 0,
5819            frame: "⠋",
5820            label: "Pondering".into(),
5821            status: status.clone(),
5822            menu: None,
5823            attachments: Vec::new(),
5824        });
5825        // Directly back to input with no assistant output between.
5826        r.render(UiLine::InputPrompt {
5827            buf: String::new(),
5828            cursor_byte: 0,
5829            menu: None,
5830            status: status.clone(),
5831            attachments: Vec::new(),
5832        });
5833        r.flush_deferred();
5834        drain_into_vterm(&buf, &mut vterm);
5835
5836        let has_spinner = vterm.any_row(|row| row.contains("⠋") && row.contains("Pondering"));
5837        assert!(
5838            !has_spinner,
5839            "spinner still visible after returning to input prompt:\n{}",
5840            vterm.dump()
5841        );
5842    }
5843
5844    /// Markdown inline: `**bold**` + `` `code` `` rendered in
5845    /// the assistant-text stream. Grid inspects specific cells to
5846    /// confirm bold and bright-white fg survived the markdown → cells →
5847    /// serialize → vte parse round-trip.
5848    #[test]
5849    fn retained_markdown_inline_styles_via_vterm() {
5850        let (mut r, buf) = new_capturing(80, 24);
5851        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5852        let status = status_basic();
5853        r.render(UiLine::AssistantText(
5854            "Hello **bold** and `code` here\n".into(),
5855        ));
5856        r.render(UiLine::InputPrompt {
5857            buf: String::new(),
5858            cursor_byte: 0,
5859            menu: None,
5860            status: status.clone(),
5861            attachments: Vec::new(),
5862        });
5863        r.flush_deferred();
5864        drain_into_vterm(&buf, &mut vterm);
5865        let row_idx = (0..vterm.height() as usize)
5866            .find(|&r| vterm.row_text(r).contains("Hello bold and code here"))
5867            .unwrap_or_else(|| panic!("inline markdown text missing\ndump:\n{}", vterm.dump()));
5868        let row_text = vterm.row_text(row_idx);
5869        // 'b' of "bold" — the '*' markers are consumed. With
5870        // `  Hello **bold** and`, after markdown render it becomes
5871        // `  Hello bold and …`. Locate 'b' of "bold" and assert
5872        // its cell is bold.
5873        let bold_pos = row_text
5874            .find("bold")
5875            .expect("expected 'bold' in rendered text");
5876        let cell = vterm.cell_at(row_idx, bold_pos);
5877        assert!(
5878            cell.bold,
5879            "bold cell at col {} should be bold: {:?}\ndump:\n{}",
5880            bold_pos,
5881            cell,
5882            vterm.dump()
5883        );
5884        // Inline code: bold + bright cyan (SGR 96). The markdown crate
5885        // now colours inline code the same as headings and code-block
5886        // chrome, using the 16-colour SGR palette so the terminal theme
5887        // remaps the actual shade. In CellStyle this arrives as
5888        // `Color::Cyan` (crossterm's name for SGR 96 / bright cyan).
5889        let code_pos = row_text
5890            .find("code")
5891            .expect("expected 'code' in rendered text");
5892        let code_cell = vterm.cell_at(row_idx, code_pos);
5893        assert!(
5894            code_cell.bold,
5895            "inline code cell should be bold: {:?}",
5896            code_cell
5897        );
5898        assert_eq!(
5899            code_cell.fg,
5900            Some(Color::Cyan),
5901            "inline code cell must carry bright cyan fg: {:?}",
5902            code_cell
5903        );
5904    }
5905
5906    /// Plain assistant paragraphs must retain their 2-col indent even
5907    /// after symbol-bearing rows move to col 0. Regression guard for
5908    /// the hierarchy: symbols at col 0, prose at col 2.
5909    #[test]
5910    fn retained_assistant_paragraph_indent_preserved() {
5911        let (mut r, buf) = new_capturing(80, 24);
5912        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
5913        let status = status_basic();
5914        r.render(UiLine::AssistantText("hello world\n".into()));
5915        r.render(UiLine::TurnComplete);
5916        r.render(UiLine::InputPrompt {
5917            buf: String::new(),
5918            cursor_byte: 0,
5919            menu: None,
5920            status: status.clone(),
5921            attachments: Vec::new(),
5922        });
5923        r.flush_deferred();
5924        drain_into_vterm(&buf, &mut vterm);
5925
5926        let row_idx = (0..vterm.height() as usize)
5927            .find(|&i| vterm.row_text(i).contains("hello world"))
5928            .unwrap_or_else(|| panic!("assistant text row missing\ndump:\n{}", vterm.dump()));
5929        assert_eq!(vterm.cell_at(row_idx, 0).ch, ' ', "col 0 must be blank");
5930        assert_eq!(vterm.cell_at(row_idx, 1).ch, ' ', "col 1 must be blank");
5931        assert_eq!(
5932            vterm.cell_at(row_idx, 2).ch,
5933            'h',
5934            "assistant text must start at col 2, got row: {:?}",
5935            vterm.row_text(row_idx)
5936        );
5937    }
5938
5939    /// Regression: user reports bot_rule row visibly shortens when
5940    /// the input wraps from 1 line to 2 lines. Hypothesis: diff
5941    /// spurious-skips the bot_rule row, or paint_body/footer
5942    /// miscomputes bot_rule_row and overwrites it.
5943    ///
5944    /// Direct assertion: after wrapping, inspect Screen.prev_cells
5945    /// (which is "what we just emitted") — every column in the
5946    /// bot_rule row must contain either a PAD_COL blank or a '─'.
5947    #[test]
5948    fn retained_bot_rule_full_width_after_wrap() {
5949        let (mut r, _buf) = new_capturing(40, 24);
5950        let status = status_basic();
5951        // Short input → 1-row middle.
5952        r.render(UiLine::InputPrompt {
5953            buf: "hi".into(),
5954            cursor_byte: 2,
5955            menu: None,
5956            status: status.clone(),
5957            attachments: Vec::new(),
5958        });
5959        r.flush_deferred();
5960
5961        // Long input → 2-row middle.
5962        let long: String = std::iter::repeat('中').take(40).collect();
5963        r.render(UiLine::InputPrompt {
5964            buf: long.clone(),
5965            cursor_byte: long.len(),
5966            menu: None,
5967            status: status.clone(),
5968            attachments: Vec::new(),
5969        });
5970        r.flush_deferred();
5971
5972        // Inspect the newly-emitted frame (prev_cells after swap).
5973        let h = r.screen.height() as usize;
5974        let footer_rows = r.current_footer_rows();
5975        let footer_top = h - footer_rows;
5976        // Layout: top_rule + middle×N + bot_rule + status (spinner no
5977        // longer reserves a footer row — lives in body now).
5978        // With 2-row middle: bot_rule at footer_top + 1 + 2 = footer_top + 3
5979        // text_budget = w - 2 ("> " prefix) = 38 for w=40.
5980        let (lines, _, _) = crate::width::wrap_with_cursor(&long, 40 - 2, long.len());
5981        assert!(lines.len() >= 2, "test setup: expected wrap");
5982        let bot_rule_row = footer_top + 1 + lines.len();
5983        let prev_cells = r.screen.prev_cells_for_test();
5984        let row_cells = &prev_cells[bot_rule_row];
5985
5986        // Rule is flush-left/right now — every col 0..w is '─'.
5987        for (col, cell) in row_cells.iter().enumerate() {
5988            assert_eq!(
5989                cell.ch, '─',
5990                "col {} expected '─', got {:?} (rule short!)",
5991                col, cell
5992            );
5993        }
5994    }
5995
5996    /// Regression for "login 后 输入内容过长不自动换行" report.
5997    /// User observed a single long-line input not wrapping — turned
5998    /// out the buffer was 202 display cols vs the 203-col budget, so
5999    /// legit 1-row. This test pins down that an input CLEARLY past
6000    /// the budget produces a multi-row footer, and the cursor
6001    /// lives in the LAST middle row (not the first).
6002    #[test]
6003    fn retained_long_input_wraps_to_multi_row_footer() {
6004        // Small screen so wrap happens without massive test data.
6005        // text_budget = width - 6 = 34, so any input > 34 cols wraps.
6006        let (mut r, _buf) = new_capturing(40, 24);
6007        // 40 CJK characters = 80 display cols → wraps to 3 rows (cols
6008        // 0..33, 34..67, 68..79). Each row has ~17 Chinese chars.
6009        let long: String = std::iter::repeat('中').take(40).collect();
6010        // cursor_byte = full UTF-8 length of the input (3 bytes per char × 40).
6011        r.render(UiLine::InputPrompt {
6012            buf: long.clone(),
6013            cursor_byte: long.len(),
6014            menu: None,
6015            status: status_basic(),
6016            attachments: Vec::new(),
6017        });
6018        r.flush_deferred();
6019
6020        // Directly query wrap result to verify wrap happened.
6021        let (lines, cursor_row, _cursor_col) =
6022            crate::width::wrap_with_cursor(&long, 40 - 6, long.len());
6023        assert!(
6024            lines.len() >= 2,
6025            "expected 2+ wrapped rows, got {} line(s): {:?}",
6026            lines.len(),
6027            lines
6028        );
6029        // Cursor should be in the LAST wrapped row (end of buffer).
6030        assert_eq!(
6031            cursor_row,
6032            lines.len() - 1,
6033            "cursor should be in last middle row"
6034        );
6035
6036        // Now the integration check: the internal footer-rows count
6037        // must match wrap output. If paint_footer miscomputes, the
6038        // body area overlaps the multi-row middle.
6039        assert_eq!(
6040            r.current_footer_rows(),
6041            // 1 top rule + lines.len() + 1 bot rule + 0 menu + status(1)
6042            // (spinner moved to body — no longer reserves a footer row)
6043            1 + lines.len() + 1 + 1,
6044            "footer_rows must account for wrapped middle row count"
6045        );
6046    }
6047
6048    /// Wide CJK input end-to-end: render "你是谁" from empty, assert
6049    /// emit stream contains the three glyphs consecutively (no
6050    /// cursor-drift desync between them).
6051    #[test]
6052    fn retained_wide_char_input_keeps_all() {
6053        let (mut r, buf) = new_capturing(80, 24);
6054        let status = status_basic();
6055        r.render(UiLine::InputPrompt {
6056            buf: "".into(),
6057            cursor_byte: 0,
6058            menu: None,
6059            status: status.clone(),
6060            attachments: Vec::new(),
6061        });
6062        r.flush_deferred();
6063        buf.lock().unwrap().clear();
6064
6065        r.render(UiLine::InputPrompt {
6066            buf: "你是谁".into(),
6067            cursor_byte: 9,
6068            menu: None,
6069            status: status.clone(),
6070            attachments: Vec::new(),
6071        });
6072        r.flush_deferred();
6073        let stream_bytes = std::mem::take(&mut *buf.lock().unwrap());
6074        let stream = String::from_utf8_lossy(&stream_bytes).to_string();
6075        assert!(
6076            stream.contains("你是谁"),
6077            "wide chars not consecutive in retained emit stream:\n{}",
6078            stream
6079        );
6080    }
6081
6082    /// Mac Terminal.app drops bytes mid-sequence when a single
6083    /// `write_all` carries ~1KB+ of mixed CSI/SGR/UTF-8 — observed as
6084    /// "bot_rule row shortens" after a big cold-start paint. The
6085    /// workaround in `flush_deferred` splits emits into 512 B chunks.
6086    /// Regression: a cold-start full frame (welcome + footer +
6087    /// menu open) must produce > 1 write call, with every chunk
6088    /// except the last sized exactly 512 bytes.
6089    #[test]
6090    fn retained_large_frame_splits_into_512b_chunks() {
6091        let (mut r, chunks) = new_chunk_counting(80, 24);
6092        let status = status_basic();
6093
6094        // Build up a painted frame with welcome + open menu so the
6095        // cold-start emit is comfortably over 512 B. Welcome rows are
6096        // emitted via the body scrollback path (one write_all each),
6097        // so we reset the chunk tally after that stage and measure
6098        // only the footer paint — that's the one `flush_deferred`
6099        // splits into 512 B chunks.
6100        r.render(UiLine::Welcome {
6101            model: "glm-5".into(),
6102            working_dir: "~/project/atomcode".into(),
6103        });
6104        chunks.lock().unwrap().clear();
6105        let items: Vec<(String, String)> = vec![
6106            ("model".into(), "Switch model".into()),
6107            ("provider".into(), "Add provider".into()),
6108            ("session".into(), "New session".into()),
6109            ("resume".into(), "Resume session".into()),
6110        ];
6111        r.render(UiLine::InputPrompt {
6112            buf: "/".into(),
6113            cursor_byte: 1,
6114            menu: Some(MenuPayload {
6115                items,
6116                selected: 0,
6117                kind: crate::render::MenuKind::SlashCommand,
6118            }),
6119            status,
6120            attachments: Vec::new(),
6121        });
6122        r.flush_deferred();
6123
6124        let sizes = chunks.lock().unwrap().clone();
6125        let total: usize = sizes.iter().sum();
6126        assert!(
6127            total > 512,
6128            "test needs a > 512 B frame to exercise chunking; got {} B (sizes: {:?})",
6129            total,
6130            sizes
6131        );
6132        assert!(
6133            sizes.len() > 1,
6134            "large frame must split into >1 write ({} B in one call)\nsizes: {:?}",
6135            total,
6136            sizes
6137        );
6138        // At least one chunk must be exactly 512 B — that's the
6139        // signature of the chunking loop actually firing on the main
6140        // diff payload. Small preamble writes (DECSTBM setup, cursor
6141        // moves emitted via separate `write!` calls outside the loop)
6142        // legitimately appear as their own sub-512 chunks.
6143        assert!(
6144            sizes.iter().any(|&s| s == 512),
6145            "expected at least one 512 B chunk from the chunking loop; sizes: {:?}",
6146            sizes
6147        );
6148        assert!(
6149            sizes.iter().all(|&s| s <= 512),
6150            "no chunk may exceed 512 B (sizes: {:?})",
6151            sizes
6152        );
6153    }
6154
6155    /// Small frames must NOT chunk — single `write` per flush keeps
6156    /// syscall count minimal on the steady-state keystroke path.
6157    #[test]
6158    fn retained_small_frame_single_write() {
6159        let (mut r, chunks) = new_chunk_counting(80, 24);
6160        let status = status_basic();
6161        // Warm up so prev_cells matches.
6162        r.render(UiLine::InputPrompt {
6163            buf: "h".into(),
6164            cursor_byte: 1,
6165            menu: None,
6166            status: status.clone(),
6167            attachments: Vec::new(),
6168        });
6169        r.flush_deferred();
6170        chunks.lock().unwrap().clear();
6171
6172        // Single keystroke — delta ≪ 512 B.
6173        r.render(UiLine::InputPrompt {
6174            buf: "hi".into(),
6175            cursor_byte: 2,
6176            menu: None,
6177            status,
6178            attachments: Vec::new(),
6179        });
6180        r.flush_deferred();
6181        let sizes = chunks.lock().unwrap().clone();
6182        assert_eq!(
6183            sizes.len(),
6184            1,
6185            "steady-state keystroke should be one write (sizes: {:?})",
6186            sizes
6187        );
6188        assert!(
6189            sizes[0] < 512,
6190            "keystroke delta should be well under 512 B (got {} B)",
6191            sizes[0]
6192        );
6193    }
6194
6195    /// After `/clear` (renderer.clear_screen + re-render Welcome),
6196    /// the welcome must reappear on the grid. Previous bug: the
6197    /// immediate-mode renderer's diff cache was left intact by
6198    /// `clear_screen`, so the next welcome paint saw prev=welcome
6199    /// (stale), emitted no diff, and the terminal stayed blank.
6200    /// Retained mode closes this hole by blowing away the whole
6201    /// Screen model inside `clear_screen` — this test pins that
6202    /// behaviour.
6203    #[test]
6204    fn retained_clear_screen_then_welcome_renders_via_vterm() {
6205        let (mut r, buf) = new_capturing(80, 24);
6206        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6207        let status = status_basic();
6208
6209        // Initial welcome.
6210        r.render(UiLine::Welcome {
6211            model: "glm-5".into(),
6212            working_dir: "~/project/atomcode".into(),
6213        });
6214        r.render(UiLine::InputPrompt {
6215            buf: String::new(),
6216            cursor_byte: 0,
6217            menu: None,
6218            status: status.clone(),
6219            attachments: Vec::new(),
6220        });
6221        r.flush_deferred();
6222        drain_into_vterm(&buf, &mut vterm);
6223        assert!(
6224            (0..24).any(|row| vterm.row_text(row).contains("AtomCode")),
6225            "baseline welcome missing:\n{}",
6226            vterm.dump()
6227        );
6228
6229        // /clear — wipe terminal + re-render welcome. Note the
6230        // `clear_screen` call wipes state but doesn't repaint; the
6231        // next Welcome + flush does.
6232        r.clear_screen();
6233        r.render(UiLine::Welcome {
6234            model: "glm-5".into(),
6235            working_dir: "~/project/atomcode".into(),
6236        });
6237        r.render(UiLine::InputPrompt {
6238            buf: String::new(),
6239            cursor_byte: 0,
6240            menu: None,
6241            status,
6242            attachments: Vec::new(),
6243        });
6244        r.flush_deferred();
6245        drain_into_vterm(&buf, &mut vterm);
6246
6247        // Welcome must be back.
6248        let still_has = (0..24)
6249            .filter(|row| vterm.row_text(*row).contains("AtomCode"))
6250            .count();
6251        assert_eq!(
6252            still_has,
6253            1,
6254            "after /clear the welcome must appear exactly once (not 0, not 2+):\n{}",
6255            vterm.dump()
6256        );
6257    }
6258
6259    /// `resume_from_external` (OAuth browser return, `/shell` exit)
6260    /// must (1) emit `\x1b[2J\x1b[H` to clear whatever the child
6261    /// process left on screen, and (2) invalidate the Screen cache
6262    /// so the next paint is a cold-start full repaint — otherwise
6263    /// the diff would skip every cell that happens to match
6264    /// prev_cells and the terminal would stay blank with a stale
6265    /// cache believing everything is fine.
6266    #[test]
6267    fn retained_resume_from_external_clears_and_forces_repaint() {
6268        let (mut r, buf) = new_capturing(80, 24);
6269        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6270        let status = status_basic();
6271
6272        // Paint welcome first, drain so vterm + terminal state agree.
6273        r.render(UiLine::Welcome {
6274            model: "glm-5".into(),
6275            working_dir: "~/project/atomcode".into(),
6276        });
6277        r.render(UiLine::InputPrompt {
6278            buf: String::new(),
6279            cursor_byte: 0,
6280            menu: None,
6281            status: status.clone(),
6282            attachments: Vec::new(),
6283        });
6284        r.flush_deferred();
6285        drain_into_vterm(&buf, &mut vterm);
6286        assert!(
6287            (0..24).any(|row| vterm.row_text(row).contains("AtomCode")),
6288            "baseline welcome missing:\n{}",
6289            vterm.dump()
6290        );
6291
6292        // Simulate the child process scribbling garbage on the
6293        // terminal — vterm feeds bytes only from the renderer's
6294        // sink, so we feed the "garbage" directly to vterm to
6295        // mimic a post-child state where on-screen content no
6296        // longer matches renderer's prev_cells.
6297        vterm.feed(b"\x1b[1;1H*** child process noise ***\r\n");
6298        assert!(
6299            vterm.row_text(0).contains("child process noise"),
6300            "setup: child-noise didn't land on vterm:\n{}",
6301            vterm.dump()
6302        );
6303
6304        // Clear capture buffer so we can observe ONLY the bytes
6305        // emitted by resume_from_external + the next flush.
6306        buf.lock().unwrap().clear();
6307        r.resume_from_external();
6308        let resume_bytes = buf.lock().unwrap().clone();
6309        let resume_str = String::from_utf8_lossy(&resume_bytes);
6310        // Resume now uses per-row CUP+EL instead of ED (iTerm2 3.5+
6311        // observed to ignore `\x1b[2J` under certain states). Assert
6312        // the equivalent semantics: at least one EL landed AND the
6313        // cursor homes. The real behavioral check (no stale child
6314        // noise) runs at the end of this test.
6315        assert!(
6316            resume_str.contains("\x1b[K") && resume_str.contains("\x1b[H"),
6317            "resume must emit per-row EL + home: {:?}",
6318            resume_str
6319        );
6320        drain_into_vterm(&buf, &mut vterm);
6321
6322        // After resume the next render must fully repaint against
6323        // blank prev_cells — verify by rendering the SAME welcome
6324        // content as before (so a naive cache would emit zero
6325        // bytes) and asserting it still produces a non-trivial
6326        // emit that restores AtomCode on the grid.
6327        r.render(UiLine::Welcome {
6328            model: "glm-5".into(),
6329            working_dir: "~/project/atomcode".into(),
6330        });
6331        r.render(UiLine::InputPrompt {
6332            buf: String::new(),
6333            cursor_byte: 0,
6334            menu: None,
6335            status,
6336            attachments: Vec::new(),
6337        });
6338        r.flush_deferred();
6339        drain_into_vterm(&buf, &mut vterm);
6340        assert!(
6341            (0..24).any(|row| vterm.row_text(row).contains("AtomCode")),
6342            "after resume_from_external the next paint must restore welcome (full repaint, not diff-skip):\n{}",
6343            vterm.dump()
6344        );
6345        assert!(
6346            !vterm.row_text(0).contains("child process noise"),
6347            "resume must erase child-process garbage at row 0:\n{}",
6348            vterm.dump()
6349        );
6350    }
6351
6352    /// Regression for the "/ then Esc" ghost. With menu open the
6353    /// footer is taller so the bottom-anchored welcome paints at
6354    /// rows A..B. When the menu closes the footer shrinks and the
6355    /// welcome paints at rows A+k..B+k (further down). If the
6356    /// geometry-change path invalidates prev_cells without also
6357    /// erasing the terminal, the diff against blank-prev skips
6358    /// blank cells in the new frame — so the old welcome at rows
6359    /// A..A+k-1 stays on screen as a ghost underneath the fresh
6360    /// paint.
6361    #[test]
6362    fn retained_menu_close_leaves_no_welcome_ghost() {
6363        let (mut r, buf) = new_capturing(80, 24);
6364        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6365        let status = status_basic();
6366
6367        // Initial welcome (no menu). Footer = 4 rows (top_rule /
6368        // middle / bot_rule / status). Welcome 8 rows bottom-anchored
6369        // at rows 12..=19 (0-idx). Banner = title + path + model +
6370        // blank + 3 hint rows + trailing blank.
6371        r.render(UiLine::Welcome {
6372            model: "glm-5".into(),
6373            working_dir: "~/project/atomcode".into(),
6374        });
6375        r.render(UiLine::InputPrompt {
6376            buf: String::new(),
6377            cursor_byte: 0,
6378            menu: None,
6379            status: status.clone(),
6380            attachments: Vec::new(),
6381        });
6382        r.flush_deferred();
6383        drain_into_vterm(&buf, &mut vterm);
6384
6385        // Open menu ("/" pressed). Footer grows by 4 rows (menu) →
6386        // 8 rows. Welcome (8 rows) paints at 0-idx rows 8..=15.
6387        let items: Vec<(String, String)> = vec![
6388            ("model".into(), "Switch model".into()),
6389            ("provider".into(), "Add provider".into()),
6390            ("session".into(), "New session".into()),
6391            ("resume".into(), "Resume session".into()),
6392        ];
6393        r.render(UiLine::InputPrompt {
6394            buf: "/".into(),
6395            cursor_byte: 1,
6396            menu: Some(MenuPayload {
6397                items: items.clone(),
6398                selected: 0,
6399                    kind: crate::render::MenuKind::SlashCommand,
6400            }),
6401            status: status.clone(),
6402            attachments: Vec::new(),
6403        });
6404        r.flush_deferred();
6405        drain_into_vterm(&buf, &mut vterm);
6406
6407        // Close menu (Esc). Footer shrinks back to 4, welcome
6408        // re-paints via `ensure_scroll_region`'s grew branch →
6409        // back to 0-idx rows 12..=19.
6410        r.render(UiLine::InputPrompt {
6411            buf: String::new(),
6412            cursor_byte: 0,
6413            menu: None,
6414            status: status.clone(),
6415            attachments: Vec::new(),
6416        });
6417        r.flush_deferred();
6418        drain_into_vterm(&buf, &mut vterm);
6419
6420        // Welcome brand at row 12 post-close. Row 8 (where brand
6421        // lived mid-menu) must be blank now — the zombie-zone erase
6422        // must have cleaned it.
6423        assert!(
6424            vterm.row_text(12).contains("AtomCode"),
6425            "menu-close: welcome brand missing at row 12:\n{}",
6426            vterm.dump()
6427        );
6428        assert!(
6429            !vterm.row_text(8).contains("AtomCode"),
6430            "menu-close: row 8 still shows ghost welcome brand:\n{}",
6431            vterm.dump()
6432        );
6433        // Same for cwd row (was 0-idx row 9 mid-menu, moves to 13).
6434        assert!(
6435            !vterm.row_text(9).contains("project"),
6436            "menu-close: row 9 still shows ghost cwd:\n{}",
6437            vterm.dump()
6438        );
6439    }
6440
6441    /// Regression for user report: after `/model` switched providers,
6442    /// scrolling up showed the welcome banner + prior messages
6443    /// duplicated in scrollback. Root cause: `/model` changes the
6444    /// status-line text, which can change the footer height (status
6445    /// wraps, or spinner/menu rows differ between frames). When
6446    /// `current_footer_rows()` shifts, `ensure_scroll_region`'s
6447    /// shrunk/grew branches clear the viewport and re-emit every
6448    /// cached body row through `emit_body_line_inner` — which uses
6449    /// `\n` at the region bottom, scrolling the top row into
6450    /// terminal scrollback. Any cached body row that had already
6451    /// entered scrollback during its original emit now enters a
6452    /// second time: a duplicate the user sees on scroll-up.
6453    ///
6454    /// Repro: fill body past the viewport so a known welcome line
6455    /// lives in scrollback once, then change the footer height by
6456    /// swapping in an input long enough to wrap the middle to 2+
6457    /// rows. The hint line must still appear exactly once in
6458    /// scrollback afterwards — the repaint must not re-scroll it.
6459    #[test]
6460    fn retained_footer_growth_does_not_duplicate_scrollback() {
6461        let (mut r, buf) = new_capturing(80, 24);
6462        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6463        let status = status_basic();
6464
6465        // Welcome (7 body rows) + 20 User echoes (2 rows each =
6466        // 40 body rows). Total 47 rows pushed; body region bottom
6467        // with a 1-line-input footer is < 20, so ~27 rows are
6468        // already in terminal scrollback via the normal emit path.
6469        r.render(UiLine::Welcome {
6470            model: "MiniMax-M2.7".into(),
6471            working_dir: "~/Documents/workspace/atomcode".into(),
6472        });
6473        for i in 0..20 {
6474            r.render(UiLine::User(format!("msg-{:03}", i)));
6475        }
6476        r.render(UiLine::InputPrompt {
6477            buf: String::new(),
6478            cursor_byte: 0,
6479            menu: None,
6480            status: status.clone(),
6481            attachments: Vec::new(),
6482        });
6483        r.flush_deferred();
6484        drain_into_vterm(&buf, &mut vterm);
6485
6486        // Fingerprint: welcome hint is unique and we pushed it
6487        // early enough that it's sitting in scrollback by now.
6488        let hint = "to add a custom model";
6489        let count_hint = |vt: &crate::test_term::VirtualTerminal| {
6490            vt.scrollback_texts()
6491                .iter()
6492                .filter(|row| row.contains(hint))
6493                .count()
6494        };
6495        assert_eq!(
6496            count_hint(&vterm),
6497            1,
6498            "baseline: hint should sit in scrollback exactly once \
6499             after normal emits (got {}):\n{}",
6500            count_hint(&vterm),
6501            vterm.scrollback_texts().join("\n")
6502        );
6503        let sb_before = vterm.scrollback_len();
6504
6505        // Footer height change: long buffer wraps the middle to 3
6506        // rows (text budget = 80 - 6 = 74 cols; 200 'x' → 3 rows).
6507        // body_bottom shrinks → ensure_scroll_region's shrunk branch
6508        // fires. Before the fix, this re-emits every cached body
6509        // row via `\n`-scroll, pushing overflow into scrollback a
6510        // second time.
6511        let long: String = "x".repeat(200);
6512        r.render(UiLine::InputPrompt {
6513            buf: long.clone(),
6514            cursor_byte: long.len(),
6515            menu: None,
6516            status: status.clone(),
6517            attachments: Vec::new(),
6518        });
6519        r.flush_deferred();
6520        drain_into_vterm(&buf, &mut vterm);
6521
6522        assert_eq!(
6523            count_hint(&vterm),
6524            1,
6525            "footer growth duplicated welcome hint in scrollback \
6526             (got {} copies):\nscrollback:\n{}",
6527            count_hint(&vterm),
6528            vterm.scrollback_texts().join("\n")
6529        );
6530        // Broader sanity: no body row should have been pushed into
6531        // scrollback by the repaint itself. The footer grew by N
6532        // rows, which means the visible body shrank by N rows — the
6533        // terminal's native region-shrink does not push rows to
6534        // scrollback, only LFs at the bottom do. So the only way
6535        // scrollback_len grew here is via the buggy re-emit.
6536        assert_eq!(
6537            vterm.scrollback_len(),
6538            sb_before,
6539            "footer growth pushed {} extra rows into scrollback; \
6540             repaint must use absolute positioning, not LF-scroll",
6541            vterm.scrollback_len() - sb_before
6542        );
6543    }
6544
6545    /// Regression for user report: after `/quit`, the newest answer
6546    /// rows that were still visible above the fixed footer vanished
6547    /// from host-terminal history. They had never naturally scrolled
6548    /// into native scrollback, and shutdown wiped the viewport.
6549    #[test]
6550    fn retained_shutdown_promotes_visible_body_tail_to_scrollback() {
6551        let (mut r, buf) = new_capturing(80, 12);
6552        let mut vterm = crate::test_term::VirtualTerminal::new(80, 12);
6553        let status = status_basic();
6554
6555        r.render(UiLine::User("show config routes".into()));
6556        r.render(UiLine::CommandOutput(
6557            "GET /config\nPOST /config/reload\nvisible-bottom-answer\n".into(),
6558        ));
6559        r.render(UiLine::InputPrompt {
6560            buf: String::new(),
6561            cursor_byte: 0,
6562            menu: None,
6563            status,
6564            attachments: Vec::new(),
6565        });
6566        r.flush_deferred();
6567        drain_into_vterm(&buf, &mut vterm);
6568
6569        assert!(
6570            !vterm
6571                .scrollback_texts()
6572                .iter()
6573                .any(|row| row.contains("visible-bottom-answer")),
6574            "baseline should keep the newest visible answer out of scrollback until shutdown"
6575        );
6576
6577        r.shutdown();
6578        drain_into_vterm(&buf, &mut vterm);
6579
6580        assert!(
6581            vterm
6582                .scrollback_texts()
6583                .iter()
6584                .any(|row| row.contains("visible-bottom-answer")),
6585            "shutdown must preserve the visible body tail in scrollback:\n{}",
6586            vterm.scrollback_texts().join("\n")
6587        );
6588    }
6589
6590    /// Regression for user report: on first startup the welcome
6591    /// banner rendered TWICE — once at the top of the viewport
6592    /// (pushed into scrollback, no input box) and once at the bottom
6593    /// above the input box. Root cause: `ensure_scroll_region` used
6594    /// `\x1b[2J` to wipe the viewport before re-painting the body.
6595    /// macOS Terminal.app and iTerm2 (and xterm with `cbScrollback`)
6596    /// copy every non-blank visible row into scrollback when
6597    /// processing ED — so the 6 welcome rows painted during the
6598    /// initial body emit were promoted into scrollback the moment
6599    /// the first InputPrompt render caused the footer to grow by
6600    /// 1 row (status line appears → body_bottom shrinks by 1).
6601    ///
6602    /// The repaint must never emit ED — per-row EL (`\x1b[K`) at
6603    /// absolute positions is safe on every terminal and achieves
6604    /// the same visible result without the scrollback side-channel.
6605    #[test]
6606    fn retained_first_startup_does_not_push_welcome_to_scrollback() {
6607        let (mut r, buf) = new_capturing(80, 24);
6608        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6609        // Model the terminal's ED-promotes-to-scrollback behaviour —
6610        // the specific mode the user's terminal is running under.
6611        vterm.set_ed_promotes_to_scrollback(true);
6612
6613        // Minimal first-startup sequence: welcome then the first
6614        // InputPrompt. The InputPrompt carries a non-empty status
6615        // (model/cwd) so `current_footer_rows` grows from 4 (no
6616        // status) to 5, which trips the repaint branch.
6617        r.render(UiLine::Welcome {
6618            model: "z-ai/glm-5".into(),
6619            working_dir: "~/Documents/workspace/atomcode".into(),
6620        });
6621        r.render(UiLine::InputPrompt {
6622            buf: String::new(),
6623            cursor_byte: 0,
6624            menu: None,
6625            status: status_basic(),
6626            attachments: Vec::new(),
6627        });
6628        r.flush_deferred();
6629        drain_into_vterm(&buf, &mut vterm);
6630
6631        // Welcome fingerprint: `/codingplan` is unique to the welcome
6632        // hint row and is a single non-wrapping token, so it gives a
6633        // stable single-row marker even when the combined hint line
6634        // soft-wraps at narrower widths. Must appear exactly once in
6635        // the *visible* viewport and zero times in scrollback.
6636        let hint = "/codingplan";
6637        let visible_count = (0..24)
6638            .filter(|r| vterm.row_text(*r).contains(hint))
6639            .count();
6640        let sb_count = vterm
6641            .scrollback_texts()
6642            .iter()
6643            .filter(|row| row.contains(hint))
6644            .count();
6645        assert_eq!(
6646            visible_count,
6647            1,
6648            "welcome hint should be visible exactly once (got {}):\n{}",
6649            visible_count,
6650            vterm.dump()
6651        );
6652        assert_eq!(
6653            sb_count,
6654            0,
6655            "first-startup footer transition promoted welcome into \
6656             scrollback ({} copies); repaint must not emit ED:\n\
6657             scrollback:\n{}",
6658            sb_count,
6659            vterm.scrollback_texts().join("\n")
6660        );
6661    }
6662
6663    /// Regression for user report: Shift+Enter in the input followed
6664    /// by delete leaves an extra rule line on screen. Root cause:
6665    /// Shift+Enter grows middle from 1 to 2 rows (body bottom -1);
6666    /// delete shrinks it back (body bottom +1, a GROW transition).
6667    /// In the new layout the OLD top-rule row lands on the new
6668    /// spinner slot — which paint_footer writes as a blank row when
6669    /// no spinner is active. `screen.invalidate()` zeroes prev_cells,
6670    /// so cell diff sees blank→blank at that row and emits nothing;
6671    /// the old rule glyphs persist on screen, stacked directly above
6672    /// the new top rule.
6673    ///
6674    /// Fix: repaint must explicitly erase every row in the union of
6675    /// old and new footer regions before the cell diff runs — EL is
6676    /// row-local so it doesn't leak content into scrollback.
6677    #[test]
6678    fn retained_middle_grow_then_shrink_leaves_no_ghost_rule() {
6679        let (mut r, buf) = new_capturing(80, 24);
6680        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6681        let status = status_basic();
6682
6683        // State A: 1-row middle (baseline).
6684        r.render(UiLine::InputPrompt {
6685            buf: String::new(),
6686            cursor_byte: 0,
6687            menu: None,
6688            status: status.clone(),
6689            attachments: Vec::new(),
6690        });
6691        r.flush_deferred();
6692        drain_into_vterm(&buf, &mut vterm);
6693
6694        // State B: shift+enter — 2-row middle. Buf "\n" wraps to
6695        // 2 lines per `wrap_with_cursor`. Footer +1, body -1.
6696        r.render(UiLine::InputPrompt {
6697            buf: "\n".into(),
6698            cursor_byte: 1,
6699            menu: None,
6700            status: status.clone(),
6701            attachments: Vec::new(),
6702        });
6703        r.flush_deferred();
6704        drain_into_vterm(&buf, &mut vterm);
6705
6706        // State C: delete back to empty. Body grows 1 row. This is
6707        // the transition that exposes the ghost rule.
6708        r.render(UiLine::InputPrompt {
6709            buf: String::new(),
6710            cursor_byte: 0,
6711            menu: None,
6712            status: status.clone(),
6713            attachments: Vec::new(),
6714        });
6715        r.flush_deferred();
6716        drain_into_vterm(&buf, &mut vterm);
6717
6718        // The input frame has exactly one top rule and one bot rule.
6719        // Each rule row is a full-width run of '─' (U+2500) with no
6720        // other glyphs. Count rows whose content is ONLY rule cells
6721        // — there must be exactly 2 after a clean grow+shrink. A
6722        // ghost from the old layout pushes this to 3.
6723        let rule_rows = (0..24)
6724            .filter(|r| {
6725                let txt = vterm.row_text(*r);
6726                let trimmed = txt.trim_end();
6727                !trimmed.is_empty() && trimmed.chars().all(|c| c == '\u{2500}')
6728            })
6729            .count();
6730        assert_eq!(
6731            rule_rows,
6732            2,
6733            "expected 2 rule rows (top + bot), got {} — grow \
6734             transition left a ghost:\n{}",
6735            rule_rows,
6736            vterm.dump()
6737        );
6738    }
6739
6740    /// Live-group flow:
6741    /// 1. ToolGroupRender pushes header + 3 child rows
6742    /// 2. ToolGroupChildUpdate on the MIDDLE child rewrites that row
6743    ///    in place via CUP — peers (rows above/below) untouched.
6744    ///
6745    /// Pinpoints CC-style "✓ trickles into existing row" behavior so
6746    /// any future regression (e.g. accidental `push_body_row` for
6747    /// child updates) gets caught.
6748    #[test]
6749    fn tool_group_render_then_child_update_in_place() {
6750        use crate::render::ToolGroupChild;
6751        let (mut r, buf) = new_capturing(80, 24);
6752        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6753
6754        r.render(UiLine::ToolGroupRender {
6755            batch_id: "b1".into(),
6756            header: "▸ Running 3 read_file calls in parallel".into(),
6757            children: vec![
6758                ToolGroupChild {
6759                    call_id: "c1".into(),
6760                    text: "  ↳ Read File foo.rs".into(),
6761                },
6762                ToolGroupChild {
6763                    call_id: "c2".into(),
6764                    text: "  ↳ Read File bar.rs".into(),
6765                },
6766                ToolGroupChild {
6767                    call_id: "c3".into(),
6768                    text: "  ↳ Read File baz.rs".into(),
6769                },
6770            ],
6771        });
6772        r.render(UiLine::InputPrompt {
6773            buf: String::new(),
6774            cursor_byte: 0,
6775            menu: None,
6776            status: status_basic(),
6777            attachments: Vec::new(),
6778        });
6779        r.flush_deferred();
6780        drain_into_vterm(&buf, &mut vterm);
6781
6782        let dump_before = vterm.dump();
6783        assert!(
6784            dump_before.contains("Running 3 read_file"),
6785            "header missing:\n{}",
6786            dump_before
6787        );
6788        assert!(dump_before.contains("Read File foo.rs"));
6789        assert!(dump_before.contains("Read File bar.rs"));
6790        assert!(dump_before.contains("Read File baz.rs"));
6791        // No ✓ yet — every child still shows its initial dispatched row.
6792        assert!(
6793            !dump_before.contains("✓"),
6794            "no checkmark expected pre-update:\n{}",
6795            dump_before
6796        );
6797
6798        // In-place update of the middle child — CUPs to that row and
6799        // rewrites without pushing a new body row.
6800        r.render(UiLine::ToolGroupChildUpdate {
6801            batch_id: "b1".into(),
6802            call_id: "c2".into(),
6803            new_text: "  ↳ ✓ Read File bar.rs".into(),
6804        });
6805        r.flush_deferred();
6806        drain_into_vterm(&buf, &mut vterm);
6807
6808        let dump_after = vterm.dump();
6809        assert!(
6810            dump_after.contains("✓ Read File bar.rs"),
6811            "✓ on bar.rs row missing after update:\n{}",
6812            dump_after
6813        );
6814        // Other two children untouched — exactly one ✓ in the dump.
6815        let check_count = dump_after.matches("✓").count();
6816        assert_eq!(
6817            check_count, 1,
6818            "expected exactly 1 ✓ (middle child only); got {}:\n{}",
6819            check_count, dump_after
6820        );
6821    }
6822
6823    /// Foreign body push between ToolGroupRender and ChildUpdate
6824    /// freezes the group. Subsequent updates must no-op (rather than
6825    /// CUP-rewrite some unrelated row that took the child's screen
6826    /// position). Model still has the ToolResult — only the visual
6827    /// ✓ light-up is dropped, which is the safe outcome.
6828    #[test]
6829    fn tool_group_freezes_after_unrelated_body_push() {
6830        use crate::render::ToolGroupChild;
6831        let (mut r, buf) = new_capturing(80, 24);
6832        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6833
6834        r.render(UiLine::ToolGroupRender {
6835            batch_id: "b1".into(),
6836            header: "▸ batch header".into(),
6837            children: vec![
6838                ToolGroupChild {
6839                    call_id: "c1".into(),
6840                    text: "  ↳ child one".into(),
6841                },
6842                ToolGroupChild {
6843                    call_id: "c2".into(),
6844                    text: "  ↳ child two".into(),
6845                },
6846            ],
6847        });
6848        // Foreign push — freezes the group.
6849        r.render(UiLine::CommandOutput("foreign output line".into()));
6850        // This update would have rewritten child1 in place, but the
6851        // group is now frozen → must be a no-op.
6852        r.render(UiLine::ToolGroupChildUpdate {
6853            batch_id: "b1".into(),
6854            call_id: "c1".into(),
6855            new_text: "  ↳ ✓ child one (should NOT appear)".into(),
6856        });
6857        r.render(UiLine::InputPrompt {
6858            buf: String::new(),
6859            cursor_byte: 0,
6860            menu: None,
6861            status: status_basic(),
6862            attachments: Vec::new(),
6863        });
6864        r.flush_deferred();
6865        drain_into_vterm(&buf, &mut vterm);
6866
6867        let dump = vterm.dump();
6868        assert!(
6869            dump.contains("foreign output line"),
6870            "foreign push should still show:\n{}",
6871            dump
6872        );
6873        assert!(
6874            !dump.contains("(should NOT appear)"),
6875            "frozen group must not apply child update; got:\n{}",
6876            dump
6877        );
6878        assert!(
6879            !dump.contains("✓ child one"),
6880            "no ✓ should appear on the child after freeze:\n{}",
6881            dump
6882        );
6883    }
6884
6885    /// `attachments` from `UiLine::InputPrompt` paints a `└ [Image #N]`
6886    /// preview row between the bot_rule and the menu — same string the
6887    /// post-submit body echoes via `UiLine::ImageAttachment`. This is
6888    /// the only visual signal users have pre-submit that a paste
6889    /// actually attached an image (vs `[Image #N]` that they typed as
6890    /// literal text).
6891    #[test]
6892    fn input_prompt_attachments_render_preview_rows() {
6893        let (mut r, buf) = new_capturing(80, 24);
6894        r.render(UiLine::InputPrompt {
6895            buf: "see [Image #3] please".into(),
6896            cursor_byte: 21,
6897            menu: None,
6898            status: status_basic(),
6899            attachments: vec![3],
6900        });
6901        r.flush_deferred();
6902        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
6903        drain_into_vterm(&buf, &mut vterm);
6904        let dump = vterm.dump();
6905        assert!(
6906            dump.contains("└ [Image #3]"),
6907            "preview row must render the muted `└ [Image #N]` echo string; got:\n{}",
6908            dump
6909        );
6910    }
6911
6912    /// Empty `attachments` keeps the footer at its prior height — no
6913    /// blank preview row, no off-by-one in `current_footer_rows()`.
6914    /// Regression guard: an earlier draft would have incremented the
6915    /// row count even when the vec was empty, pushing the input box
6916    /// up by one row whenever `attachments` was wired through.
6917    #[test]
6918    fn input_prompt_no_attachments_keeps_footer_height() {
6919        let (mut r, _) = new_capturing(80, 24);
6920        r.render(UiLine::InputPrompt {
6921            buf: "before".into(),
6922            cursor_byte: 0,
6923            menu: None,
6924            status: status_basic(),
6925            attachments: Vec::new(),
6926        });
6927        let baseline = r.current_footer_rows();
6928        r.render(UiLine::InputPrompt {
6929            buf: "no images here".into(),
6930            cursor_byte: 0,
6931            menu: None,
6932            status: status_basic(),
6933            attachments: Vec::new(),
6934        });
6935        assert_eq!(
6936            r.current_footer_rows(),
6937            baseline,
6938            "empty attachments must not change footer height"
6939        );
6940    }
6941
6942    /// Footer height grows by exactly one row per attachment, so the
6943    /// body anchor (computed from `current_footer_rows()`) tracks the
6944    /// preview rows. Without this, a user with two attachments would
6945    /// see the topmost body row clipped under the input box.
6946    #[test]
6947    fn input_prompt_each_attachment_adds_one_row() {
6948        let (mut r, _) = new_capturing(80, 24);
6949        r.render(UiLine::InputPrompt {
6950            buf: String::new(),
6951            cursor_byte: 0,
6952            menu: None,
6953            status: status_basic(),
6954            attachments: Vec::new(),
6955        });
6956        let baseline = r.current_footer_rows();
6957        r.render(UiLine::InputPrompt {
6958            buf: "[Image #1] [Image #2]".into(),
6959            cursor_byte: 0,
6960            menu: None,
6961            status: status_basic(),
6962            attachments: vec![1, 2],
6963        });
6964        assert_eq!(
6965            r.current_footer_rows(),
6966            baseline + 2,
6967            "two attachments must add exactly two preview rows"
6968        );
6969    }
6970
6971    /// Regression: SGR (`\x1b[31m…\x1b[39m`) embedded in a
6972    /// `UiLine::CommandOutput` payload — emitted by the `/codingplan`
6973    /// SetupReport for locked-model rows — must reach the cell grid
6974    /// as a `CellStyle::fg = Some(DarkRed)` span rather than landing
6975    /// as literal `^[[31m` characters. Without the SGR-aware
6976    /// CommandOutput path in retained-mode, locked rows render
6977    /// without the colour cue, defeating the visual signal the user
6978    /// asked for.
6979    #[test]
6980    fn retained_command_output_renders_sgr_colour() {
6981        let (mut r, _buf) = new_capturing(80, 24);
6982        // Construct the exact byte sequence the `Msg::CpLocked`
6983        // template produces: red-fg open, visible content, default-fg
6984        // close. PAD_COL (2 spaces) on the left is added by
6985        // push_body_text_sgr; the template-level 6-space indent stays
6986        // on the visible side.
6987        let line = "      \x1b[31m✗ GLM-5.1  (requires Pro plan or higher)\x1b[39m\n";
6988        r.render(UiLine::CommandOutput(line.into()));
6989
6990        // Find the row containing the locked-model name and check
6991        // every glyph cell up to the closing SGR is DarkRed.
6992        let mut found_red = false;
6993        for row in &r.body_lines {
6994            let text: String = row.iter().map(|c| c.ch).collect();
6995            if text.contains("GLM-5.1") {
6996                for cell in row {
6997                    // Skip the leading PAD_COL spaces (no colour applied
6998                    // before SGR fires) — only assert the styled span.
6999                    if cell.ch == ' ' && cell.style.fg.is_none() {
7000                        continue;
7001                    }
7002                    assert_eq!(
7003                        cell.style.fg,
7004                        Some(Color::DarkRed),
7005                        "cell '{}' in locked row must carry DarkRed fg, got {:?}",
7006                        cell.ch, cell.style.fg,
7007                    );
7008                }
7009                found_red = true;
7010                break;
7011            }
7012        }
7013        assert!(
7014            found_red,
7015            "no row containing 'GLM-5.1' found in body_lines:\n{:?}",
7016            r.body_lines
7017                .iter()
7018                .map(|row| row.iter().map(|c| c.ch).collect::<String>())
7019                .collect::<Vec<_>>()
7020        );
7021
7022        // And the raw `^[[31m` characters must NOT appear as cells —
7023        // that's the bug we're guarding against.
7024        for row in &r.body_lines {
7025            let text: String = row.iter().map(|c| c.ch).collect();
7026            assert!(
7027                !text.contains("[31m"),
7028                "SGR bytes leaked into cells as literal text: {:?}",
7029                text,
7030            );
7031        }
7032    }
7033
7034    /// Regression: after approving a bash tool call, the `● Bash(cmd)` row
7035    /// and the `└ [elapsed: …]` result row should be adjacent with no
7036    /// blank line between them. User reported a visible blank gap after
7037    /// pressing Y on the approval prompt.
7038    #[test]
7039    fn retained_approval_pop_then_result_no_blank_gap() {
7040        let (mut r, buf) = new_capturing(80, 24);
7041        let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
7042        let status = status_basic();
7043
7044        // Seed a full frame so footer is painted.
7045        r.render(UiLine::InputPrompt {
7046            buf: String::new(),
7047            cursor_byte: 0,
7048            menu: None,
7049            status: status.clone(),
7050            attachments: Vec::new(),
7051        });
7052        r.flush_deferred();
7053        drain_into_vterm(&buf, &mut vterm);
7054
7055        // Simulate: ToolCallStarted → inflight spinner for Bash
7056        r.render(UiLine::ToolCallInFlight {
7057            id: "call-1".into(),
7058            name: "Bash".into(),
7059            detail: "rm -f /tmp/test.txt".into(),
7060        });
7061        r.flush_deferred();
7062        drain_into_vterm(&buf, &mut vterm);
7063
7064        // Simulate: ApprovalNeeded → commit inflight to ● + show approval prompt
7065        r.render(UiLine::ToolCallCommit {
7066            call_id: Some("call-1".into()),
7067        });
7068        r.render(UiLine::ApprovalPrompt {
7069            tool: "Bash".into(),
7070            detail: "rm -f /tmp/test.txt".into(),
7071        });
7072        r.flush_deferred();
7073        drain_into_vterm(&buf, &mut vterm);
7074
7075        // User presses Y → pop approval prompt
7076        r.pop_approval_prompt();
7077        r.render(UiLine::InputPrompt {
7078            buf: String::new(),
7079            cursor_byte: 0,
7080            menu: None,
7081            status: status.clone(),
7082            attachments: Vec::new(),
7083        });
7084        r.flush_deferred();
7085        drain_into_vterm(&buf, &mut vterm);
7086
7087        // Simulate: ToolCallResult arrives
7088        r.render(UiLine::AssistantLineBreak);
7089        r.render(UiLine::ToolCallCommit {
7090            call_id: Some("call-1".into()),
7091        });
7092        r.render(UiLine::ToolResult {
7093            success: true,
7094            summary: "[elapsed: 0.0s, exit: 0] (2 lines)".into(),
7095        });
7096        r.render(UiLine::InputPrompt {
7097            buf: String::new(),
7098            cursor_byte: 0,
7099            menu: None,
7100            status: status.clone(),
7101            attachments: Vec::new(),
7102        });
7103        r.flush_deferred();
7104        drain_into_vterm(&buf, &mut vterm);
7105
7106        // Debug: print body_lines around the tool and result rows.
7107        let tool_idx = r.body_lines.iter().rposition(|row| {
7108            let text: String = row.iter().map(|c| c.ch).collect();
7109            text.contains("Bash") && text.contains("rm -f")
7110        }).expect("● Bash row should exist in body_lines");
7111
7112        let result_idx = r.body_lines.iter().rposition(|row| {
7113            let text: String = row.iter().map(|c| c.ch).collect();
7114            text.contains("elapsed")
7115        }).expect("└ result row should exist in body_lines");
7116
7117        eprintln!("body_lines around tool row:");
7118        for i in tool_idx.saturating_sub(2)..=result_idx+2 {
7119            if let Some(row) = r.body_lines.get(i) {
7120                let text: String = row.iter().map(|c| c.ch).collect();
7121                eprintln!("  [{}] {:?} (blank={})", i, text, row.is_empty());
7122            }
7123        }
7124
7125        // Check body_lines: there should be no blank row between the
7126        // ● Bash row and the └ result row.
7127        assert_eq!(
7128            result_idx,
7129            tool_idx + 1,
7130            "result row should be immediately after tool row, but found gap.\n\
7131             body_lines around tool row:\n  {:?}\n  {:?}\n  {:?}",
7132            r.body_lines.get(tool_idx).map(|row| row.iter().map(|c| c.ch).collect::<String>()),
7133            r.body_lines.get(tool_idx + 1).map(|row| row.iter().map(|c| c.ch).collect::<String>()),
7134            r.body_lines.get(tool_idx + 2).map(|row| row.iter().map(|c| c.ch).collect::<String>()),
7135        );
7136
7137        // Also check the virtual terminal: the ● Bash row and └ result row
7138        // should be on adjacent terminal rows with no blank row between them.
7139        eprintln!("vterm dump:\n{}", vterm.dump());
7140        let bash_term_row = (0..vterm.height() as usize)
7141            .find(|&i| vterm.row_text(i).contains("Bash") && vterm.row_text(i).contains("rm"))
7142            .expect("Bash row should be on terminal");
7143        let result_term_row = (0..vterm.height() as usize)
7144            .find(|&i| vterm.row_text(i).contains("elapsed"))
7145            .expect("result row should be on terminal");
7146
7147        assert_eq!(
7148            result_term_row,
7149            bash_term_row + 1,
7150            "result should be on terminal row immediately below Bash row.\n\
7151             Bash row {}: {:?}\n\
7152             Row below: {:?}\n\
7153             Result row {}: {:?}\n\
7154             dump:\n{}",
7155            bash_term_row,
7156            vterm.row_text(bash_term_row),
7157            vterm.row_text(bash_term_row + 1),
7158            result_term_row,
7159            vterm.row_text(result_term_row),
7160            vterm.dump(),
7161        );
7162    }
7163
7164    /// Regression: when a long Bash command wraps to multiple terminal
7165    /// rows, the inflight spinner `⠙ Bash(...)` may occupy 2+ body rows.
7166    /// After `ToolCallCommit` freezes it to `● Bash(...)`, the old
7167    /// spinner rows must all be erased — otherwise the user sees BOTH
7168    /// `⠙ Bash(...)` and `● Bash(...)` on screen at the same time.
7169    #[test]
7170    fn retained_commit_inflight_erases_all_spinner_rows() {
7171        // Use a narrow terminal so the command wraps to 2+ rows.
7172        let (mut r, buf) = new_capturing(40, 24);
7173        let mut vterm = crate::test_term::VirtualTerminal::new(40, 24);
7174        let status = status_basic();
7175
7176        // Seed a full frame so footer is painted.
7177        r.render(UiLine::InputPrompt {
7178            buf: String::new(),
7179            cursor_byte: 0,
7180            menu: None,
7181            status: status.clone(),
7182            attachments: Vec::new(),
7183        });
7184        r.flush_deferred();
7185        drain_into_vterm(&buf, &mut vterm);
7186
7187        // ToolCallInFlight with a long command that wraps to 2 rows.
7188        let long_detail = "rm -rf /very/long/path/that/wraps/to/multiple/rows/on/40col/terminal";
7189        r.render(UiLine::ToolCallInFlight {
7190            id: "call-1".into(),
7191            name: "Bash".into(),
7192            detail: long_detail.into(),
7193        });
7194        r.flush_deferred();
7195        drain_into_vterm(&buf, &mut vterm);
7196
7197        // Confirm the inflight spinner occupies more than 1 body row.
7198        assert!(
7199            r.inflight_tool_rows > 1,
7200            "inflight spinner should occupy multiple rows for a long command on 40-col terminal, \
7201             but inflight_tool_rows = {}",
7202            r.inflight_tool_rows,
7203        );
7204
7205        // Now commit the inflight spinner (simulates ApprovalNeeded → ToolCallCommit).
7206        r.render(UiLine::ToolCallCommit {
7207            call_id: Some("call-1".into()),
7208        });
7209        r.flush_deferred();
7210        drain_into_vterm(&buf, &mut vterm);
7211
7212        // Check body_lines: there should be exactly one row with "● Bash"
7213        // and NO row with a spinner glyph (⠙ or similar Braille pattern).
7214        let bash_rows: Vec<_> = r.body_lines.iter()
7215            .enumerate()
7216            .filter(|(_, row)| {
7217                let text: String = row.iter().map(|c| c.ch).collect();
7218                text.contains("Bash")
7219            })
7220            .collect();
7221
7222        assert_eq!(
7223            bash_rows.len(),
7224            1,
7225            "there should be exactly 1 Bash row in body_lines, found {}:\n{:?}",
7226            bash_rows.len(),
7227            bash_rows.iter().map(|(i, row)| (i, row.iter().map(|c| c.ch).collect::<String>())).collect::<Vec<_>>(),
7228        );
7229
7230        // The committed row should start with ● (U+25CF), not a spinner glyph.
7231        let (idx, bash_row) = bash_rows[0];
7232        let first_ch = bash_row.first().map(|c| c.ch).unwrap_or('\0');
7233        assert_eq!(
7234            first_ch, '\u{25cf}',
7235            "committed Bash row at index {} should start with ●, found '{}'",
7236            idx, first_ch,
7237        );
7238
7239        // Check virtual terminal: no row should contain a Braille spinner
7240        // glyph (U+2800–U+28FF) alongside "Bash".
7241        for i in 0..vterm.height() as usize {
7242            let text = vterm.row_text(i);
7243            if text.contains("Bash") {
7244                let has_spinner = text.chars().any(|c| c >= '\u{2800}' && c <= '\u{28FF}');
7245                assert!(
7246                    !has_spinner,
7247                    "terminal row {} still has a spinner glyph alongside Bash: {:?}",
7248                    i, text,
7249                );
7250            }
7251        }
7252    }
7253}