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