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(¶ms, &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}