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