Skip to main content

mermaid_cli/render/
mod.rs

1//! Pure view: `fn render(&State, &mut RenderCache, &mut Frame)`.
2//!
3//! Three contracts:
4//!   1. Never mutates `State`. The view is fully derived.
5//!   2. Never performs I/O. All state — model lists, MCP status,
6//!      file contents — is whatever the reducer put in `State`.
7//!   3. Never holds a `&mut App` / `&mut anything` other than the
8//!      `Frame` ratatui owns and the render-layer `RenderCache`
9//!      (which is memoization + scroll-position bookkeeping, not
10//!      reducer state).
11//!
12//! Signature: `fn render(&State, &mut RenderCache, &mut Frame)`.
13//! The `&mut RenderCache` is memoization only (markdown parse
14//! cache, scroll position, theme choice) — it never affects
15//! reducer outcomes or persisted state.
16
17pub mod markdown;
18pub mod theme;
19pub mod widgets;
20pub mod wrap;
21
22use ratatui::{
23    Frame,
24    layout::{Margin, Rect},
25    style::Style,
26    text::{Line, Span},
27};
28use rustc_hash::FxHashMap;
29
30use mermaid_domain::{State, TurnState};
31use mermaid_model::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
32
33use widgets::{
34    ChatState, ChatWidget, GenerationStatus, InputState, InputWidget, SlashPaletteWidget,
35    StatusWidget, build_status_lines,
36};
37
38/// Transient render-layer state that lives across frames but isn't
39/// reducer state. Owned by `app::run_interactive`; passed as `&mut`
40/// to `render()` per frame.
41///
42/// Contents are pure memoization + UI affordances (scroll position,
43/// wrapped-line cache, theme choice). Nothing here affects what the
44/// reducer sees or what ends up on disk — the cache can be dropped
45/// and rebuilt from `&State` at any time.
46pub struct RenderCache {
47    pub chat: ChatState,
48    /// Shell label for `execute_command` rows (`Bash(...)`/`PowerShell(...)`).
49    /// A field, not a `HostShell::current()` read at use sites, for the same
50    /// reason `hostname`/`username` are: the snapshot rig pins it (`Posix`)
51    /// so one set of `.snap` files serves every platform.
52    pub host_shell: mermaid_runtime::HostShell,
53    /// Per-message render cache: `(content, theme, width)` hash → fully wrapped,
54    /// role-prefixed assistant lines, so committed messages aren't re-parsed or
55    /// re-wrapped every frame (#134).
56    pub wrapped_line_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
57    /// Memoized stitched transcript: committed `Continuation` messages folded
58    /// into their predecessor bubble and spent `RecoveryNudge` notes hidden.
59    /// Rebuilt only when the committed log changes (keyed by a content
60    /// fingerprint) — without the memo, every idle frame after the first
61    /// auto-continue would deep-clone the whole transcript forever.
62    stitched: Option<StitchedMemo>,
63    pub theme: theme::Theme,
64    /// `(state.ui.theme, state.ui.no_color)` the current `theme` was resolved
65    /// from. `render()` diffs it each frame and swaps the palette (clearing
66    /// `wrapped_line_cache`) only on change, so `/theme` repaints instantly
67    /// without per-frame `Theme` construction. `None` (fresh cache) keeps the
68    /// `Theme::dark()` default until the first frame resolves it.
69    applied_theme: Option<(mermaid_domain::ThemeChoice, bool)>,
70    /// Host + user for the status bar's `user@host:cwd` line, injected once at
71    /// startup so `StatusWidget::render` doesn't hit the environment on every
72    /// frame (#55). Process-constant, so caching here is exact. The shell reads
73    /// the environment and passes the result to [`RenderCache::new`]; render
74    /// itself never does, which is what keeps this module a pure function of
75    /// its inputs.
76    pub hostname: String,
77    pub username: String,
78    /// App version for the status footer. Defaults to the compile-time crate
79    /// version; the snapshot suite pins it (like hostname/username) so pinned
80    /// frames survive release bumps.
81    pub version: String,
82    /// F13: last `state.ui.mouse_scroll_accum` value we applied to
83    /// `chat.scroll_up/down`. Diffing lets the reducer stay pure —
84    /// it just publishes a counter; render owns the chat-state side.
85    last_mouse_scroll_accum: i32,
86    /// Last `state.ui.scroll_to_bottom_seq` we acted on; a bump (keyboard
87    /// `End`) means resume auto-follow / jump to the newest message.
88    last_scroll_to_bottom_seq: u32,
89}
90
91/// See [`RenderCache::stitched`].
92struct StitchedMemo {
93    key: u64,
94    messages: Vec<mermaid_model::models::ChatMessage>,
95}
96
97impl RenderCache {
98    /// `hostname` and `username` are parameters rather than environment reads
99    /// because this module is covered by the layering guard: a `std::env` call
100    /// anywhere under `src/render` is impurity in a tree that is supposed to be
101    /// a pure function of `State`. The shell resolves them once at startup
102    /// (`app::run::host_identity`); the snapshot and bench rigs pass pinned
103    /// literals, which is how their frames stay byte-stable across machines.
104    #[must_use]
105    pub fn new(hostname: String, username: String) -> Self {
106        Self {
107            chat: ChatState::new(),
108            host_shell: mermaid_runtime::HostShell::current(),
109            wrapped_line_cache: FxHashMap::default(),
110            theme: theme::Theme::dark(),
111            hostname,
112            username,
113            version: env!("CARGO_PKG_VERSION").to_string(),
114            stitched: None,
115            applied_theme: None,
116            last_mouse_scroll_accum: 0,
117            last_scroll_to_bottom_seq: 0,
118        }
119    }
120}
121
122/// The entrypoint. Call once per render pass from the main loop.
123#[expect(
124    clippy::too_many_lines,
125    reason = "predates the lint; see .github/baselines/expect_budget.txt"
126)]
127pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
128    // Resolve the palette from reducer state: NO_COLOR beats the theme
129    // choice (colors off entirely); otherwise `/theme` picks dark/light.
130    let want = (state.ui.theme, state.ui.no_color);
131    if rstate.applied_theme != Some(want) {
132        rstate.theme = if state.ui.no_color {
133            theme::Theme::plain()
134        } else {
135            match state.ui.theme {
136                mermaid_domain::ThemeChoice::Dark => theme::Theme::dark(),
137                mermaid_domain::ThemeChoice::Light => theme::Theme::light(),
138            }
139        };
140        // The wrapped-line cache is theme-keyed, but drop stale entries
141        // eagerly rather than letting the old palette's lines linger.
142        rstate.wrapped_line_cache.clear();
143        rstate.applied_theme = Some(want);
144    }
145
146    // F13: consume any pending mouse-scroll accumulator. The reducer
147    // publishes a monotonic counter on `ui.mouse_scroll_accum`; we
148    // apply the delta to `ChatState` since the reducer isn't allowed
149    // to touch render-layer state directly.
150    let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
151    if pending > 0 {
152        rstate.chat.scroll_up(pending as u16);
153    } else if pending < 0 {
154        rstate.chat.scroll_down((-pending) as u16);
155    }
156    rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
157    // Keyboard End: a bumped counter means jump back to the newest message.
158    if state.ui.scroll_to_bottom_seq != rstate.last_scroll_to_bottom_seq {
159        rstate.chat.resume_auto_scroll();
160        rstate.last_scroll_to_bottom_seq = state.ui.scroll_to_bottom_seq;
161    }
162
163    // Interrupt modals, decided up front because they reshape the whole
164    // bottom of the screen. Approval wins over question when both queue up.
165    let approval_item = state.pending_approval.front();
166    let question_item = if approval_item.is_none() {
167        state.pending_question.front()
168    } else {
169        None
170    };
171    // Claude Code parity: while the question modal is up it owns the bottom
172    // of the screen — no status spinner, no task band, no input box. Keys
173    // route exclusively to the modal anyway (see `handle_question_key`), so
174    // the hidden input is inert, not just invisible.
175    let question_modal_open = question_item.is_some();
176
177    // Input height: content-aware, respecting CJK/emoji widths.
178    //
179    // Must come from the same layout the widget and the caret use. This once
180    // counted cells to the hard edge in a loop of its own, which ignored that
181    // the layout breaks on whitespace and so starts a row sooner — every input
182    // whose last word wrapped got a box one row too short, clipping that line
183    // and stranding the caret on a row the box did not have.
184    //
185    // The input box spans the full frame width, so `frame.area().width` is the
186    // box width here; the layout has not been split yet, since this height is
187    // one of its inputs.
188    let input_lines = widgets::rendered_row_count(
189        &state.ui.input_buffer,
190        frame.area().width.saturating_sub(2) as usize,
191    )
192    .min(5);
193    let input_height = if question_modal_open {
194        0
195    } else {
196        (input_lines + 2) as u16
197    };
198
199    // Build the status-line rows up front (wrapped to the terminal width) so
200    // the layout reserves exactly the height they need — a long task headline
201    // plus the trailing `(esc to interrupt …)` fold onto continuation rows
202    // instead of bleeding off the right edge.
203    let status_lines = if question_modal_open {
204        Vec::new()
205    } else if state.is_busy() {
206        // Elapsed is computed from the injected `state.now` (stamped every tick),
207        // not the live wall clock, so the rendered frame is a pure function of
208        // State (Cause 3). Visually identical — both resolve to whole seconds.
209        let now_sys = std::time::SystemTime::from(state.now);
210        let elapsed_since =
211            |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
212        let elapsed_secs = match &state.turn {
213            // A model run (generating + executing tools) anchors to the run start
214            // so the timer spans the whole agentic loop, not just this step.
215            TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
216                state
217                    .runtime
218                    .run_started
219                    .map_or_else(|| elapsed_since(*started), elapsed_since)
220            },
221            TurnState::Compacting { started, .. } => elapsed_since(*started),
222            TurnState::Cancelling { since, .. } => elapsed_since(*since),
223            TurnState::Idle => 0,
224        };
225        let (agent_rows, status_override, bg_available) = agent_panel_data(state);
226        // Claude Code parity: while a checklist task is in_progress its
227        // active_form IS the spinner headline ("Wiring the broker…"), with
228        // the executing tool folded in after a separator.
229        let task_headline = state
230            .session
231            .conversation
232            .tasks
233            .active()
234            .map(|t| t.active_form.clone());
235        // Tokens generated so far this run: completed phases carry real
236        // provider output counts via `run_tokens` (chars/4 only when a phase
237        // reported no usage); the live phase's char-based count rides on top
238        // and reconciles to the provider number at its `Done`. While tools
239        // run, running subagents' throttled live counts ride on top the same
240        // way so the counter keeps climbing instead of freezing for the whole
241        // child run (they reconcile when the child's real usage folds in).
242        // Marked `~` whenever any estimated component is included.
243        let committed = state.runtime.run_tokens;
244        let live_child_tokens: usize = state.ui.live_tool_status.values().map(|l| l.tokens).sum();
245        let (tokens_display, tokens_estimated) = match &state.turn {
246            TurnState::Generating { tokens, .. } => (committed.output_tokens + *tokens, true),
247            TurnState::ExecutingTools { .. } => (
248                committed.output_tokens + live_child_tokens,
249                committed.contains_estimate || live_child_tokens > 0,
250            ),
251            _ => (0, false),
252        };
253        build_status_lines(
254            GenerationStatus::from_turn(&state.turn),
255            elapsed_secs,
256            tokens_display,
257            tokens_estimated,
258            status_override.as_deref(),
259            &agent_rows,
260            bg_available,
261            task_headline.as_deref(),
262            &state.ui.queued_messages,
263            exit_armed(state),
264            &rstate.theme,
265            // Match the 1-cell horizontal pad the status zone is rendered with.
266            frame.area().width.saturating_sub(2),
267        )
268    } else if !state.runtime.background_agents.is_empty() {
269        // Idle, but detached background agents are still running: keep their
270        // rows visible between turns (no spinner head).
271        let (agent_rows, _, _) = agent_panel_data(state);
272        build_status_lines(
273            GenerationStatus::Idle,
274            0,
275            0,
276            false,
277            None,
278            &agent_rows,
279            false,
280            None,
281            &state.ui.queued_messages,
282            exit_armed(state),
283            &rstate.theme,
284            frame.area().width.saturating_sub(2),
285        )
286    } else {
287        Vec::new()
288    };
289
290    // Reserve the status zone's height to match its row count, but never so much
291    // that the input box or bottom bar get evicted on a short terminal: keep room
292    // for the chat floor (Min 10), the input box, and the bottom bar (≥2). (The
293    // trailing Length zones would otherwise starve before the Min(10) chat zone.)
294    let status_reserve = 10 + input_height + 2;
295    let status_line_height = (status_lines.len() as u16)
296        .min(14)
297        .min(frame.area().height.saturating_sub(status_reserve));
298
299    // Task checklist band, directly under the status line. Same starvation
300    // guard as the status zone: chat floor + input + bottom bar always win.
301    // The `⎿` connector only draws when the status zone above actually
302    // renders (attached); collapsed + detached shows nothing at all.
303    let tasks_store = &state.session.conversation.tasks;
304    let tasks_attached = status_line_height > 0;
305    let tasks_zone_height = if question_modal_open {
306        0
307    } else if widgets::tasks_visible(
308        tasks_store,
309        &state.turn,
310        state.ui.tasks_collapsed,
311        tasks_attached,
312    ) {
313        widgets::tasks_height(tasks_store, state.ui.tasks_collapsed).min(
314            frame
315                .area()
316                .height
317                .saturating_sub(status_reserve + status_line_height),
318        )
319    } else {
320        0
321    };
322
323    // Bottom region: one of three widgets based on UI mode.
324    //   - ConversationList picker: 12-line pane.
325    //   - Slash palette (input starts with `/`): 3–10 lines based on
326    //     filter match count.
327    //   - Otherwise: 2-line status bar.
328    // Precedence: approval modal > confirm modal > ConversationList picker >
329    // slash palette > status bar. Approvals/confirms are interrupts that
330    // overlay regardless of input mode. (`approval_item`/`question_item`
331    // were decided up front, before the status/input zones were sized.)
332    let confirm_open =
333        approval_item.is_none() && question_item.is_none() && state.confirm.is_some();
334    let conv_list_open = approval_item.is_none()
335        && question_item.is_none()
336        && !confirm_open
337        && matches!(
338            state.ui.mode,
339            mermaid_domain::UiMode::ConversationList { .. }
340        );
341    let rewind_open = approval_item.is_none()
342        && question_item.is_none()
343        && !confirm_open
344        && matches!(state.ui.mode, mermaid_domain::UiMode::RewindPicker { .. });
345    let plan_config_open = approval_item.is_none()
346        && question_item.is_none()
347        && !confirm_open
348        && matches!(state.ui.mode, mermaid_domain::UiMode::PlanConfig { .. });
349    let model_picker_open = approval_item.is_none()
350        && question_item.is_none()
351        && !confirm_open
352        && matches!(state.ui.mode, mermaid_domain::UiMode::ModelPicker { .. });
353    let file_picker_open = approval_item.is_none()
354        && question_item.is_none()
355        && !confirm_open
356        && !conv_list_open
357        && !rewind_open
358        && !plan_config_open
359        && state.ui.file_picker_open();
360    let palette_open = approval_item.is_none()
361        && question_item.is_none()
362        && !confirm_open
363        && !conv_list_open
364        && !file_picker_open
365        && state.ui.input_buffer.starts_with('/');
366    let bottom_height = if let Some(item) = approval_item {
367        // border(2) + body lines + blank(1) + 3 option lines
368        let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
369        2 + body_lines + 1 + 3
370    } else if let Some(qset) = question_item {
371        // The modal spans the full frame width; wrapping must be measured at
372        // the same width it is drawn at or the reserved zone clips it.
373        widgets::question_modal_height(qset, &rstate.theme, frame.area().width)
374    } else if confirm_open {
375        6
376    } else if conv_list_open || rewind_open {
377        12
378    } else if plan_config_open {
379        widgets::PLAN_CONFIG_HEIGHT
380    } else if model_picker_open {
381        widgets::MODEL_PICKER_HEIGHT
382    } else if file_picker_open {
383        let rows = state.ui.file_picker_matches.len().clamp(1, 8);
384        (rows as u16) + 2
385    } else if palette_open {
386        let typed = state
387            .ui
388            .input_buffer
389            .trim_start_matches('/')
390            .split_whitespace()
391            .next()
392            .unwrap_or("");
393        let row_count =
394            mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands)
395                .len()
396                .clamp(1, 8);
397        (row_count as u16) + 2
398    } else {
399        2
400    };
401
402    // 4-zone vertical layout: chat / status line / input / bottom. Pasted images
403    // are inline `[Image #N]` tokens in the input now, so there's no separate
404    // attachment zone.
405    use ratatui::layout::{Constraint, Direction, Layout};
406    let chunks = Layout::default()
407        .direction(Direction::Vertical)
408        .constraints([
409            Constraint::Min(10),
410            Constraint::Length(status_line_height),
411            Constraint::Length(tasks_zone_height),
412            Constraint::Length(input_height),
413            Constraint::Length(bottom_height),
414        ])
415        .split(frame.area());
416
417    // Chat area with 1-cell horizontal padding.
418    let chat_area = chunks[0].inner(Margin {
419        horizontal: 1,
420        vertical: 0,
421    });
422    // A live toast borrows the chat area's LAST row rather than claiming its
423    // own layout slot: the zone above the input already stacks three
424    // conditional bands, and a fourth that appears for two seconds would shove
425    // the whole transcript. Borrowing a row keeps the input box still.
426    let toast = active_toast(state);
427    let (chat_area, toast_area) = match toast {
428        Some(_) if chat_area.height > 1 => (
429            Rect {
430                height: chat_area.height - 1,
431                ..chat_area
432            },
433            Some(Rect {
434                y: chat_area.y + chat_area.height - 1,
435                height: 1,
436                ..chat_area
437            }),
438        ),
439        _ => (chat_area, None),
440    };
441    // Stitch pre-pass: fold auto-continued replies into one bubble and hide
442    // spent recovery nudges. Sessions without either kind skip this entirely
443    // (borrowed slice, no fingerprint); with them, the memo makes idle frames
444    // a hash-check instead of a transcript clone.
445    let committed = state.session.messages();
446    let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
447        let key = stitch_fingerprint(committed);
448        if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
449            rstate.stitched = Some(StitchedMemo {
450                key,
451                messages: stitch_committed(committed),
452            });
453        }
454        &rstate
455            .stitched
456            .as_ref()
457            .expect("stitched memo populated above")
458            .messages
459    } else {
460        committed
461    };
462    let live_messages = build_live_messages(base, &state.turn, state.now, rstate.host_shell);
463    // 500ms blink phase for in-flight action dots, from the injected clock
464    // (never the wall clock) so a frame stays a pure function of State.
465    let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
466    let chat_widget = ChatWidget {
467        messages: live_messages.as_ref(),
468        content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
469        theme: &rstate.theme,
470        wrapped_line_cache: &mut rstate.wrapped_line_cache,
471        show_reasoning: state.ui.show_reasoning,
472        blink_on,
473        today: state.now.date_naive(),
474    };
475    frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
476
477    // Toast: right-aligned and dim on the row it borrowed, so it reads as
478    // feedback beside the input rather than as a transcript entry.
479    if let (Some(text), Some(area)) = (toast, toast_area) {
480        frame.render_widget(
481            ratatui::widgets::Paragraph::new(Line::from(Span::styled(
482                text,
483                Style::new().fg(rstate.theme.colors.info.to_color()),
484            )))
485            .alignment(ratatui::layout::Alignment::Right),
486            area,
487        );
488    }
489
490    // Status line for every active turn (built above, already fit to width).
491    // Indented 1 cell to align with the chat column's 1-cell pad.
492    if !status_lines.is_empty() {
493        let status_area = chunks[1].inner(Margin {
494            horizontal: 1,
495            vertical: 0,
496        });
497        frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
498    }
499
500    // Task checklist band (chunks[2]), hanging under the spinner line.
501    if tasks_zone_height > 0 {
502        let tasks_area = chunks[2].inner(Margin {
503            horizontal: 1,
504            vertical: 0,
505        });
506        let lines = widgets::build_task_lines(
507            tasks_store,
508            state.ui.tasks_collapsed,
509            tasks_attached,
510            tasks_area.width,
511            &rstate.theme,
512        );
513        frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
514    }
515
516    // Input box (chunks[3]; the attachment zone is gone, the task band
517    // precedes). Collapsed entirely — including the terminal cursor — while
518    // the question modal owns the bottom of the screen.
519    if !question_modal_open {
520        let input_widget = InputWidget {
521            input: state.ui.input_buffer.as_str(),
522            showing_command_hints: state.ui.input_buffer.starts_with('/'),
523            theme: &rstate.theme,
524            reasoning_active: state.session.reasoning != ReasoningLevel::None,
525            exit_armed: exit_armed(state),
526            rewind_armed: rewind_armed(state),
527        };
528        let mut input_widget_state = InputState {
529            cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
530        };
531        frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
532
533        // Cursor tracks the input caret.
534        let input_area = chunks[3];
535        let content_width = input_area.width.saturating_sub(2) as usize;
536        let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
537            &state.ui.input_buffer,
538            state.ui.input_cursor.min(state.ui.input_buffer.len()),
539            content_width,
540        );
541        frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
542    }
543
544    // Effective reasoning level. Per-model supported_reasoning cap
545    // isn't threaded through `State` yet; defaults to no snap
546    // indicator until `ProviderFactory::capabilities` reaches here.
547    let requested = state.session.reasoning;
548    let effective = match supported_reasoning_for(state) {
549        Some(ReasoningCapability::Levels(supp)) => {
550            nearest_effort(requested, &supp).unwrap_or(requested)
551        },
552        _ => requested,
553    };
554    let requested_level = if effective == requested {
555        None
556    } else {
557        Some(requested)
558    };
559
560    // Bottom: conversation-list picker, slash-palette overlay, or
561    // persistent status bar — whichever the UI mode dictates.
562    if let Some(item) = state.pending_approval.front() {
563        use widgets::ApprovalModalWidget;
564        // Content-bearing external tools (type_text, MCP, …) are
565        // non-allowlistable: the gate leaves their scope empty, and we omit the
566        // "don't ask again" option so the user can't blanket-approve them (#6, #31).
567        let options = if item.allowlist_scope.is_empty() {
568            vec!["1. Yes".to_string(), "2. No  (Esc)".to_string()]
569        } else {
570            vec![
571                "1. Yes".to_string(),
572                format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
573                "3. No  (Esc)".to_string(),
574            ]
575        };
576        let widget = ApprovalModalWidget {
577            theme: &rstate.theme,
578            title: format!("Approval required — {}  [{}]", item.tool, item.risk),
579            body: item.prompt.as_str(),
580            options,
581            selected_index: Some(item.selected_option),
582            accent: rstate.theme.colors.warning.to_color(),
583        };
584        frame.render_widget(widget, chunks[4]);
585    } else if let Some(qset) = state.pending_question.front() {
586        use widgets::QuestionModalWidget;
587        let widget = QuestionModalWidget {
588            theme: &rstate.theme,
589            set: qset,
590            width: chunks[4].width,
591        };
592        frame.render_widget(widget, chunks[4]);
593    } else if let Some(confirm) = &state.confirm {
594        use widgets::ApprovalModalWidget;
595        let widget = ApprovalModalWidget {
596            theme: &rstate.theme,
597            title: "Confirm".to_string(),
598            body: confirm.prompt.as_str(),
599            options: vec!["y. Yes".to_string(), "n. No  (Esc)".to_string()],
600            selected_index: None,
601            accent: rstate.theme.colors.warning.to_color(),
602        };
603        frame.render_widget(widget, chunks[4]);
604    } else if let mermaid_domain::UiMode::ModelPicker {
605        candidates,
606        query,
607        cursor,
608        loading,
609    } = &state.ui.mode
610    {
611        use widgets::ModelPickerWidget;
612        let matches = mermaid_domain::reducer::filter_model_choices(candidates, query);
613        let widget = ModelPickerWidget {
614            theme: &rstate.theme,
615            matches: &matches,
616            query,
617            cursor: *cursor,
618            loading: *loading,
619            current: &state.session.model_id,
620        };
621        frame.render_widget(widget, chunks[4]);
622    } else if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
623        use widgets::ConversationListWidget;
624        let widget = ConversationListWidget {
625            theme: &rstate.theme,
626            candidates,
627            cursor: *cursor,
628        };
629        frame.render_widget(widget, chunks[4]);
630    } else if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
631        use widgets::RewindPickerWidget;
632        let widget = RewindPickerWidget {
633            theme: &rstate.theme,
634            candidates,
635            cursor: *cursor,
636        };
637        frame.render_widget(widget, chunks[4]);
638    } else if let mermaid_domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
639        use widgets::PlanConfigWidget;
640        let widget = PlanConfigWidget {
641            theme: &rstate.theme,
642            plan: &state.settings.plan,
643            session_model: &state.session.model_id,
644            cursor: *cursor,
645        };
646        frame.render_widget(widget, chunks[4]);
647    } else if file_picker_open {
648        use widgets::FilePickerWidget;
649        let widget = FilePickerWidget {
650            theme: &rstate.theme,
651            matches: &state.ui.file_picker_matches,
652            selected_index: state.ui.file_picker_cursor.unwrap_or(0),
653            loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
654        };
655        frame.render_widget(widget, chunks[4]);
656    } else if palette_open {
657        let typed = state
658            .ui
659            .input_buffer
660            .trim_start_matches('/')
661            .split_whitespace()
662            .next()
663            .unwrap_or("");
664        let entries = mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands);
665        let palette_widget = SlashPaletteWidget {
666            theme: &rstate.theme,
667            entries,
668            selected_index: state.ui.palette_cursor.unwrap_or(0),
669        };
670        frame.render_widget(palette_widget, chunks[4]);
671    } else {
672        let cwd = state.cwd.display().to_string();
673        let status_widget = StatusWidget {
674            theme: &rstate.theme,
675            working_dir: &cwd,
676            hostname: &rstate.hostname,
677            username: &rstate.username,
678            version: &rstate.version,
679            context_usage: state.session.context_usage.as_ref(),
680            model_name: &state.session.model_id,
681            reasoning_level: effective,
682            requested_level,
683            // Planning IS the mode — `plan` renders through the same
684            // `safety: <mode>` segment as every other level.
685            safety_mode: state.session.safety_mode,
686        };
687        frame.render_widget(status_widget, chunks[4]);
688    }
689}
690
691/// Can a `Continuation` message be folded into this predecessor? Guards the
692/// stitch against non-bubble assistants: a compaction checkpoint's assistant
693/// half (`ContextCheckpoint`, rendered as an event block), the empty
694/// error-carrier message, or an assistant that ended in tool calls.
695/// `pub(crate)` so the chat widget applies the same rule when deciding to
696/// draw a streaming continuation without a fresh bubble prefix.
697pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
698    prev.role == mermaid_model::models::MessageRole::Assistant
699        && matches!(
700            prev.kind,
701            mermaid_model::models::ChatMessageKind::Normal
702                | mermaid_model::models::ChatMessageKind::Continuation
703        )
704        && prev.tool_calls.is_none()
705}
706
707/// Identify the transcript the chat widget is about to paint, in O(1).
708///
709/// The widget's frame memo needs a key that changes whenever the rendered
710/// content changes. Hashing every message did that honestly but cost
711/// O(transcript) on every frame — 34% of an idle frame at a 2000-message
712/// scrollback, and the last thing scaling with history size.
713///
714/// Three inputs, all constant-time:
715/// - `ConversationHistory::revision`, bumped by the accessor that hands out
716///   `&mut` to the messages, so no committed change can escape it.
717/// - The messages `build_live_messages` derived on top of the committed slice
718///   (a streaming partial, or a live action row) — at most one, and not part
719///   of history, so it must be hashed directly.
720/// - The blink phase, folded in ONLY while a turn is active. Running action
721///   dots exist during a turn, and an active turn already invalidates the memo
722///   continuously; folding it in unconditionally would invalidate twice a
723///   second on every idle frame, which is exactly what this exists to avoid.
724///   The cosmetic cost is that a `Running` action left behind by a cancelled
725///   run stops blinking once the session goes idle.
726fn chat_content_key(
727    state: &State,
728    base: &[mermaid_model::models::ChatMessage],
729    live: &[mermaid_model::models::ChatMessage],
730    blink_on: bool,
731) -> u64 {
732    use std::hash::{Hash, Hasher};
733    let mut h = rustc_hash::FxHasher::default();
734    state.session.conversation.revision().hash(&mut h);
735    // The stitch is a pure function of committed history, but its output
736    // length is not, so fold it in rather than assuming.
737    base.len().hash(&mut h);
738    for msg in live.iter().skip(base.len()) {
739        msg.content.hash(&mut h);
740        msg.thinking.hash(&mut h);
741        std::mem::discriminant(&msg.kind).hash(&mut h);
742        msg.actions.len().hash(&mut h);
743        for action in &msg.actions {
744            action.action_type.hash(&mut h);
745            action.target.hash(&mut h);
746            std::mem::discriminant(&action.result).hash(&mut h);
747        }
748    }
749    if !matches!(state.turn, TurnState::Idle) {
750        blink_on.hash(&mut h);
751    }
752    h.finish()
753}
754
755/// Would the stitch pre-pass change anything the user can see? If not,
756/// rendering borrows the committed slice with zero copies.
757///
758/// Only CONTINUATIONS need it. `RecoveryNudge` and `ContextMarker` are hidden
759/// either way — `ChatWidget` skips exactly those two kinds itself — so
760/// removing them upstream matters only when something downstream inspects a
761/// message's neighbours, and only continuation merging does:
762///
763/// - `stitch_committed` merges a committed `Continuation` into `out.last_mut()`.
764/// - `build_live_messages` merges a LIVE continuation when
765///   `committed.last().is_some_and(mergeable_into)` — and during auto-continue
766///   the last committed message is the "hit the output limit" nudge, which
767///   must be stripped or the merge fails and the partial re-renders as a fresh
768///   bubble with duplicated overlap text. Hence the turn state, not just
769///   history: the live continuation streams BEFORE any `Continuation` is
770///   committed.
771///
772/// Including markers here made this permanently true for any session that ever
773/// changed mode — `ContextMarker` is never swept — which cost a
774/// transcript-sized hash on every frame forever, at ~60 frames per second.
775fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
776    let live_continuation = matches!(
777        turn,
778        TurnState::Generating { continuation, .. } if *continuation
779    );
780    live_continuation
781        || committed
782            .iter()
783            .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
784}
785
786/// Fingerprint of every committed-message field the stitched transcript
787/// depends on. Cheap relative to re-stitching (hashing, no cloning); mirrors
788/// the chat widget's frame fingerprint so in-place mutations that don't
789/// change message count (e.g. an action attached to the last message during a
790/// tool run) still invalidate the memo.
791fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
792    use std::hash::{Hash, Hasher};
793    use std::mem::discriminant;
794
795    let mut h = rustc_hash::FxHasher::default();
796    committed.len().hash(&mut h);
797    for msg in committed {
798        msg.content.hash(&mut h);
799        msg.thinking.hash(&mut h);
800        msg.timestamp.timestamp().hash(&mut h);
801        msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
802        msg.image_numbers
803            .as_ref()
804            .map_or(0, |v| v.len())
805            .hash(&mut h);
806        discriminant(&msg.role).hash(&mut h);
807        discriminant(&msg.kind).hash(&mut h);
808        msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
809        // Actions used to be folded in with `{:?}`, which Debug-formatted the
810        // whole `ToolRunMetadata` — INCLUDING `display_diff`, a full diff
811        // string — for every action on every message, every frame. Since a
812        // persistent `ContextMarker` keeps `needs_stitch` true for the rest of
813        // the session, that ran continuously. Hash the fields that actually
814        // change instead: `display_diff` is captured once at tool-execution
815        // time and never mutates afterward, so its length is a sound stand-in.
816        msg.actions.len().hash(&mut h);
817        for action in &msg.actions {
818            action.action_type.hash(&mut h);
819            action.target.hash(&mut h);
820            discriminant(&action.result).hash(&mut h);
821            discriminant(&action.details).hash(&mut h);
822            action.duration_seconds.map(f64::to_bits).hash(&mut h);
823            if let Some(meta) = &action.metadata {
824                meta.lines_added.hash(&mut h);
825                meta.lines_removed.hash(&mut h);
826                meta.diff_truncated.hash(&mut h);
827                meta.display_diff.as_ref().map(String::len).hash(&mut h);
828            }
829        }
830    }
831    h.finish()
832}
833
834/// The display stitch: fold committed `Continuation` messages into their
835/// predecessor bubble and hide spent `RecoveryNudge` notes, so an
836/// auto-continued reply reads as ONE uninterrupted assistant message.
837///
838/// Display-only — canonical history keeps the separate messages exactly as
839/// they crossed the wire (provider-correct, thinking-signature-safe). Merging
840/// the contents into one string here also means one `parse_markdown` call, so
841/// a code fence cut open by the output cap and re-closed in the continuation
842/// renders as a single intact block. A `Continuation` whose predecessor is
843/// not a mergeable bubble (archived by compaction, wedged system note)
844/// renders as its own message — a graceful seam, never a wrong merge.
845fn stitch_committed(
846    committed: &[mermaid_model::models::ChatMessage],
847) -> Vec<mermaid_model::models::ChatMessage> {
848    let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
849    for msg in committed {
850        if matches!(
851            msg.kind,
852            mermaid_model::models::ChatMessageKind::RecoveryNudge
853                | mermaid_model::models::ChatMessageKind::ContextMarker
854        ) {
855            continue;
856        }
857        if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
858            && let Some(prev) = out.last_mut()
859            && mergeable_into(prev)
860        {
861            merge_continuation(prev, msg);
862            continue;
863        }
864        out.push(msg.clone());
865    }
866    out
867}
868
869/// Fold one continuation segment into the bubble it resumes. The seam gets a
870/// conservative overlap trim (see `continuation_overlap`): a resume-echo of
871/// the previous tail is dropped, anything ambiguous is kept.
872fn merge_continuation(
873    prev: &mut mermaid_model::models::ChatMessage,
874    cont: &mermaid_model::models::ChatMessage,
875) {
876    let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
877    prev.content.push_str(&cont.content[skip..]);
878    if let Some(cont_thinking) = &cont.thinking {
879        match &mut prev.thinking {
880            Some(t) => {
881                t.push_str("\n\n");
882                t.push_str(cont_thinking);
883            },
884            None => prev.thinking = Some(cont_thinking.clone()),
885        }
886    }
887    prev.actions.extend(cont.actions.iter().cloned());
888    if let Some(imgs) = &cont.images {
889        prev.images
890            .get_or_insert_with(Vec::new)
891            .extend(imgs.iter().cloned());
892    }
893    if let Some(nums) = &cont.image_numbers {
894        prev.image_numbers
895            .get_or_insert_with(Vec::new)
896            .extend(nums.iter().copied());
897    }
898    // A continuation that resumed the reply and then called tools carries the
899    // calls; the merged bubble inherits them (the guard ensured prev had none).
900    if cont.tool_calls.is_some() {
901        prev.tool_calls = cont.tool_calls.clone();
902    }
903}
904
905/// Merge the committed message log with the live turn's in-flight view:
906/// partial streamed content from `TurnState::Generating`, or the executing
907/// batch's action rows from `TurnState::ExecutingTools`. The chat widget
908/// renders this as a single stream.
909///
910/// While tools run, each call gets its transcript action row immediately —
911/// completed calls with their real outcome, still-running ones as a
912/// `Running` placeholder whose header dot blinks (Claude Code parity: the
913/// transcript, not the status spinner, names the tool). Two kinds of pending
914/// call are skipped: `agent` (the live agent panel under the spinner carries
915/// them) and `ask_user_question` (the modal IS its in-flight representation;
916/// the question → answer block lands once answered).
917///
918/// `committed` is the (possibly stitched) display transcript. When the live
919/// turn is an auto-continue, the pseudo-message is stamped `Continuation` so
920/// the widget draws it as a prefix-less extension of the previous bubble, and
921/// its leading resume-echo is trimmed against that bubble's tail — the
922/// in-flight reply looks like one message while it streams, not just after
923/// it commits.
924fn build_live_messages<'a>(
925    committed: &'a [mermaid_model::models::ChatMessage],
926    turn: &TurnState,
927    now: chrono::DateTime<chrono::Local>,
928    host_shell: mermaid_runtime::HostShell,
929) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
930    if let TurnState::ExecutingTools {
931        calls, outcomes, ..
932    } = turn
933    {
934        let actions: Vec<mermaid_domain::ActionDisplay> = calls
935            .iter()
936            .zip(outcomes)
937            .filter_map(|(call, outcome)| match outcome {
938                Some(outcome) => Some(mermaid_domain::transition::action_display_for_shell(
939                    call, outcome, host_shell,
940                )),
941                None => {
942                    let name = call.source.function.name.as_str();
943                    if name == "agent" || name == "ask_user_question" {
944                        return None;
945                    }
946                    let (action_type, target) =
947                        mermaid_domain::display_info_for_shell(call, host_shell);
948                    Some(mermaid_domain::ActionDisplay {
949                        action_type,
950                        target,
951                        result: mermaid_domain::ActionResult::Running,
952                        details: mermaid_domain::ActionDetails::Simple,
953                        duration_seconds: None,
954                        metadata: None,
955                    })
956                },
957            })
958            .collect();
959        if actions.is_empty() {
960            return std::borrow::Cow::Borrowed(committed);
961        }
962        let mut msg = mermaid_model::models::ChatMessage::assistant("");
963        msg.timestamp = now;
964        msg.actions = actions;
965        let mut out = committed.to_vec();
966        out.push(msg);
967        return std::borrow::Cow::Owned(out);
968    }
969    // Idle / no-partial frames borrow the committed log directly — no per-frame
970    // clone of the whole transcript. Only an in-flight partial forces an owned
971    // copy (committed + the one live assistant message).
972    if let TurnState::Generating {
973        partial_text,
974        partial_reasoning,
975        continuation,
976        ..
977    } = turn
978        && (!partial_text.is_empty() || !partial_reasoning.is_empty())
979    {
980        let thinking = if partial_reasoning.is_empty() {
981            None
982        } else {
983            Some(partial_reasoning.clone())
984        };
985        let stitching = *continuation && committed.last().is_some_and(mergeable_into);
986        let content = if stitching {
987            let prev = &committed[committed.len() - 1].content;
988            let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
989            partial_text[skip..].to_string()
990        } else {
991            partial_text.clone()
992        };
993        let msg = mermaid_model::models::ChatMessage {
994            role: mermaid_model::models::MessageRole::Assistant,
995            content,
996            // `state.now` (stamped each tick) keeps render a pure function of
997            // State — never read the wall clock here.
998            timestamp: now,
999            kind: if stitching {
1000                mermaid_model::models::ChatMessageKind::Continuation
1001            } else {
1002                mermaid_model::models::ChatMessageKind::Normal
1003            },
1004            metadata: None,
1005            actions: Vec::new(),
1006            thinking,
1007            images: None,
1008            image_numbers: None,
1009            tool_calls: None,
1010            tool_call_id: None,
1011            tool_name: None,
1012            provider_continuation: None,
1013        };
1014        let mut out = committed.to_vec();
1015        out.push(msg);
1016        std::borrow::Cow::Owned(out)
1017    } else {
1018        std::borrow::Cow::Borrowed(committed)
1019    }
1020}
1021
1022/// True while a first Ctrl+C's exit-confirmation window is open. Expiry is
1023/// lazy: compared against the injected `state.now` (stamped every tick), so
1024/// the hint disappears on the next tick frame with no reducer state change.
1025fn exit_armed(state: &State) -> bool {
1026    state
1027        .ui
1028        .exit_armed_until
1029        .is_some_and(|deadline| state.now <= deadline)
1030}
1031
1032/// The toast to draw, or `None` once it has expired. Same lazy-expiry pattern
1033/// as `exit_armed`: nothing clears `ui.toast`, the 60 Hz tick just stops
1034/// drawing it once `state.now` passes the deadline.
1035fn active_toast(state: &State) -> Option<String> {
1036    state
1037        .ui
1038        .toast
1039        .as_ref()
1040        .filter(|(_, until)| state.now <= *until)
1041        .map(|(text, _)| text.clone())
1042}
1043
1044/// True while a first idle Esc's rewind window is open (same lazy-expiry
1045/// pattern as `exit_armed`; the reducer owns the 1s window constant).
1046fn rewind_armed(state: &State) -> bool {
1047    state
1048        .ui
1049        .esc_armed_at
1050        .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1051}
1052
1053/// Data for the live agent panel + the status-line adjustments it implies:
1054/// one `AgentPanelRow` per in-flight `agent` call (plus every detached
1055/// background agent), a status override ("Running N agents") when agents are
1056/// the only pending work, and whether anything running can honor Ctrl+B.
1057fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1058    let now_sys = std::time::SystemTime::from(state.now);
1059    let elapsed_since =
1060        |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1061
1062    let mut rows = Vec::new();
1063    let mut running_agents = 0usize;
1064    let mut pending_total = 0usize;
1065    let mut bg_available = false;
1066    if let TurnState::ExecutingTools {
1067        calls,
1068        outcomes,
1069        started,
1070        ..
1071    } = &state.turn
1072    {
1073        let elapsed = elapsed_since(*started);
1074        for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1075            pending_total += 1;
1076            let name = call.source.function.name.as_str();
1077            if name == "execute_command" || name == "agent" {
1078                bg_available = true;
1079            }
1080            if name != "agent" {
1081                continue;
1082            }
1083            running_agents += 1;
1084            let (_, description) = mermaid_domain::display_info_for(call);
1085            let live = state.ui.live_tool_status.get(&call.call_id);
1086            rows.push(widgets::AgentPanelRow {
1087                description,
1088                activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1089                tokens: live.map_or(0, |l| l.tokens),
1090                elapsed_secs: elapsed,
1091                backgrounded: false,
1092            });
1093        }
1094    }
1095    for agent in &state.runtime.background_agents {
1096        rows.push(widgets::AgentPanelRow {
1097            description: agent.description.clone(),
1098            activity: agent.activity.clone(),
1099            tokens: agent.tokens,
1100            elapsed_secs: elapsed_since(agent.started),
1101            backgrounded: true,
1102        });
1103    }
1104    let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1105        if running_agents == 1 {
1106            "Running 1 agent".to_string()
1107        } else {
1108            format!("Running {running_agents} agents")
1109        }
1110    });
1111    (rows, status_override, bg_available)
1112}
1113
1114/// Future hook: consult `ProviderFactory` for per-model capabilities.
1115/// Today returns `None` — reasoning snap indicator is suppressed
1116/// until the factory is threaded through `State` (or an equivalent
1117/// capability table).
1118fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1119    None
1120}
1121
1122/// Render one frame into a plain-text character grid at the given size.
1123/// Test-only: shared by the unit tests below and the snapshot suite
1124/// (`snapshots.rs`), which needs to control both the frame size and the
1125/// `RenderCache` (pinned hostname/username).
1126#[cfg(test)]
1127pub(crate) fn render_frame(
1128    state: &State,
1129    rstate: &mut RenderCache,
1130    width: u16,
1131    height: u16,
1132) -> String {
1133    use ratatui::Terminal;
1134    use ratatui::backend::TestBackend;
1135    let backend = TestBackend::new(width, height);
1136    let mut terminal = Terminal::new(backend).expect("terminal");
1137    terminal.draw(|f| render(state, rstate, f)).expect("draw");
1138    let buf = terminal.backend().buffer();
1139    let mut out = String::new();
1140    for y in 0..buf.area.height {
1141        for x in 0..buf.area.width {
1142            out.push_str(buf[(x, y)].symbol());
1143        }
1144        out.push('\n');
1145    }
1146    out
1147}
1148
1149/// Full-frame snapshots of `render()`. Runs on every platform (#296): the
1150/// suite pins its own clock, host/user, version and cwd, so nothing platform-
1151/// dependent reaches the frame.
1152#[cfg(test)]
1153mod snapshots;
1154
1155/// Idle-frame measurement rig (`#[ignore]`d). See `bench.rs` for how to run it.
1156#[cfg(test)]
1157mod bench;
1158
1159#[cfg(test)]
1160mod tests {
1161    use super::*;
1162    use mermaid_domain::Config;
1163    use mermaid_domain::{State, TurnState};
1164    use ratatui::Terminal;
1165    use ratatui::backend::TestBackend;
1166    use std::path::PathBuf;
1167
1168    fn mock_state() -> State {
1169        State::new(
1170            Config::default(),
1171            PathBuf::from("/tmp/p"),
1172            "ollama/test".to_string(),
1173            chrono::Local::now(),
1174            PathBuf::from("/tmp"),
1175        )
1176    }
1177
1178    /// Pinned `user@host`, so a frame rendered here is the same on every
1179    /// machine — the reason `RenderCache::new` takes them as arguments.
1180    fn test_cache() -> RenderCache {
1181        RenderCache::new("testhost".to_string(), "testuser".to_string())
1182    }
1183
1184    fn render_to_string(state: &State) -> String {
1185        render_frame(state, &mut test_cache(), 80, 24)
1186    }
1187
1188    fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1189        let backend = TestBackend::new(80, 24);
1190        let mut terminal = Terminal::new(backend).expect("terminal");
1191        let mut rstate = test_cache();
1192        terminal
1193            .draw(|f| render(state, &mut rstate, f))
1194            .expect("draw");
1195        terminal.backend().buffer().clone()
1196    }
1197
1198    #[test]
1199    fn theme_choice_changes_colors_never_glyphs() {
1200        // Guards the "theme changes can't break snapshots" claim: light,
1201        // dark, and NO_COLOR-plain frames must be glyph-identical — a theme
1202        // is a palette, not a layout.
1203        let mut state = mock_state();
1204        state
1205            .session
1206            .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1207        let dark = render_to_string(&state);
1208        state.ui.theme = mermaid_domain::ThemeChoice::Light;
1209        let light = render_to_string(&state);
1210        assert_eq!(dark, light, "light theme changed glyphs");
1211        state.ui.no_color = true;
1212        let plain = render_to_string(&state);
1213        assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1214    }
1215
1216    #[test]
1217    fn theme_memo_swaps_palette_on_state_change() {
1218        let mut state = mock_state();
1219        let mut rstate = test_cache();
1220        render_frame(&state, &mut rstate, 80, 24);
1221        assert_eq!(rstate.theme.name, "Dark");
1222        state.ui.theme = mermaid_domain::ThemeChoice::Light;
1223        render_frame(&state, &mut rstate, 80, 24);
1224        assert_eq!(rstate.theme.name, "Light");
1225        // NO_COLOR beats the theme choice.
1226        state.ui.no_color = true;
1227        render_frame(&state, &mut rstate, 80, 24);
1228        assert_eq!(rstate.theme.name, "Plain");
1229    }
1230
1231    #[test]
1232    fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1233        use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1234
1235        let mut state = mock_state();
1236        let call_id = ToolCallId(7);
1237        state.turn = TurnState::ExecutingTools {
1238            id: TurnId(1),
1239            started: std::time::SystemTime::now(),
1240            calls: vec![PendingToolCall {
1241                call_id,
1242                source: mermaid_model::models::tool_call::ToolCall {
1243                    id: None,
1244                    function: mermaid_model::models::tool_call::FunctionCall {
1245                        name: "agent".to_string(),
1246                        arguments: serde_json::json!({"description": "explore crates"}),
1247                    },
1248                },
1249            }],
1250            outcomes: vec![None],
1251        };
1252        state.ui.live_tool_status.insert(
1253            call_id,
1254            LiveToolStatus {
1255                activity: "read_file…".to_string(),
1256                tokens: 12_300,
1257            },
1258        );
1259
1260        // Agent calls never get a live transcript action row — they get panel
1261        // rows (and the "Running N agents" override) instead.
1262        let live = build_live_messages(
1263            &[],
1264            &state.turn,
1265            chrono::Local::now(),
1266            mermaid_runtime::HostShell::Posix,
1267        );
1268        assert!(
1269            live.is_empty(),
1270            "a pending agent call must not synthesize a transcript row"
1271        );
1272        let (rows, override_text, bg_available) = agent_panel_data(&state);
1273        assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1274        assert!(bg_available, "agents are detachable via ctrl+b");
1275        assert_eq!(rows.len(), 1);
1276        assert_eq!(rows[0].description, "explore crates");
1277        assert_eq!(rows[0].activity, "read_file…");
1278        assert_eq!(rows[0].tokens, 12_300);
1279        assert!(!rows[0].backgrounded);
1280    }
1281
1282    #[test]
1283    fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1284        use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1285
1286        let mut state = mock_state();
1287        let exec_id = ToolCallId(8);
1288        let agent_id = ToolCallId(9);
1289        let call = |id, name: &str, args| PendingToolCall {
1290            call_id: id,
1291            source: mermaid_model::models::tool_call::ToolCall {
1292                id: None,
1293                function: mermaid_model::models::tool_call::FunctionCall {
1294                    name: name.to_string(),
1295                    arguments: args,
1296                },
1297            },
1298        };
1299        state.turn = TurnState::ExecutingTools {
1300            id: TurnId(1),
1301            started: std::time::SystemTime::now(),
1302            calls: vec![
1303                call(
1304                    exec_id,
1305                    "execute_command",
1306                    serde_json::json!({"command": "cargo test"}),
1307                ),
1308                call(
1309                    agent_id,
1310                    "agent",
1311                    serde_json::json!({"description": "audit docs"}),
1312                ),
1313            ],
1314            outcomes: vec![None, None],
1315        };
1316        state.ui.live_tool_status.insert(
1317            exec_id,
1318            LiveToolStatus {
1319                activity: String::new(),
1320                tokens: 0,
1321            },
1322        );
1323
1324        // The shell command gets a live Running transcript row; the agent gets
1325        // only its panel row. No override since agents aren't the only work.
1326        let live = build_live_messages(
1327            &[],
1328            &state.turn,
1329            chrono::Local::now(),
1330            mermaid_runtime::HostShell::Posix,
1331        );
1332        assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1333        let actions = &live[0].actions;
1334        assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1335        assert_eq!(actions[0].action_type, "Bash");
1336        assert_eq!(actions[0].target, "cargo test");
1337        assert!(matches!(
1338            actions[0].result,
1339            mermaid_domain::ActionResult::Running
1340        ));
1341        let (rows, override_text, _) = agent_panel_data(&state);
1342        assert_eq!(override_text, None);
1343        assert_eq!(rows.len(), 1);
1344    }
1345
1346    #[test]
1347    fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1348        use mermaid_domain::{GenPhase, TurnId};
1349        use mermaid_model::models::ChatMessage;
1350        use std::borrow::Cow;
1351        use std::time::SystemTime;
1352
1353        let committed = vec![ChatMessage::user("hi")];
1354        let now = chrono::Local::now();
1355
1356        // Idle frames borrow the committed log unchanged — no per-frame clone.
1357        let idle = build_live_messages(
1358            &committed,
1359            &TurnState::Idle,
1360            now,
1361            mermaid_runtime::HostShell::Posix,
1362        );
1363        assert!(matches!(idle, Cow::Borrowed(_)));
1364        assert_eq!(idle.len(), 1);
1365
1366        // A generating partial yields an owned copy whose live message is stamped
1367        // from the injected `now`, never the wall clock (render purity, #135).
1368        let turn = TurnState::Generating {
1369            id: TurnId(1),
1370            started: SystemTime::now(),
1371            partial_text: "draft".to_string(),
1372            partial_reasoning: String::new(),
1373            tokens: 0,
1374            phase: GenPhase::Sending,
1375            provider_continuation: None,
1376            pending_tool_calls: Vec::new(),
1377            continuation: false,
1378        };
1379        let live = build_live_messages(&committed, &turn, now, mermaid_runtime::HostShell::Posix);
1380        assert!(matches!(live, Cow::Owned(_)));
1381        assert_eq!(live.len(), 2);
1382        assert_eq!(live[1].timestamp, now);
1383    }
1384
1385    fn kinded(
1386        mut msg: mermaid_model::models::ChatMessage,
1387        kind: mermaid_model::models::ChatMessageKind,
1388    ) -> mermaid_model::models::ChatMessage {
1389        msg.kind = kind;
1390        msg
1391    }
1392
1393    #[test]
1394    fn stitch_committed_merges_chain_and_hides_nudges() {
1395        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1396        let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1397        part1.thinking = Some("first trace".to_string());
1398        // The continuation echoes the tail of part1 — the seam trim drops it.
1399        let mut part2 = kinded(
1400            ChatMessage::assistant("issues in the resolver, and here is the fix."),
1401            ChatMessageKind::Continuation,
1402        );
1403        part2.thinking = Some("second trace".to_string());
1404        let committed = vec![
1405            ChatMessage::user("audit the widget"),
1406            part1,
1407            kinded(
1408                ChatMessage::system("resume nudge"),
1409                ChatMessageKind::RecoveryNudge,
1410            ),
1411            part2,
1412        ];
1413
1414        assert!(needs_stitch(&committed, &TurnState::Idle));
1415        let stitched = stitch_committed(&committed);
1416        assert_eq!(stitched.len(), 2, "user + one merged bubble");
1417        assert_eq!(
1418            stitched[1].content,
1419            "The audit found three issues in the resolver, and here is the fix.",
1420            "contents merge with the resume echo trimmed"
1421        );
1422        assert_eq!(
1423            stitched[1].thinking.as_deref(),
1424            Some("first trace\n\nsecond trace"),
1425            "both reasoning segments survive in order"
1426        );
1427        assert!(
1428            !stitched.iter().any(|m| m.content.contains("resume nudge")),
1429            "nudges never render"
1430        );
1431    }
1432
1433    /// Context markers are model-facing timeline records — the status band is
1434    /// the human announcement of a mode change, so the transcript hides them.
1435    #[test]
1436    fn context_markers_are_hidden_from_the_transcript() {
1437        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1438        let committed = vec![
1439            ChatMessage::user("plan this"),
1440            kinded(
1441                ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1442                ChatMessageKind::ContextMarker,
1443            ),
1444            ChatMessage::assistant("Grounding first."),
1445        ];
1446        // Markers are hidden by `ChatWidget` itself, so they do NOT force the
1447        // copying stitch path — that is the whole point, since a marker is
1448        // never swept and would otherwise cost a transcript hash on every
1449        // frame for the rest of the session.
1450        assert!(
1451            !needs_stitch(&committed, &TurnState::Idle),
1452            "a marker alone must not defeat the zero-copy path",
1453        );
1454        // The stitch still drops them when it runs for a real continuation.
1455        let stitched = stitch_committed(&committed);
1456        assert_eq!(stitched.len(), 2, "user + assistant only");
1457        assert!(
1458            !stitched
1459                .iter()
1460                .any(|m| m.content.contains("Plan mode is now ON")),
1461            "markers never render"
1462        );
1463    }
1464
1465    #[test]
1466    fn stitch_refuses_non_bubble_predecessor() {
1467        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1468        // A continuation whose bubble was archived by compaction lands after
1469        // the checkpoint's assistant half — render it as its own message
1470        // (graceful seam) rather than merging into the event block.
1471        let committed = vec![
1472            kinded(
1473                ChatMessage::assistant("checkpoint summary"),
1474                ChatMessageKind::ContextCheckpoint,
1475            ),
1476            kinded(
1477                ChatMessage::assistant("orphaned continuation"),
1478                ChatMessageKind::Continuation,
1479            ),
1480        ];
1481        let stitched = stitch_committed(&committed);
1482        assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1483        assert_eq!(stitched[1].content, "orphaned continuation");
1484    }
1485
1486    #[test]
1487    fn needs_stitch_is_false_for_plain_sessions() {
1488        use mermaid_model::models::ChatMessage;
1489        // The fast path: a session that never auto-continued skips the
1490        // pre-pass entirely (borrowed slice, no fingerprint, no clone).
1491        let committed = vec![
1492            ChatMessage::user("hi"),
1493            ChatMessage::assistant("hello"),
1494            ChatMessage::system("note"),
1495        ];
1496        assert!(!needs_stitch(&committed, &TurnState::Idle));
1497    }
1498
1499    /// A live auto-continue streams BEFORE any `Continuation` is committed,
1500    /// and the message just before it is the "hit the output limit" nudge.
1501    /// `build_live_messages` merges the partial only when
1502    /// `committed.last()` is a mergeable assistant bubble — so the nudge has
1503    /// to be stitched out even though nothing in HISTORY is a continuation.
1504    /// Miss this and the partial renders as a fresh bubble with the overlap
1505    /// text duplicated.
1506    #[test]
1507    fn a_live_continuation_still_forces_the_stitch() {
1508        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1509        let committed = vec![
1510            ChatMessage::user("write it"),
1511            ChatMessage::assistant("first half"),
1512            kinded(
1513                ChatMessage::system("output limit — continuing"),
1514                ChatMessageKind::RecoveryNudge,
1515            ),
1516        ];
1517        let streaming = TurnState::Generating {
1518            id: mermaid_domain::TurnId(1),
1519            started: std::time::SystemTime::UNIX_EPOCH,
1520            partial_text: "first half and the rest".to_string(),
1521            partial_reasoning: String::new(),
1522            tokens: 0,
1523            phase: mermaid_domain::GenPhase::Streaming,
1524            provider_continuation: None,
1525            pending_tool_calls: Vec::new(),
1526            continuation: true,
1527        };
1528        assert!(
1529            needs_stitch(&committed, &streaming),
1530            "a live continuation needs the nudge stripped to find its bubble",
1531        );
1532        // Without the nudge in the way, the partial merges into the bubble.
1533        let stitched = stitch_committed(&committed);
1534        assert!(
1535            stitched.last().is_some_and(mergeable_into),
1536            "the stitched tail is the assistant bubble the partial merges into",
1537        );
1538    }
1539
1540    #[test]
1541    fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1542        use mermaid_domain::{GenPhase, TurnId};
1543        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1544
1545        let committed = vec![ChatMessage::assistant(
1546            "the fix lands in the resolver module",
1547        )];
1548        let turn = TurnState::Generating {
1549            id: TurnId(2),
1550            started: std::time::SystemTime::now(),
1551            partial_text: "in the resolver module, specifically the clamp".to_string(),
1552            partial_reasoning: String::new(),
1553            tokens: 0,
1554            phase: GenPhase::Streaming,
1555            provider_continuation: None,
1556            pending_tool_calls: Vec::new(),
1557            continuation: true,
1558        };
1559        let live = build_live_messages(
1560            &committed,
1561            &turn,
1562            chrono::Local::now(),
1563            mermaid_runtime::HostShell::Posix,
1564        );
1565        let streamed = live.last().expect("pseudo-message appended");
1566        assert_eq!(
1567            streamed.kind,
1568            ChatMessageKind::Continuation,
1569            "the live half is stamped so the widget draws it prefix-less"
1570        );
1571        assert_eq!(
1572            streamed.content, ", specifically the clamp",
1573            "the leading resume echo is trimmed against the committed tail"
1574        );
1575    }
1576
1577    #[test]
1578    fn auto_continued_reply_renders_as_one_bubble() {
1579        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1580        let mut s = mock_state();
1581        s.session.append(ChatMessage::user("audit"), s.now);
1582        s.session
1583            .append(ChatMessage::assistant("part one of the reply"), s.now);
1584        s.session.append(
1585            kinded(
1586                ChatMessage::system("output limit — continuing"),
1587                ChatMessageKind::RecoveryNudge,
1588            ),
1589            s.now,
1590        );
1591        s.session.append(
1592            kinded(
1593                ChatMessage::assistant("and part two lands here"),
1594                ChatMessageKind::Continuation,
1595            ),
1596            s.now,
1597        );
1598
1599        let out = render_to_string(&s);
1600        assert!(out.contains("part one of the reply"));
1601        assert!(out.contains("and part two lands here"));
1602        assert!(
1603            !out.contains("continuing"),
1604            "the recovery nudge never renders"
1605        );
1606        assert_eq!(
1607            out.matches('●').count(),
1608            1,
1609            "both halves share one assistant bullet:\n{out}"
1610        );
1611    }
1612
1613    #[test]
1614    fn streaming_continuation_renders_without_fresh_bullet() {
1615        use mermaid_domain::{GenPhase, TurnId};
1616        use mermaid_model::models::{ChatMessage, ChatMessageKind};
1617        let mut s = mock_state();
1618        s.session.append(ChatMessage::user("audit"), s.now);
1619        s.session
1620            .append(ChatMessage::assistant("part one of the reply"), s.now);
1621        s.session.append(
1622            kinded(
1623                ChatMessage::system("output limit — continuing"),
1624                ChatMessageKind::RecoveryNudge,
1625            ),
1626            s.now,
1627        );
1628        s.turn = TurnState::Generating {
1629            id: TurnId(3),
1630            started: std::time::SystemTime::now(),
1631            partial_text: "and part two streams in".to_string(),
1632            partial_reasoning: String::new(),
1633            tokens: 0,
1634            phase: GenPhase::Streaming,
1635            provider_continuation: None,
1636            pending_tool_calls: Vec::new(),
1637            continuation: true,
1638        };
1639
1640        let out = render_to_string(&s);
1641        assert!(out.contains("part one of the reply"));
1642        assert!(out.contains("and part two streams in"));
1643        assert!(!out.contains("continuing"), "live nudge hidden too");
1644        assert_eq!(
1645            out.matches('●').count(),
1646            1,
1647            "the streaming half joins the committed bubble:\n{out}"
1648        );
1649    }
1650
1651    #[test]
1652    fn user_prompt_renders_with_highlight_band() {
1653        let mut s = mock_state();
1654        s.session.append(
1655            mermaid_model::models::ChatMessage::user("hello there"),
1656            s.now,
1657        );
1658        let buf = render_to_buffer(&s);
1659        let band_bg = crate::render::theme::Theme::dark()
1660            .colors
1661            .user_message_background
1662            .to_color();
1663        // Row carrying the prompt text.
1664        let y = (0..buf.area.height)
1665            .find(|&y| {
1666                (0..buf.area.width)
1667                    .map(|x| buf[(x, y)].symbol())
1668                    .collect::<String>()
1669                    .contains("hello there")
1670            })
1671            .expect("user prompt should render");
1672        // The band fills the row: the great majority of cells carry the band bg
1673        // (a thin layout margin at the very edges may not).
1674        let banded = (0..buf.area.width)
1675            .filter(|&x| buf[(x, y)].bg == band_bg)
1676            .count();
1677        assert!(
1678            banded >= (buf.area.width as usize) * 3 / 4,
1679            "user prompt band should fill most of the row; only {banded}/{} cells banded",
1680            buf.area.width
1681        );
1682    }
1683
1684    #[test]
1685    fn idle_state_renders_cwd_and_model_footer() {
1686        let s = mock_state();
1687        let frame = render_to_string(&s);
1688        // Bottom status bar shows cwd + model id somewhere.
1689        assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1690        assert!(frame.contains("ollama/test"));
1691    }
1692
1693    #[test]
1694    fn status_line_appears_during_generating() {
1695        let mut s = mock_state();
1696        s.turn = mermaid_domain::transition::start_generating(
1697            mermaid_domain::TurnId(1),
1698            std::time::SystemTime::now(),
1699        );
1700        let frame = render_to_string(&s);
1701        assert!(
1702            frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1703            "expected generation status in frame"
1704        );
1705    }
1706
1707    #[test]
1708    fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1709        use mermaid_domain::PendingToolCall;
1710        use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1711        let mut s = mock_state();
1712        let call = PendingToolCall {
1713            call_id: mermaid_domain::ToolCallId(1),
1714            source: ModelToolCall {
1715                id: Some("c1".to_string()),
1716                function: FunctionCall {
1717                    name: "execute_command".to_string(),
1718                    arguments: serde_json::json!({"command": "npm run dev"}),
1719                },
1720            },
1721        };
1722        s.turn = TurnState::ExecutingTools {
1723            id: mermaid_domain::TurnId(1),
1724            started: std::time::SystemTime::now(),
1725            calls: vec![call],
1726            outcomes: vec![None],
1727        };
1728        let frame = render_to_string(&s);
1729        // The spinner headline is the bare phase word — the command must NOT
1730        // ride on it (the bug class this regression test pins down)…
1731        assert!(frame.contains("Running tools..."), "got: {frame}");
1732        assert!(
1733            !frame.contains("Running tools:"),
1734            "status line must not carry tool detail; got: {frame}"
1735        );
1736        // …because the transcript's live action row names it instead.
1737        assert!(
1738            frame.contains("npm run dev"),
1739            "transcript must show the in-flight call's action row; got: {frame}"
1740        );
1741    }
1742
1743    #[test]
1744    fn pending_question_and_agent_calls_get_no_transcript_row() {
1745        use mermaid_domain::PendingToolCall;
1746        use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1747        let mut s = mock_state();
1748        let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1749            call_id: mermaid_domain::ToolCallId(id),
1750            source: ModelToolCall {
1751                id: Some(format!("c{id}")),
1752                function: FunctionCall {
1753                    name: name.to_string(),
1754                    arguments: args,
1755                },
1756            },
1757        };
1758        s.turn = TurnState::ExecutingTools {
1759            id: mermaid_domain::TurnId(1),
1760            started: std::time::SystemTime::now(),
1761            calls: vec![
1762                mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1763                mk(
1764                    2,
1765                    "agent",
1766                    serde_json::json!({"description": "scan the repo"}),
1767                ),
1768            ],
1769            outcomes: vec![None, None],
1770        };
1771        let frame = render_to_string(&s);
1772        // The question's representation is the modal; the agent's is its
1773        // panel row under the spinner. Neither gets a transcript action row.
1774        assert!(
1775            !frame.contains("ask_user_question"),
1776            "pending question must not surface as a transcript row or status text; got: {frame}"
1777        );
1778    }
1779
1780    #[test]
1781    fn status_line_appears_during_tool_execution_and_shows_queue() {
1782        let mut s = mock_state();
1783        s.turn = TurnState::ExecutingTools {
1784            id: mermaid_domain::TurnId(1),
1785            started: std::time::SystemTime::now(),
1786            calls: Vec::new(),
1787            outcomes: Vec::new(),
1788        };
1789        s.ui.queued_messages
1790            .push_back(mermaid_domain::QueuedMessage {
1791                text: "please steer this".to_string(),
1792                attachment_ids: Vec::new(),
1793            });
1794        let frame = render_to_string(&s);
1795        assert!(frame.contains("Running tools"), "expected tool status");
1796        assert!(
1797            frame.contains("please steer this"),
1798            "queued busy input must be visible"
1799        );
1800    }
1801
1802    #[test]
1803    fn reasoning_blocks_are_collapsed_by_default() {
1804        let mut s = mock_state();
1805        let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1806        first_msg.thinking = Some("first private chain of thought".to_string());
1807        s.session.append(first_msg, s.now);
1808        let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1809        second_msg.thinking = Some("second private chain of thought".to_string());
1810        s.session.append(second_msg, s.now);
1811        let frame = render_to_string(&s);
1812        // Hidden reasoning is collapsed silently — no placeholder line.
1813        assert!(!frame.contains("Reasoning hidden"));
1814        assert!(frame.contains("first visible answer"));
1815        assert!(frame.contains("second visible answer"));
1816        assert!(!frame.contains("first private chain of thought"));
1817        assert!(!frame.contains("second private chain of thought"));
1818    }
1819
1820    /// A "thought, then immediately called a tool" turn (hidden reasoning +
1821    /// empty text + actions) renders the action directly — the turn is not
1822    /// skipped, and there is no "reasoning hidden" placeholder ahead of it.
1823    #[test]
1824    fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1825        let mut s = mock_state();
1826        let mut msg = mermaid_model::models::ChatMessage::assistant("");
1827        msg.thinking = Some("private chain of thought".to_string());
1828        msg.actions.push(mermaid_domain::ActionDisplay {
1829            action_type: "Bash".to_string(),
1830            target: "dir".to_string(),
1831            result: mermaid_domain::ActionResult::Success {
1832                output: "ok".to_string(),
1833                images: None,
1834            },
1835            details: mermaid_domain::ActionDetails::Simple,
1836            duration_seconds: Some(0.015),
1837            metadata: None,
1838        });
1839        s.session.append(msg, s.now);
1840        let frame = render_to_string(&s);
1841        assert!(
1842            !frame.contains("Reasoning hidden"),
1843            "no reasoning-hidden placeholder"
1844        );
1845        assert!(
1846            frame.contains("Bash"),
1847            "the action still renders even though reasoning is hidden"
1848        );
1849    }
1850
1851    #[test]
1852    fn committed_message_appears_in_chat_pane() {
1853        let mut s = mock_state();
1854        s.session.append(
1855            mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1856            s.now,
1857        );
1858        let frame = render_to_string(&s);
1859        assert!(frame.contains("unique-user-token-xyz"));
1860    }
1861
1862    #[test]
1863    fn palette_renders_when_input_starts_with_slash() {
1864        let mut s = mock_state();
1865        s.ui.input_buffer = "/help".to_string();
1866        s.ui.input_cursor = 5;
1867        let frame = render_to_string(&s);
1868        // At least one registered command should surface in the overlay.
1869        assert!(frame.contains("help"));
1870    }
1871
1872    #[test]
1873    fn status_line_helper_maps_idle_to_idle() {
1874        assert_eq!(
1875            GenerationStatus::from_turn(&TurnState::Idle),
1876            GenerationStatus::Idle
1877        );
1878    }
1879
1880    /// The caret must land on a text row of the input box, never on its
1881    /// border, and the box must show every row of the wrapped input.
1882    ///
1883    /// This asserts against a real frame rather than against the row count,
1884    /// because the shipped bug's symptom lived in the frame: the box drew one
1885    /// row short, the last line was clipped, and the caret was placed on the
1886    /// bottom border. A row-count unit test alone would not have said that.
1887    ///
1888    /// Swept across widths and every prefix, since the failure window between
1889    /// a word wrapping and a cell count catching up is only a character or two
1890    /// wide.
1891    #[test]
1892    fn the_caret_lands_inside_the_input_box_and_no_row_is_clipped() {
1893        use ratatui::Terminal;
1894        use ratatui::backend::TestBackend;
1895
1896        let text = "Create a language that. Your goal is up to you.";
1897        for width in [24u16, 30, 40, 55] {
1898            for n in 0..=text.len() {
1899                // `get` yields None on a non-boundary, which is also the
1900                // char-boundary check.
1901                let Some(prefix) = text.get(..n) else {
1902                    continue;
1903                };
1904                let mut state = mock_state();
1905                state.ui.input_buffer = prefix.to_string();
1906                state.ui.input_cursor = prefix.len();
1907
1908                let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("terminal");
1909                let mut rstate = test_cache();
1910                terminal
1911                    .draw(|f| render(&state, &mut rstate, f))
1912                    .expect("draw");
1913                let cursor = terminal.get_cursor_position().expect("cursor");
1914                let buf = terminal.backend().buffer().clone();
1915
1916                let row_at = |y: u16| {
1917                    (0..buf.area.width)
1918                        .map(|x| buf[(x, y)].symbol())
1919                        .collect::<String>()
1920                };
1921                let caret_row = row_at(cursor.y);
1922
1923                // The border is drawn with box-drawing glyphs; a text row
1924                // never is. Catches the caret being pushed onto either edge.
1925                assert!(
1926                    !caret_row.contains('─'),
1927                    "caret on the box border at width {width}, prefix {prefix:?}\n\
1928                     row {}: |{caret_row}|",
1929                    cursor.y
1930                );
1931
1932                // Nothing the widget wrapped may be missing from the frame.
1933                let content_width = width.saturating_sub(2) as usize;
1934                let rows = widgets::rendered_row_count(prefix, content_width);
1935                assert!(
1936                    rows <= 5,
1937                    "fixture outgrew the 5-row cap at width {width}: {prefix:?}"
1938                );
1939                if let Some(last) = prefix.split_whitespace().next_back() {
1940                    let frame: String = (0..buf.area.height)
1941                        .map(row_at)
1942                        .collect::<Vec<_>>()
1943                        .join("\n");
1944                    assert!(
1945                        frame.contains(last),
1946                        "last word {last:?} clipped at width {width}, \
1947                         prefix {prefix:?}\n{frame}"
1948                    );
1949                }
1950            }
1951        }
1952    }
1953}