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