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) {
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    let chat_widget = ChatWidget {
425        messages: live_messages.as_ref(),
426        theme: &rstate.theme,
427        wrapped_line_cache: &mut rstate.wrapped_line_cache,
428        show_reasoning: state.ui.show_reasoning,
429        // 500ms blink phase for in-flight action dots, from the injected
430        // clock (never the wall clock) so a frame stays a pure function of
431        // State. Only frames that paint a running action consume it.
432        blink_on: (state.now.timestamp_millis().div_euclid(500)) % 2 == 0,
433    };
434    frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
435
436    // Status line for every active turn (built above, already fit to width).
437    // Indented 1 cell to align with the chat column's 1-cell pad.
438    if !status_lines.is_empty() {
439        let status_area = chunks[1].inner(Margin {
440            horizontal: 1,
441            vertical: 0,
442        });
443        frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
444    }
445
446    // Task checklist band (chunks[2]), hanging under the spinner line.
447    if tasks_zone_height > 0 {
448        let tasks_area = chunks[2].inner(Margin {
449            horizontal: 1,
450            vertical: 0,
451        });
452        let lines = widgets::build_task_lines(
453            tasks_store,
454            state.ui.tasks_collapsed,
455            tasks_attached,
456            tasks_area.width,
457            &rstate.theme,
458        );
459        frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
460    }
461
462    // Input box (chunks[3]; the attachment zone is gone, the task band
463    // precedes). Collapsed entirely — including the terminal cursor — while
464    // the question modal owns the bottom of the screen.
465    if !question_modal_open {
466        let input_widget = InputWidget {
467            input: state.ui.input_buffer.as_str(),
468            showing_command_hints: state.ui.input_buffer.starts_with('/'),
469            theme: &rstate.theme,
470            reasoning_active: state.session.reasoning != ReasoningLevel::None,
471            exit_armed: exit_armed(state),
472            rewind_armed: rewind_armed(state),
473        };
474        let mut input_widget_state = InputState {
475            cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
476        };
477        frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
478
479        // Cursor tracks the input caret.
480        let input_area = chunks[3];
481        let content_width = input_area.width.saturating_sub(2) as usize;
482        let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
483            &state.ui.input_buffer,
484            state.ui.input_cursor.min(state.ui.input_buffer.len()),
485            content_width,
486        );
487        frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
488    }
489
490    // Effective reasoning level. Per-model supported_reasoning cap
491    // isn't threaded through `State` yet; defaults to no snap
492    // indicator until `ProviderFactory::capabilities` reaches here.
493    let requested = state.session.reasoning;
494    let effective = match supported_reasoning_for(state) {
495        Some(ReasoningCapability::Levels(supp)) => {
496            nearest_effort(requested, &supp).unwrap_or(requested)
497        },
498        _ => requested,
499    };
500    let requested_level = if effective == requested {
501        None
502    } else {
503        Some(requested)
504    };
505
506    // Bottom: conversation-list picker, slash-palette overlay, or
507    // persistent status bar — whichever the UI mode dictates.
508    if let Some(item) = state.pending_approval.front() {
509        use widgets::ApprovalModalWidget;
510        // Content-bearing external tools (type_text, MCP, …) are
511        // non-allowlistable: the gate leaves their scope empty, and we omit the
512        // "don't ask again" option so the user can't blanket-approve them (#6, #31).
513        let options = if item.allowlist_scope.is_empty() {
514            vec!["1. Yes".to_string(), "2. No  (Esc)".to_string()]
515        } else {
516            vec![
517                "1. Yes".to_string(),
518                format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
519                "3. No  (Esc)".to_string(),
520            ]
521        };
522        let widget = ApprovalModalWidget {
523            theme: &rstate.theme,
524            title: format!("Approval required — {}  [{}]", item.tool, item.risk),
525            body: item.prompt.as_str(),
526            options,
527            selected_index: Some(item.selected_option),
528            accent: rstate.theme.colors.warning.to_color(),
529        };
530        frame.render_widget(widget, chunks[4]);
531    } else if let Some(qset) = state.pending_question.front() {
532        use widgets::QuestionModalWidget;
533        let widget = QuestionModalWidget {
534            theme: &rstate.theme,
535            set: qset,
536        };
537        frame.render_widget(widget, chunks[4]);
538    } else if let Some(confirm) = &state.confirm {
539        use widgets::ApprovalModalWidget;
540        let widget = ApprovalModalWidget {
541            theme: &rstate.theme,
542            title: "Confirm".to_string(),
543            body: confirm.prompt.as_str(),
544            options: vec!["y. Yes".to_string(), "n. No  (Esc)".to_string()],
545            selected_index: None,
546            accent: rstate.theme.colors.warning.to_color(),
547        };
548        frame.render_widget(widget, chunks[4]);
549    } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
550        use widgets::ConversationListWidget;
551        let widget = ConversationListWidget {
552            theme: &rstate.theme,
553            candidates,
554            cursor: *cursor,
555        };
556        frame.render_widget(widget, chunks[4]);
557    } else if let crate::domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
558        use widgets::RewindPickerWidget;
559        let widget = RewindPickerWidget {
560            theme: &rstate.theme,
561            candidates,
562            cursor: *cursor,
563        };
564        frame.render_widget(widget, chunks[4]);
565    } else if let crate::domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
566        use widgets::PlanConfigWidget;
567        let widget = PlanConfigWidget {
568            theme: &rstate.theme,
569            plan: &state.settings.plan,
570            session_model: &state.session.model_id,
571            cursor: *cursor,
572        };
573        frame.render_widget(widget, chunks[4]);
574    } else if file_picker_open {
575        use widgets::FilePickerWidget;
576        let widget = FilePickerWidget {
577            theme: &rstate.theme,
578            matches: &state.ui.file_picker_matches,
579            selected_index: state.ui.file_picker_cursor.unwrap_or(0),
580            loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
581        };
582        frame.render_widget(widget, chunks[4]);
583    } else if palette_open {
584        let typed = state
585            .ui
586            .input_buffer
587            .trim_start_matches('/')
588            .split_whitespace()
589            .next()
590            .unwrap_or("");
591        let entries = crate::domain::slash_commands::filter_entries(typed, &state.plugin_commands);
592        let palette_widget = SlashPaletteWidget {
593            theme: &rstate.theme,
594            entries,
595            selected_index: state.ui.palette_cursor.unwrap_or(0),
596        };
597        frame.render_widget(palette_widget, chunks[4]);
598    } else {
599        let cwd = state.cwd.display().to_string();
600        let status_widget = StatusWidget {
601            theme: &rstate.theme,
602            working_dir: &cwd,
603            hostname: &rstate.hostname,
604            username: &rstate.username,
605            version: &rstate.version,
606            context_usage: state.session.context_usage.as_ref(),
607            model_name: &state.session.model_id,
608            reasoning_level: effective,
609            requested_level,
610            safety_mode: state.session.safety_mode,
611            plan_active: state.session.plan.is_some(),
612        };
613        frame.render_widget(status_widget, chunks[4]);
614    }
615}
616
617/// Can a `Continuation` message be folded into this predecessor? Guards the
618/// stitch against non-bubble assistants: a compaction checkpoint's assistant
619/// half (`ContextCheckpoint`, rendered as an event block), the empty
620/// error-carrier message, or an assistant that ended in tool calls.
621/// `pub(crate)` so the chat widget applies the same rule when deciding to
622/// draw a streaming continuation without a fresh bubble prefix.
623pub(crate) fn mergeable_into(prev: &crate::models::ChatMessage) -> bool {
624    prev.role == crate::models::MessageRole::Assistant
625        && matches!(
626            prev.kind,
627            crate::models::ChatMessageKind::Normal | crate::models::ChatMessageKind::Continuation
628        )
629        && prev.tool_calls.is_none()
630}
631
632/// Does the committed log contain anything the stitch pre-pass would change?
633/// The common session never does — and then rendering keeps borrowing the
634/// committed slice with zero copies, exactly as before auto-continue existed.
635fn needs_stitch(committed: &[crate::models::ChatMessage]) -> bool {
636    committed.iter().any(|m| {
637        matches!(
638            m.kind,
639            crate::models::ChatMessageKind::Continuation
640                | crate::models::ChatMessageKind::RecoveryNudge
641        )
642    })
643}
644
645/// Fingerprint of every committed-message field the stitched transcript
646/// depends on. Cheap relative to re-stitching (hashing, no cloning); mirrors
647/// the chat widget's frame fingerprint so in-place mutations that don't
648/// change message count (e.g. an action attached to the last message during a
649/// tool run) still invalidate the memo.
650fn stitch_fingerprint(committed: &[crate::models::ChatMessage]) -> u64 {
651    use std::fmt::Write as _;
652    use std::hash::{Hash, Hasher};
653    struct HashWrite<'a, H: Hasher>(&'a mut H);
654    impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
655        fn write_str(&mut self, s: &str) -> std::fmt::Result {
656            self.0.write(s.as_bytes());
657            Ok(())
658        }
659    }
660    let mut h = rustc_hash::FxHasher::default();
661    committed.len().hash(&mut h);
662    for msg in committed {
663        msg.content.hash(&mut h);
664        msg.thinking.hash(&mut h);
665        msg.timestamp.timestamp().hash(&mut h);
666        msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
667        msg.image_numbers
668            .as_ref()
669            .map_or(0, |v| v.len())
670            .hash(&mut h);
671        let mut hw = HashWrite(&mut h);
672        let _ = write!(
673            hw,
674            "{:?}|{:?}|{:?}|{:?}",
675            msg.role,
676            msg.kind,
677            msg.actions,
678            msg.tool_calls.as_ref().map(|t| t.len())
679        );
680    }
681    h.finish()
682}
683
684/// The display stitch: fold committed `Continuation` messages into their
685/// predecessor bubble and hide spent `RecoveryNudge` notes, so an
686/// auto-continued reply reads as ONE uninterrupted assistant message.
687///
688/// Display-only — canonical history keeps the separate messages exactly as
689/// they crossed the wire (provider-correct, thinking-signature-safe). Merging
690/// the contents into one string here also means one `parse_markdown` call, so
691/// a code fence cut open by the output cap and re-closed in the continuation
692/// renders as a single intact block. A `Continuation` whose predecessor is
693/// not a mergeable bubble (archived by compaction, wedged system note)
694/// renders as its own message — a graceful seam, never a wrong merge.
695fn stitch_committed(committed: &[crate::models::ChatMessage]) -> Vec<crate::models::ChatMessage> {
696    let mut out: Vec<crate::models::ChatMessage> = Vec::with_capacity(committed.len());
697    for msg in committed {
698        if msg.kind == crate::models::ChatMessageKind::RecoveryNudge {
699            continue;
700        }
701        if msg.kind == crate::models::ChatMessageKind::Continuation
702            && let Some(prev) = out.last_mut()
703            && mergeable_into(prev)
704        {
705            merge_continuation(prev, msg);
706            continue;
707        }
708        out.push(msg.clone());
709    }
710    out
711}
712
713/// Fold one continuation segment into the bubble it resumes. The seam gets a
714/// conservative overlap trim (see `continuation_overlap`): a resume-echo of
715/// the previous tail is dropped, anything ambiguous is kept.
716fn merge_continuation(prev: &mut crate::models::ChatMessage, cont: &crate::models::ChatMessage) {
717    let skip = crate::utils::continuation_overlap(&prev.content, &cont.content);
718    prev.content.push_str(&cont.content[skip..]);
719    if let Some(cont_thinking) = &cont.thinking {
720        match &mut prev.thinking {
721            Some(t) => {
722                t.push_str("\n\n");
723                t.push_str(cont_thinking);
724            },
725            None => prev.thinking = Some(cont_thinking.clone()),
726        }
727    }
728    prev.actions.extend(cont.actions.iter().cloned());
729    if let Some(imgs) = &cont.images {
730        prev.images
731            .get_or_insert_with(Vec::new)
732            .extend(imgs.iter().cloned());
733    }
734    if let Some(nums) = &cont.image_numbers {
735        prev.image_numbers
736            .get_or_insert_with(Vec::new)
737            .extend(nums.iter().copied());
738    }
739    // A continuation that resumed the reply and then called tools carries the
740    // calls; the merged bubble inherits them (the guard ensured prev had none).
741    if cont.tool_calls.is_some() {
742        prev.tool_calls = cont.tool_calls.clone();
743    }
744}
745
746/// Merge the committed message log with the live turn's in-flight view:
747/// partial streamed content from `TurnState::Generating`, or the executing
748/// batch's action rows from `TurnState::ExecutingTools`. The chat widget
749/// renders this as a single stream.
750///
751/// While tools run, each call gets its transcript action row immediately —
752/// completed calls with their real outcome, still-running ones as a
753/// `Running` placeholder whose header dot blinks (Claude Code parity: the
754/// transcript, not the status spinner, names the tool). Two kinds of pending
755/// call are skipped: `agent` (the live agent panel under the spinner carries
756/// them) and `ask_user_question` (the modal IS its in-flight representation;
757/// the question → answer block lands once answered).
758///
759/// `committed` is the (possibly stitched) display transcript. When the live
760/// turn is an auto-continue, the pseudo-message is stamped `Continuation` so
761/// the widget draws it as a prefix-less extension of the previous bubble, and
762/// its leading resume-echo is trimmed against that bubble's tail — the
763/// in-flight reply looks like one message while it streams, not just after
764/// it commits.
765fn build_live_messages<'a>(
766    committed: &'a [crate::models::ChatMessage],
767    turn: &TurnState,
768    now: chrono::DateTime<chrono::Local>,
769) -> std::borrow::Cow<'a, [crate::models::ChatMessage]> {
770    if let TurnState::ExecutingTools {
771        calls, outcomes, ..
772    } = turn
773    {
774        let actions: Vec<crate::domain::ActionDisplay> = calls
775            .iter()
776            .zip(outcomes)
777            .filter_map(|(call, outcome)| match outcome {
778                Some(outcome) => Some(crate::domain::transition::action_display_for(call, outcome)),
779                None => {
780                    let name = call.source.function.name.as_str();
781                    if name == "agent" || name == "ask_user_question" {
782                        return None;
783                    }
784                    let (action_type, target) = crate::domain::display_info_for(call);
785                    Some(crate::domain::ActionDisplay {
786                        action_type,
787                        target,
788                        result: crate::domain::ActionResult::Running,
789                        details: crate::domain::ActionDetails::Simple,
790                        duration_seconds: None,
791                        metadata: None,
792                    })
793                },
794            })
795            .collect();
796        if actions.is_empty() {
797            return std::borrow::Cow::Borrowed(committed);
798        }
799        let mut msg = crate::models::ChatMessage::assistant("");
800        msg.timestamp = now;
801        msg.actions = actions;
802        let mut out = committed.to_vec();
803        out.push(msg);
804        return std::borrow::Cow::Owned(out);
805    }
806    // Idle / no-partial frames borrow the committed log directly — no per-frame
807    // clone of the whole transcript. Only an in-flight partial forces an owned
808    // copy (committed + the one live assistant message).
809    if let TurnState::Generating {
810        partial_text,
811        partial_reasoning,
812        continuation,
813        ..
814    } = turn
815        && (!partial_text.is_empty() || !partial_reasoning.is_empty())
816    {
817        let thinking = if partial_reasoning.is_empty() {
818            None
819        } else {
820            Some(partial_reasoning.clone())
821        };
822        let stitching = *continuation && committed.last().is_some_and(mergeable_into);
823        let content = if stitching {
824            let prev = &committed[committed.len() - 1].content;
825            let skip = crate::utils::continuation_overlap(prev, partial_text);
826            partial_text[skip..].to_string()
827        } else {
828            partial_text.clone()
829        };
830        let msg = crate::models::ChatMessage {
831            role: crate::models::MessageRole::Assistant,
832            content,
833            // `state.now` (stamped each tick) keeps render a pure function of
834            // State — never read the wall clock here.
835            timestamp: now,
836            kind: if stitching {
837                crate::models::ChatMessageKind::Continuation
838            } else {
839                crate::models::ChatMessageKind::Normal
840            },
841            metadata: None,
842            actions: Vec::new(),
843            thinking,
844            images: None,
845            image_numbers: None,
846            tool_calls: None,
847            tool_call_id: None,
848            tool_name: None,
849            provider_continuation: None,
850        };
851        let mut out = committed.to_vec();
852        out.push(msg);
853        std::borrow::Cow::Owned(out)
854    } else {
855        std::borrow::Cow::Borrowed(committed)
856    }
857}
858
859/// True while a first Ctrl+C's exit-confirmation window is open. Expiry is
860/// lazy: compared against the injected `state.now` (stamped every tick), so
861/// the hint disappears on the next tick frame with no reducer state change.
862fn exit_armed(state: &State) -> bool {
863    state
864        .ui
865        .exit_armed_until
866        .is_some_and(|deadline| state.now <= deadline)
867}
868
869/// True while a first idle Esc's rewind window is open (same lazy-expiry
870/// pattern as `exit_armed`; the reducer owns the 1s window constant).
871fn rewind_armed(state: &State) -> bool {
872    state
873        .ui
874        .esc_armed_at
875        .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
876}
877
878/// Data for the live agent panel + the status-line adjustments it implies:
879/// one `AgentPanelRow` per in-flight `agent` call (plus every detached
880/// background agent), a status override ("Running N agents") when agents are
881/// the only pending work, and whether anything running can honor Ctrl+B.
882fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
883    let now_sys = std::time::SystemTime::from(state.now);
884    let elapsed_since =
885        |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
886
887    let mut rows = Vec::new();
888    let mut running_agents = 0usize;
889    let mut pending_total = 0usize;
890    let mut bg_available = false;
891    if let TurnState::ExecutingTools {
892        calls,
893        outcomes,
894        started,
895        ..
896    } = &state.turn
897    {
898        let elapsed = elapsed_since(*started);
899        for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
900            pending_total += 1;
901            let name = call.source.function.name.as_str();
902            if name == "execute_command" || name == "agent" {
903                bg_available = true;
904            }
905            if name != "agent" {
906                continue;
907            }
908            running_agents += 1;
909            let (_, description) = crate::domain::display_info_for(call);
910            let live = state.ui.live_tool_status.get(&call.call_id);
911            rows.push(widgets::AgentPanelRow {
912                description,
913                activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
914                tokens: live.map_or(0, |l| l.tokens),
915                elapsed_secs: elapsed,
916                backgrounded: false,
917            });
918        }
919    }
920    for agent in &state.runtime.background_agents {
921        rows.push(widgets::AgentPanelRow {
922            description: agent.description.clone(),
923            activity: agent.activity.clone(),
924            tokens: agent.tokens,
925            elapsed_secs: elapsed_since(agent.started),
926            backgrounded: true,
927        });
928    }
929    let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
930        if running_agents == 1 {
931            "Running 1 agent".to_string()
932        } else {
933            format!("Running {running_agents} agents")
934        }
935    });
936    (rows, status_override, bg_available)
937}
938
939/// Future hook: consult `ProviderFactory` for per-model capabilities.
940/// Today returns `None` — reasoning snap indicator is suppressed
941/// until the factory is threaded through `State` (or an equivalent
942/// capability table).
943fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
944    None
945}
946
947/// Render one frame into a plain-text character grid at the given size.
948/// Test-only: shared by the unit tests below and the snapshot suite
949/// (`snapshots.rs`), which needs to control both the frame size and the
950/// `RenderCache` (pinned hostname/username).
951#[cfg(test)]
952pub(crate) fn render_frame(
953    state: &State,
954    rstate: &mut RenderCache,
955    width: u16,
956    height: u16,
957) -> String {
958    use ratatui::Terminal;
959    use ratatui::backend::TestBackend;
960    let backend = TestBackend::new(width, height);
961    let mut terminal = Terminal::new(backend).expect("terminal");
962    terminal.draw(|f| render(state, rstate, f)).expect("draw");
963    let buf = terminal.backend().buffer();
964    let mut out = String::new();
965    for y in 0..buf.area.height {
966        for x in 0..buf.area.width {
967            out.push_str(buf[(x, y)].symbol());
968        }
969        out.push('\n');
970    }
971    out
972}
973
974// TZ-sensitive (`temp_env` TZ pinning) and fixture scripts assume unix paths;
975// the unit tests above already cover Windows-relevant logic.
976#[cfg(all(test, unix))]
977mod snapshots;
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982    use crate::app::Config;
983    use crate::domain::{State, TurnState};
984    use ratatui::Terminal;
985    use ratatui::backend::TestBackend;
986    use std::path::PathBuf;
987
988    fn mock_state() -> State {
989        State::new(
990            Config::default(),
991            PathBuf::from("/tmp/p"),
992            "ollama/test".to_string(),
993            chrono::Local::now(),
994        )
995    }
996
997    fn render_to_string(state: &State) -> String {
998        render_frame(state, &mut RenderCache::new(), 80, 24)
999    }
1000
1001    fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1002        let backend = TestBackend::new(80, 24);
1003        let mut terminal = Terminal::new(backend).expect("terminal");
1004        let mut rstate = RenderCache::new();
1005        terminal
1006            .draw(|f| render(state, &mut rstate, f))
1007            .expect("draw");
1008        terminal.backend().buffer().clone()
1009    }
1010
1011    #[test]
1012    fn theme_choice_changes_colors_never_glyphs() {
1013        // Guards the "theme changes can't break snapshots" claim: light,
1014        // dark, and NO_COLOR-plain frames must be glyph-identical — a theme
1015        // is a palette, not a layout.
1016        let mut state = mock_state();
1017        state
1018            .session
1019            .append(crate::models::ChatMessage::user("hello"), state.now);
1020        let dark = render_to_string(&state);
1021        state.ui.theme = crate::app::ThemeChoice::Light;
1022        let light = render_to_string(&state);
1023        assert_eq!(dark, light, "light theme changed glyphs");
1024        state.ui.no_color = true;
1025        let plain = render_to_string(&state);
1026        assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1027    }
1028
1029    #[test]
1030    fn theme_memo_swaps_palette_on_state_change() {
1031        let mut state = mock_state();
1032        let mut rstate = RenderCache::new();
1033        render_frame(&state, &mut rstate, 80, 24);
1034        assert_eq!(rstate.theme.name, "Dark");
1035        state.ui.theme = crate::app::ThemeChoice::Light;
1036        render_frame(&state, &mut rstate, 80, 24);
1037        assert_eq!(rstate.theme.name, "Light");
1038        // NO_COLOR beats the theme choice.
1039        state.ui.no_color = true;
1040        render_frame(&state, &mut rstate, 80, 24);
1041        assert_eq!(rstate.theme.name, "Plain");
1042    }
1043
1044    #[test]
1045    fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1046        use crate::domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1047
1048        let mut state = mock_state();
1049        let call_id = ToolCallId(7);
1050        state.turn = TurnState::ExecutingTools {
1051            id: TurnId(1),
1052            started: std::time::SystemTime::now(),
1053            calls: vec![PendingToolCall {
1054                call_id,
1055                source: crate::models::tool_call::ToolCall {
1056                    id: None,
1057                    function: crate::models::tool_call::FunctionCall {
1058                        name: "agent".to_string(),
1059                        arguments: serde_json::json!({"description": "explore crates"}),
1060                    },
1061                },
1062            }],
1063            outcomes: vec![None],
1064        };
1065        state.ui.live_tool_status.insert(
1066            call_id,
1067            LiveToolStatus {
1068                activity: "read_file…".to_string(),
1069                tokens: 12_300,
1070            },
1071        );
1072
1073        // Agent calls never get a live transcript action row — they get panel
1074        // rows (and the "Running N agents" override) instead.
1075        let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1076        assert!(
1077            live.is_empty(),
1078            "a pending agent call must not synthesize a transcript row"
1079        );
1080        let (rows, override_text, bg_available) = agent_panel_data(&state);
1081        assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1082        assert!(bg_available, "agents are detachable via ctrl+b");
1083        assert_eq!(rows.len(), 1);
1084        assert_eq!(rows[0].description, "explore crates");
1085        assert_eq!(rows[0].activity, "read_file…");
1086        assert_eq!(rows[0].tokens, 12_300);
1087        assert!(!rows[0].backgrounded);
1088    }
1089
1090    #[test]
1091    fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1092        use crate::domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1093
1094        let mut state = mock_state();
1095        let exec_id = ToolCallId(8);
1096        let agent_id = ToolCallId(9);
1097        let call = |id, name: &str, args| PendingToolCall {
1098            call_id: id,
1099            source: crate::models::tool_call::ToolCall {
1100                id: None,
1101                function: crate::models::tool_call::FunctionCall {
1102                    name: name.to_string(),
1103                    arguments: args,
1104                },
1105            },
1106        };
1107        state.turn = TurnState::ExecutingTools {
1108            id: TurnId(1),
1109            started: std::time::SystemTime::now(),
1110            calls: vec![
1111                call(
1112                    exec_id,
1113                    "execute_command",
1114                    serde_json::json!({"command": "cargo test"}),
1115                ),
1116                call(
1117                    agent_id,
1118                    "agent",
1119                    serde_json::json!({"description": "audit docs"}),
1120                ),
1121            ],
1122            outcomes: vec![None, None],
1123        };
1124        state.ui.live_tool_status.insert(
1125            exec_id,
1126            LiveToolStatus {
1127                activity: String::new(),
1128                tokens: 0,
1129            },
1130        );
1131
1132        // The shell command gets a live Running transcript row; the agent gets
1133        // only its panel row. No override since agents aren't the only work.
1134        let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1135        assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1136        let actions = &live[0].actions;
1137        assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1138        assert_eq!(actions[0].action_type, "Bash");
1139        assert_eq!(actions[0].target, "cargo test");
1140        assert!(matches!(
1141            actions[0].result,
1142            crate::domain::ActionResult::Running
1143        ));
1144        let (rows, override_text, _) = agent_panel_data(&state);
1145        assert_eq!(override_text, None);
1146        assert_eq!(rows.len(), 1);
1147    }
1148
1149    #[test]
1150    fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1151        use crate::domain::{GenPhase, TurnId};
1152        use crate::models::ChatMessage;
1153        use std::borrow::Cow;
1154        use std::time::SystemTime;
1155
1156        let committed = vec![ChatMessage::user("hi")];
1157        let now = chrono::Local::now();
1158
1159        // Idle frames borrow the committed log unchanged — no per-frame clone.
1160        let idle = build_live_messages(&committed, &TurnState::Idle, now);
1161        assert!(matches!(idle, Cow::Borrowed(_)));
1162        assert_eq!(idle.len(), 1);
1163
1164        // A generating partial yields an owned copy whose live message is stamped
1165        // from the injected `now`, never the wall clock (render purity, #135).
1166        let turn = TurnState::Generating {
1167            id: TurnId(1),
1168            started: SystemTime::now(),
1169            partial_text: "draft".to_string(),
1170            partial_reasoning: String::new(),
1171            tokens: 0,
1172            phase: GenPhase::Sending,
1173            provider_continuation: None,
1174            pending_tool_calls: Vec::new(),
1175            continuation: false,
1176        };
1177        let live = build_live_messages(&committed, &turn, now);
1178        assert!(matches!(live, Cow::Owned(_)));
1179        assert_eq!(live.len(), 2);
1180        assert_eq!(live[1].timestamp, now);
1181    }
1182
1183    fn kinded(
1184        mut msg: crate::models::ChatMessage,
1185        kind: crate::models::ChatMessageKind,
1186    ) -> crate::models::ChatMessage {
1187        msg.kind = kind;
1188        msg
1189    }
1190
1191    #[test]
1192    fn stitch_committed_merges_chain_and_hides_nudges() {
1193        use crate::models::{ChatMessage, ChatMessageKind};
1194        let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1195        part1.thinking = Some("first trace".to_string());
1196        // The continuation echoes the tail of part1 — the seam trim drops it.
1197        let mut part2 = kinded(
1198            ChatMessage::assistant("issues in the resolver, and here is the fix."),
1199            ChatMessageKind::Continuation,
1200        );
1201        part2.thinking = Some("second trace".to_string());
1202        let committed = vec![
1203            ChatMessage::user("audit the widget"),
1204            part1,
1205            kinded(
1206                ChatMessage::system("resume nudge"),
1207                ChatMessageKind::RecoveryNudge,
1208            ),
1209            part2,
1210        ];
1211
1212        assert!(needs_stitch(&committed));
1213        let stitched = stitch_committed(&committed);
1214        assert_eq!(stitched.len(), 2, "user + one merged bubble");
1215        assert_eq!(
1216            stitched[1].content,
1217            "The audit found three issues in the resolver, and here is the fix.",
1218            "contents merge with the resume echo trimmed"
1219        );
1220        assert_eq!(
1221            stitched[1].thinking.as_deref(),
1222            Some("first trace\n\nsecond trace"),
1223            "both reasoning segments survive in order"
1224        );
1225        assert!(
1226            !stitched.iter().any(|m| m.content.contains("resume nudge")),
1227            "nudges never render"
1228        );
1229    }
1230
1231    #[test]
1232    fn stitch_refuses_non_bubble_predecessor() {
1233        use crate::models::{ChatMessage, ChatMessageKind};
1234        // A continuation whose bubble was archived by compaction lands after
1235        // the checkpoint's assistant half — render it as its own message
1236        // (graceful seam) rather than merging into the event block.
1237        let committed = vec![
1238            kinded(
1239                ChatMessage::assistant("checkpoint summary"),
1240                ChatMessageKind::ContextCheckpoint,
1241            ),
1242            kinded(
1243                ChatMessage::assistant("orphaned continuation"),
1244                ChatMessageKind::Continuation,
1245            ),
1246        ];
1247        let stitched = stitch_committed(&committed);
1248        assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1249        assert_eq!(stitched[1].content, "orphaned continuation");
1250    }
1251
1252    #[test]
1253    fn needs_stitch_is_false_for_plain_sessions() {
1254        use crate::models::ChatMessage;
1255        // The fast path: a session that never auto-continued skips the
1256        // pre-pass entirely (borrowed slice, no fingerprint, no clone).
1257        let committed = vec![
1258            ChatMessage::user("hi"),
1259            ChatMessage::assistant("hello"),
1260            ChatMessage::system("note"),
1261        ];
1262        assert!(!needs_stitch(&committed));
1263    }
1264
1265    #[test]
1266    fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1267        use crate::domain::{GenPhase, TurnId};
1268        use crate::models::{ChatMessage, ChatMessageKind};
1269
1270        let committed = vec![ChatMessage::assistant(
1271            "the fix lands in the resolver module",
1272        )];
1273        let turn = TurnState::Generating {
1274            id: TurnId(2),
1275            started: std::time::SystemTime::now(),
1276            partial_text: "in the resolver module, specifically the clamp".to_string(),
1277            partial_reasoning: String::new(),
1278            tokens: 0,
1279            phase: GenPhase::Streaming,
1280            provider_continuation: None,
1281            pending_tool_calls: Vec::new(),
1282            continuation: true,
1283        };
1284        let live = build_live_messages(&committed, &turn, chrono::Local::now());
1285        let streamed = live.last().expect("pseudo-message appended");
1286        assert_eq!(
1287            streamed.kind,
1288            ChatMessageKind::Continuation,
1289            "the live half is stamped so the widget draws it prefix-less"
1290        );
1291        assert_eq!(
1292            streamed.content, ", specifically the clamp",
1293            "the leading resume echo is trimmed against the committed tail"
1294        );
1295    }
1296
1297    #[test]
1298    fn auto_continued_reply_renders_as_one_bubble() {
1299        use crate::models::{ChatMessage, ChatMessageKind};
1300        let mut s = mock_state();
1301        s.session.append(ChatMessage::user("audit"), s.now);
1302        s.session
1303            .append(ChatMessage::assistant("part one of the reply"), s.now);
1304        s.session.append(
1305            kinded(
1306                ChatMessage::system("output limit — continuing"),
1307                ChatMessageKind::RecoveryNudge,
1308            ),
1309            s.now,
1310        );
1311        s.session.append(
1312            kinded(
1313                ChatMessage::assistant("and part two lands here"),
1314                ChatMessageKind::Continuation,
1315            ),
1316            s.now,
1317        );
1318
1319        let out = render_to_string(&s);
1320        assert!(out.contains("part one of the reply"));
1321        assert!(out.contains("and part two lands here"));
1322        assert!(
1323            !out.contains("continuing"),
1324            "the recovery nudge never renders"
1325        );
1326        assert_eq!(
1327            out.matches('●').count(),
1328            1,
1329            "both halves share one assistant bullet:\n{out}"
1330        );
1331    }
1332
1333    #[test]
1334    fn streaming_continuation_renders_without_fresh_bullet() {
1335        use crate::domain::{GenPhase, TurnId};
1336        use crate::models::{ChatMessage, ChatMessageKind};
1337        let mut s = mock_state();
1338        s.session.append(ChatMessage::user("audit"), s.now);
1339        s.session
1340            .append(ChatMessage::assistant("part one of the reply"), s.now);
1341        s.session.append(
1342            kinded(
1343                ChatMessage::system("output limit — continuing"),
1344                ChatMessageKind::RecoveryNudge,
1345            ),
1346            s.now,
1347        );
1348        s.turn = TurnState::Generating {
1349            id: TurnId(3),
1350            started: std::time::SystemTime::now(),
1351            partial_text: "and part two streams in".to_string(),
1352            partial_reasoning: String::new(),
1353            tokens: 0,
1354            phase: GenPhase::Streaming,
1355            provider_continuation: None,
1356            pending_tool_calls: Vec::new(),
1357            continuation: true,
1358        };
1359
1360        let out = render_to_string(&s);
1361        assert!(out.contains("part one of the reply"));
1362        assert!(out.contains("and part two streams in"));
1363        assert!(!out.contains("continuing"), "live nudge hidden too");
1364        assert_eq!(
1365            out.matches('●').count(),
1366            1,
1367            "the streaming half joins the committed bubble:\n{out}"
1368        );
1369    }
1370
1371    #[test]
1372    fn user_prompt_renders_with_highlight_band() {
1373        let mut s = mock_state();
1374        s.session
1375            .append(crate::models::ChatMessage::user("hello there"), s.now);
1376        let buf = render_to_buffer(&s);
1377        let band_bg = crate::render::theme::Theme::dark()
1378            .colors
1379            .user_message_background
1380            .to_color();
1381        // Row carrying the prompt text.
1382        let y = (0..buf.area.height)
1383            .find(|&y| {
1384                (0..buf.area.width)
1385                    .map(|x| buf[(x, y)].symbol())
1386                    .collect::<String>()
1387                    .contains("hello there")
1388            })
1389            .expect("user prompt should render");
1390        // The band fills the row: the great majority of cells carry the band bg
1391        // (a thin layout margin at the very edges may not).
1392        let banded = (0..buf.area.width)
1393            .filter(|&x| buf[(x, y)].bg == band_bg)
1394            .count();
1395        assert!(
1396            banded >= (buf.area.width as usize) * 3 / 4,
1397            "user prompt band should fill most of the row; only {banded}/{} cells banded",
1398            buf.area.width
1399        );
1400    }
1401
1402    #[test]
1403    fn idle_state_renders_cwd_and_model_footer() {
1404        let s = mock_state();
1405        let frame = render_to_string(&s);
1406        // Bottom status bar shows cwd + model id somewhere.
1407        assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1408        assert!(frame.contains("ollama/test"));
1409    }
1410
1411    #[test]
1412    fn status_line_appears_during_generating() {
1413        let mut s = mock_state();
1414        s.turn = crate::domain::transition::start_generating(
1415            crate::domain::TurnId(1),
1416            std::time::SystemTime::now(),
1417        );
1418        let frame = render_to_string(&s);
1419        assert!(
1420            frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1421            "expected generation status in frame"
1422        );
1423    }
1424
1425    #[test]
1426    fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1427        use crate::domain::PendingToolCall;
1428        use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1429        let mut s = mock_state();
1430        let call = PendingToolCall {
1431            call_id: crate::domain::ToolCallId(1),
1432            source: ModelToolCall {
1433                id: Some("c1".to_string()),
1434                function: FunctionCall {
1435                    name: "execute_command".to_string(),
1436                    arguments: serde_json::json!({"command": "npm run dev"}),
1437                },
1438            },
1439        };
1440        s.turn = TurnState::ExecutingTools {
1441            id: crate::domain::TurnId(1),
1442            started: std::time::SystemTime::now(),
1443            calls: vec![call],
1444            outcomes: vec![None],
1445        };
1446        let frame = render_to_string(&s);
1447        // The spinner headline is the bare phase word — the command must NOT
1448        // ride on it (the bug class this regression test pins down)…
1449        assert!(frame.contains("Running tools..."), "got: {frame}");
1450        assert!(
1451            !frame.contains("Running tools:"),
1452            "status line must not carry tool detail; got: {frame}"
1453        );
1454        // …because the transcript's live action row names it instead.
1455        assert!(
1456            frame.contains("npm run dev"),
1457            "transcript must show the in-flight call's action row; got: {frame}"
1458        );
1459    }
1460
1461    #[test]
1462    fn pending_question_and_agent_calls_get_no_transcript_row() {
1463        use crate::domain::PendingToolCall;
1464        use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1465        let mut s = mock_state();
1466        let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1467            call_id: crate::domain::ToolCallId(id),
1468            source: ModelToolCall {
1469                id: Some(format!("c{id}")),
1470                function: FunctionCall {
1471                    name: name.to_string(),
1472                    arguments: args,
1473                },
1474            },
1475        };
1476        s.turn = TurnState::ExecutingTools {
1477            id: crate::domain::TurnId(1),
1478            started: std::time::SystemTime::now(),
1479            calls: vec![
1480                mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1481                mk(
1482                    2,
1483                    "agent",
1484                    serde_json::json!({"description": "scan the repo"}),
1485                ),
1486            ],
1487            outcomes: vec![None, None],
1488        };
1489        let frame = render_to_string(&s);
1490        // The question's representation is the modal; the agent's is its
1491        // panel row under the spinner. Neither gets a transcript action row.
1492        assert!(
1493            !frame.contains("ask_user_question"),
1494            "pending question must not surface as a transcript row or status text; got: {frame}"
1495        );
1496    }
1497
1498    #[test]
1499    fn status_line_appears_during_tool_execution_and_shows_queue() {
1500        let mut s = mock_state();
1501        s.turn = TurnState::ExecutingTools {
1502            id: crate::domain::TurnId(1),
1503            started: std::time::SystemTime::now(),
1504            calls: Vec::new(),
1505            outcomes: Vec::new(),
1506        };
1507        s.ui.queued_messages
1508            .push_back(crate::domain::QueuedMessage {
1509                text: "please steer this".to_string(),
1510                attachment_ids: Vec::new(),
1511            });
1512        let frame = render_to_string(&s);
1513        assert!(frame.contains("Running tools"), "expected tool status");
1514        assert!(
1515            frame.contains("please steer this"),
1516            "queued busy input must be visible"
1517        );
1518    }
1519
1520    #[test]
1521    fn reasoning_blocks_are_collapsed_by_default() {
1522        let mut s = mock_state();
1523        let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
1524        first_msg.thinking = Some("first private chain of thought".to_string());
1525        s.session.append(first_msg, s.now);
1526        let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
1527        second_msg.thinking = Some("second private chain of thought".to_string());
1528        s.session.append(second_msg, s.now);
1529        let frame = render_to_string(&s);
1530        // Hidden reasoning is collapsed silently — no placeholder line.
1531        assert!(!frame.contains("Reasoning hidden"));
1532        assert!(frame.contains("first visible answer"));
1533        assert!(frame.contains("second visible answer"));
1534        assert!(!frame.contains("first private chain of thought"));
1535        assert!(!frame.contains("second private chain of thought"));
1536    }
1537
1538    /// A "thought, then immediately called a tool" turn (hidden reasoning +
1539    /// empty text + actions) renders the action directly — the turn is not
1540    /// skipped, and there is no "reasoning hidden" placeholder ahead of it.
1541    #[test]
1542    fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1543        let mut s = mock_state();
1544        let mut msg = crate::models::ChatMessage::assistant("");
1545        msg.thinking = Some("private chain of thought".to_string());
1546        msg.actions.push(crate::domain::ActionDisplay {
1547            action_type: "Bash".to_string(),
1548            target: "dir".to_string(),
1549            result: crate::domain::ActionResult::Success {
1550                output: "ok".to_string(),
1551                images: None,
1552            },
1553            details: crate::domain::ActionDetails::Simple,
1554            duration_seconds: Some(0.015),
1555            metadata: None,
1556        });
1557        s.session.append(msg, s.now);
1558        let frame = render_to_string(&s);
1559        assert!(
1560            !frame.contains("Reasoning hidden"),
1561            "no reasoning-hidden placeholder"
1562        );
1563        assert!(
1564            frame.contains("Bash"),
1565            "the action still renders even though reasoning is hidden"
1566        );
1567    }
1568
1569    #[test]
1570    fn committed_message_appears_in_chat_pane() {
1571        let mut s = mock_state();
1572        s.session.append(
1573            crate::models::ChatMessage::user("unique-user-token-xyz"),
1574            s.now,
1575        );
1576        let frame = render_to_string(&s);
1577        assert!(frame.contains("unique-user-token-xyz"));
1578    }
1579
1580    #[test]
1581    fn palette_renders_when_input_starts_with_slash() {
1582        let mut s = mock_state();
1583        s.ui.input_buffer = "/help".to_string();
1584        s.ui.input_cursor = 5;
1585        let frame = render_to_string(&s);
1586        // At least one registered command should surface in the overlay.
1587        assert!(frame.contains("help"));
1588    }
1589
1590    #[test]
1591    fn status_line_helper_maps_idle_to_idle() {
1592        assert_eq!(
1593            GenerationStatus::from_turn(&TurnState::Idle),
1594            GenerationStatus::Idle
1595        );
1596    }
1597}