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