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