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