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    AttachmentWidget, ChatState, ChatWidget, GenerationStatus, InputState, InputWidget,
31    SlashPaletteWidget, 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    pub theme: theme::Theme,
49    /// Host + user for the status bar's `user@host:cwd` line, read once at
50    /// startup so `StatusWidget::render` doesn't hit the environment on every
51    /// frame (#55). Process-constant, so caching here is exact.
52    pub hostname: String,
53    pub username: String,
54    /// F13: last `state.ui.mouse_scroll_accum` value we applied to
55    /// `chat.scroll_up/down`. Diffing lets the reducer stay pure —
56    /// it just publishes a counter; render owns the chat-state side.
57    last_mouse_scroll_accum: i32,
58}
59
60impl Default for RenderCache {
61    fn default() -> Self {
62        Self {
63            chat: ChatState::new(),
64            wrapped_line_cache: FxHashMap::default(),
65            theme: theme::Theme::dark(),
66            hostname: std::env::var("HOSTNAME")
67                .or_else(|_| std::env::var("HOST"))
68                .unwrap_or_else(|_| "localhost".to_string()),
69            username: std::env::var("USER")
70                .or_else(|_| std::env::var("USERNAME"))
71                .unwrap_or_else(|_| "user".to_string()),
72            last_mouse_scroll_accum: 0,
73        }
74    }
75}
76
77impl RenderCache {
78    pub fn new() -> Self {
79        Self::default()
80    }
81}
82
83/// The entrypoint. Call once per render pass from the main loop.
84pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
85    // F13: consume any pending mouse-scroll accumulator. The reducer
86    // publishes a monotonic counter on `ui.mouse_scroll_accum`; we
87    // apply the delta to `ChatState` since the reducer isn't allowed
88    // to touch render-layer state directly.
89    let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
90    if pending > 0 {
91        rstate.chat.scroll_up(pending as u16);
92    } else if pending < 0 {
93        rstate.chat.scroll_down((-pending) as u16);
94    }
95    rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
96
97    // Input height: content-aware, respecting CJK/emoji widths.
98    let terminal_width = frame.area().width.saturating_sub(4) as usize;
99    let input_lines = if state.ui.input_buffer.is_empty() {
100        1
101    } else {
102        let mut lines = 1usize;
103        let mut col = 0usize;
104        for ch in state.ui.input_buffer.chars() {
105            let w = ch.width().unwrap_or(0);
106            if ch == '\n' || col >= terminal_width {
107                lines += 1;
108                col = if ch == '\n' { 0 } else { w };
109            } else {
110                col += w;
111            }
112        }
113        lines.min(5)
114    };
115    let input_height = (input_lines + 2) as u16;
116
117    // Build the status-line rows up front (wrapped to the terminal width) so
118    // the layout reserves exactly the height they need — a long
119    // `Running tools: <cmd>` plus the trailing `(esc to interrupt …)` fold onto
120    // continuation rows instead of bleeding off the right edge.
121    let status_lines = if state.is_busy() {
122        // Elapsed is computed from the injected `state.now` (stamped every tick),
123        // not the live wall clock, so the rendered frame is a pure function of
124        // State (Cause 3). Visually identical — both resolve to whole seconds.
125        let now_sys = std::time::SystemTime::from(state.now);
126        let elapsed_since =
127            |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
128        let elapsed_secs = match &state.turn {
129            // A model run (generating + executing tools) anchors to the run start
130            // so the timer spans the whole agentic loop, not just this step.
131            TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
132                state
133                    .runtime
134                    .run_started
135                    .map_or_else(|| elapsed_since(*started), elapsed_since)
136            },
137            TurnState::Compacting { started, .. } => elapsed_since(*started),
138            TurnState::Cancelling { since, .. } => elapsed_since(*since),
139            TurnState::Idle => 0,
140        };
141        // While tools run, name the in-flight one(s) so the status line isn't an
142        // opaque "Running tools…". In-flight = the call slots without an outcome.
143        let active_tool = match &state.turn {
144            TurnState::ExecutingTools {
145                calls, outcomes, ..
146            } => {
147                let pending = outcomes.iter().filter(|o| o.is_none()).count();
148                calls
149                    .iter()
150                    .zip(outcomes)
151                    .find(|(_, o)| o.is_none())
152                    .map(|(call, _)| {
153                        let (action, target) = crate::domain::display_info_for(call);
154                        let label = if target.is_empty() {
155                            action
156                        } else {
157                            format!("{action} {target}")
158                        };
159                        if pending > 1 {
160                            format!("{label} (+{} more)", pending - 1)
161                        } else {
162                            label
163                        }
164                    })
165            },
166            _ => None,
167        };
168        // Live, char-based estimate of tokens generated so far this run (answer +
169        // thinking), accumulated across tool steps via `run_committed_tokens` so it
170        // doesn't reset each model call. Marked estimated (`~`); the authoritative
171        // count lands in the footer once the turn's `Done` usage arrives.
172        let committed = state.runtime.run_committed_tokens;
173        let (tokens_display, tokens_estimated) = match &state.turn {
174            TurnState::Generating { tokens, .. } => (committed + *tokens, true),
175            TurnState::ExecutingTools { .. } => (committed, true),
176            _ => (0, false),
177        };
178        build_status_lines(
179            GenerationStatus::from_turn(&state.turn),
180            elapsed_secs,
181            tokens_display,
182            tokens_estimated,
183            active_tool.as_deref(),
184            &state.ui.queued_messages,
185            &rstate.theme,
186            // Match the 1-cell horizontal pad the status zone is rendered with.
187            frame.area().width.saturating_sub(2),
188        )
189    } else {
190        Vec::new()
191    };
192
193    let attachment_height = if state.ui.attachments.is_empty() {
194        0
195    } else {
196        1
197    };
198
199    // Reserve the status zone's height to match its row count, but never so much
200    // that the input box or bottom bar get evicted on a short terminal: keep room
201    // for the chat floor (Min 10), the input box, the bottom bar (≥2), and the
202    // attachment rows. (The trailing Length zones would otherwise starve before
203    // the Min(10) chat zone does.)
204    let status_reserve = 10 + input_height + 2 + attachment_height;
205    let status_line_height = (status_lines.len() as u16)
206        .min(6)
207        .min(frame.area().height.saturating_sub(status_reserve));
208
209    // Bottom region: one of three widgets based on UI mode.
210    //   - ConversationList picker: 12-line pane.
211    //   - Slash palette (input starts with `/`): 3–10 lines based on
212    //     filter match count.
213    //   - Otherwise: 2-line status bar.
214    // Precedence: approval modal > confirm modal > ConversationList picker >
215    // slash palette > status bar. Approvals/confirms are interrupts that
216    // overlay regardless of input mode.
217    let approval_item = state.pending_approval.front();
218    let confirm_open = approval_item.is_none() && state.confirm.is_some();
219    let conv_list_open = approval_item.is_none()
220        && !confirm_open
221        && matches!(
222            state.ui.mode,
223            crate::domain::UiMode::ConversationList { .. }
224        );
225    let palette_open = approval_item.is_none()
226        && !confirm_open
227        && !conv_list_open
228        && state.ui.input_buffer.starts_with('/');
229    let bottom_height = if let Some(item) = approval_item {
230        // border(2) + body lines + blank(1) + 3 option lines
231        let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
232        2 + body_lines + 1 + 3
233    } else if confirm_open {
234        6
235    } else if conv_list_open {
236        12
237    } else if palette_open {
238        let typed = state
239            .ui
240            .input_buffer
241            .trim_start_matches('/')
242            .split_whitespace()
243            .next()
244            .unwrap_or("");
245        let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
246            .len()
247            .clamp(1, 8);
248        (row_count as u16) + 2
249    } else {
250        2
251    };
252
253    // 5-zone vertical layout: chat / status line / attachments / input / bottom.
254    use ratatui::layout::{Constraint, Direction, Layout};
255    let chunks = Layout::default()
256        .direction(Direction::Vertical)
257        .constraints([
258            Constraint::Min(10),
259            Constraint::Length(status_line_height),
260            Constraint::Length(attachment_height),
261            Constraint::Length(input_height),
262            Constraint::Length(bottom_height),
263        ])
264        .split(frame.area());
265
266    // Chat area with 1-cell horizontal padding.
267    let chat_area = chunks[0].inner(Margin {
268        horizontal: 1,
269        vertical: 0,
270    });
271    let live_messages = build_live_messages(state.session.messages(), &state.turn, state.now);
272    let chat_widget = ChatWidget {
273        messages: live_messages.as_ref(),
274        theme: &rstate.theme,
275        wrapped_line_cache: &mut rstate.wrapped_line_cache,
276        show_reasoning: state.ui.show_reasoning,
277    };
278    frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
279
280    // Status line for every active turn (built above, already fit to width).
281    // Indented 1 cell to align with the chat column's 1-cell pad.
282    if !status_lines.is_empty() {
283        let status_area = chunks[1].inner(Margin {
284            horizontal: 1,
285            vertical: 0,
286        });
287        frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
288    }
289
290    // Attachment bar.
291    if !state.ui.attachments.is_empty() {
292        let attachment_widget = AttachmentWidget {
293            attachments: &state.ui.attachments,
294            theme: &rstate.theme,
295            focused: state.ui.attachment_focused,
296            selected: state.ui.attachment_selected,
297        };
298        frame.render_widget(attachment_widget, chunks[2]);
299    }
300
301    // Input box.
302    let input_widget = InputWidget {
303        input: state.ui.input_buffer.as_str(),
304        showing_command_hints: state.ui.input_buffer.starts_with('/'),
305        theme: &rstate.theme,
306        reasoning_active: state.session.reasoning != ReasoningLevel::None,
307    };
308    let mut input_widget_state = InputState {
309        cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
310    };
311    frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
312
313    // Cursor visible unless focus is on attachments.
314    if !state.ui.attachment_focused {
315        let input_area = chunks[3];
316        let content_width = input_area.width.saturating_sub(2) as usize;
317        let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
318            &state.ui.input_buffer,
319            state.ui.input_cursor.min(state.ui.input_buffer.len()),
320            content_width,
321        );
322        frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
323    }
324
325    // Effective reasoning level. Per-model supported_reasoning cap
326    // isn't threaded through `State` yet; defaults to no snap
327    // indicator until `ProviderFactory::capabilities` reaches here.
328    let requested = state.session.reasoning;
329    let effective = match supported_reasoning_for(state) {
330        Some(ReasoningCapability::Levels(supp)) => {
331            nearest_effort(requested, &supp).unwrap_or(requested)
332        },
333        _ => requested,
334    };
335    let requested_level = if effective == requested {
336        None
337    } else {
338        Some(requested)
339    };
340
341    // Bottom: conversation-list picker, slash-palette overlay, or
342    // persistent status bar — whichever the UI mode dictates.
343    if let Some(item) = state.pending_approval.front() {
344        use widgets::ApprovalModalWidget;
345        // Content-bearing external tools (type_text, MCP, …) are
346        // non-allowlistable: the gate leaves their scope empty, and we omit the
347        // "don't ask again" option so the user can't blanket-approve them (#6, #31).
348        let options = if item.allowlist_scope.is_empty() {
349            vec!["1. Yes".to_string(), "2. No  (Esc)".to_string()]
350        } else {
351            vec![
352                "1. Yes".to_string(),
353                format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
354                "3. No  (Esc)".to_string(),
355            ]
356        };
357        let widget = ApprovalModalWidget {
358            theme: &rstate.theme,
359            title: format!("Approval required — {}  [{}]", item.tool, item.risk),
360            body: item.prompt.as_str(),
361            options,
362            selected_index: Some(item.selected_option),
363            accent: rstate.theme.colors.warning.to_color(),
364        };
365        frame.render_widget(widget, chunks[4]);
366    } else if let Some(confirm) = &state.confirm {
367        use widgets::ApprovalModalWidget;
368        let widget = ApprovalModalWidget {
369            theme: &rstate.theme,
370            title: "Confirm".to_string(),
371            body: confirm.prompt.as_str(),
372            options: vec!["y. Yes".to_string(), "n. No  (Esc)".to_string()],
373            selected_index: None,
374            accent: rstate.theme.colors.warning.to_color(),
375        };
376        frame.render_widget(widget, chunks[4]);
377    } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
378        use widgets::ConversationListWidget;
379        let widget = ConversationListWidget {
380            theme: &rstate.theme,
381            candidates,
382            cursor: *cursor,
383        };
384        frame.render_widget(widget, chunks[4]);
385    } else if palette_open {
386        let typed = state
387            .ui
388            .input_buffer
389            .trim_start_matches('/')
390            .split_whitespace()
391            .next()
392            .unwrap_or("");
393        let commands = crate::domain::slash_commands::filter_by_prefix(typed);
394        let palette_widget = SlashPaletteWidget {
395            theme: &rstate.theme,
396            commands,
397            selected_index: state.ui.palette_cursor.unwrap_or(0),
398        };
399        frame.render_widget(palette_widget, chunks[4]);
400    } else {
401        let cwd = state.cwd.display().to_string();
402        let status_widget = StatusWidget {
403            theme: &rstate.theme,
404            working_dir: &cwd,
405            hostname: &rstate.hostname,
406            username: &rstate.username,
407            context_usage: state.session.context_usage.as_ref(),
408            last_usage: state.session.last_token_usage,
409            session_usage: state.session.cumulative_token_usage,
410            model_name: &state.session.model_id,
411            reasoning_level: effective,
412            requested_level,
413            safety_mode: state.session.safety_mode,
414        };
415        frame.render_widget(status_widget, chunks[4]);
416    }
417}
418
419/// Merge the committed message log with any in-flight partial
420/// content from `TurnState::Generating`. The chat widget renders
421/// this as a single stream.
422fn build_live_messages<'a>(
423    committed: &'a [crate::models::ChatMessage],
424    turn: &TurnState,
425    now: chrono::DateTime<chrono::Local>,
426) -> std::borrow::Cow<'a, [crate::models::ChatMessage]> {
427    // Idle / no-partial frames borrow the committed log directly — no per-frame
428    // clone of the whole transcript. Only an in-flight partial forces an owned
429    // copy (committed + the one live assistant message).
430    if let TurnState::Generating {
431        partial_text,
432        partial_reasoning,
433        ..
434    } = turn
435        && (!partial_text.is_empty() || !partial_reasoning.is_empty())
436    {
437        let thinking = if partial_reasoning.is_empty() {
438            None
439        } else {
440            Some(partial_reasoning.clone())
441        };
442        let msg = crate::models::ChatMessage {
443            role: crate::models::MessageRole::Assistant,
444            content: partial_text.clone(),
445            // `state.now` (stamped each tick) keeps render a pure function of
446            // State — never read the wall clock here.
447            timestamp: now,
448            kind: crate::models::ChatMessageKind::Normal,
449            metadata: None,
450            actions: Vec::new(),
451            thinking,
452            images: None,
453            tool_calls: None,
454            tool_call_id: None,
455            tool_name: None,
456            thinking_signature: None,
457        };
458        let mut out = committed.to_vec();
459        out.push(msg);
460        std::borrow::Cow::Owned(out)
461    } else {
462        std::borrow::Cow::Borrowed(committed)
463    }
464}
465
466/// Future hook: consult `ProviderFactory` for per-model capabilities.
467/// Today returns `None` — reasoning snap indicator is suppressed
468/// until the factory is threaded through `State` (or an equivalent
469/// capability table).
470fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
471    None
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::app::Config;
478    use crate::domain::{State, TurnState};
479    use ratatui::Terminal;
480    use ratatui::backend::TestBackend;
481    use std::path::PathBuf;
482
483    fn mock_state() -> State {
484        State::new(
485            Config::default(),
486            PathBuf::from("/tmp/p"),
487            "ollama/test".to_string(),
488            chrono::Local::now(),
489        )
490    }
491
492    fn render_to_string(state: &State) -> String {
493        let backend = TestBackend::new(80, 24);
494        let mut terminal = Terminal::new(backend).expect("terminal");
495        let mut rstate = RenderCache::new();
496        terminal
497            .draw(|f| render(state, &mut rstate, f))
498            .expect("draw");
499        let buf = terminal.backend().buffer();
500        let mut out = String::new();
501        for y in 0..buf.area.height {
502            for x in 0..buf.area.width {
503                out.push_str(buf[(x, y)].symbol());
504            }
505            out.push('\n');
506        }
507        out
508    }
509
510    fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
511        let backend = TestBackend::new(80, 24);
512        let mut terminal = Terminal::new(backend).expect("terminal");
513        let mut rstate = RenderCache::new();
514        terminal
515            .draw(|f| render(state, &mut rstate, f))
516            .expect("draw");
517        terminal.backend().buffer().clone()
518    }
519
520    #[test]
521    fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
522        use crate::domain::{GenPhase, TurnId};
523        use crate::models::ChatMessage;
524        use std::borrow::Cow;
525        use std::time::SystemTime;
526
527        let committed = vec![ChatMessage::user("hi")];
528        let now = chrono::Local::now();
529
530        // Idle frames borrow the committed log unchanged — no per-frame clone.
531        let idle = build_live_messages(&committed, &TurnState::Idle, now);
532        assert!(matches!(idle, Cow::Borrowed(_)));
533        assert_eq!(idle.len(), 1);
534
535        // A generating partial yields an owned copy whose live message is stamped
536        // from the injected `now`, never the wall clock (render purity, #135).
537        let turn = TurnState::Generating {
538            id: TurnId(1),
539            started: SystemTime::now(),
540            partial_text: "draft".to_string(),
541            partial_reasoning: String::new(),
542            tokens: 0,
543            phase: GenPhase::Sending,
544            thinking_signature: None,
545            pending_tool_calls: Vec::new(),
546        };
547        let live = build_live_messages(&committed, &turn, now);
548        assert!(matches!(live, Cow::Owned(_)));
549        assert_eq!(live.len(), 2);
550        assert_eq!(live[1].timestamp, now);
551    }
552
553    #[test]
554    fn user_prompt_renders_with_highlight_band() {
555        let mut s = mock_state();
556        s.session
557            .append(crate::models::ChatMessage::user("hello there"), s.now);
558        let buf = render_to_buffer(&s);
559        let band_bg = crate::render::theme::Theme::dark()
560            .colors
561            .user_message_background
562            .to_color();
563        // Row carrying the prompt text.
564        let y = (0..buf.area.height)
565            .find(|&y| {
566                (0..buf.area.width)
567                    .map(|x| buf[(x, y)].symbol())
568                    .collect::<String>()
569                    .contains("hello there")
570            })
571            .expect("user prompt should render");
572        // The band fills the row: the great majority of cells carry the band bg
573        // (a thin layout margin at the very edges may not).
574        let banded = (0..buf.area.width)
575            .filter(|&x| buf[(x, y)].bg == band_bg)
576            .count();
577        assert!(
578            banded >= (buf.area.width as usize) * 3 / 4,
579            "user prompt band should fill most of the row; only {banded}/{} cells banded",
580            buf.area.width
581        );
582    }
583
584    #[test]
585    fn idle_state_renders_cwd_and_model_footer() {
586        let s = mock_state();
587        let frame = render_to_string(&s);
588        // Bottom status bar shows cwd + model id somewhere.
589        assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
590        assert!(frame.contains("ollama/test"));
591    }
592
593    #[test]
594    fn status_line_appears_during_generating() {
595        let mut s = mock_state();
596        s.turn = crate::domain::transition::start_generating(
597            crate::domain::TurnId(1),
598            std::time::SystemTime::now(),
599        );
600        let frame = render_to_string(&s);
601        assert!(
602            frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
603            "expected generation status in frame"
604        );
605    }
606
607    #[test]
608    fn status_line_names_the_in_flight_tool() {
609        use crate::domain::PendingToolCall;
610        use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
611        let mut s = mock_state();
612        let call = PendingToolCall {
613            call_id: crate::domain::ToolCallId(1),
614            source: ModelToolCall {
615                id: Some("c1".to_string()),
616                function: FunctionCall {
617                    name: "execute_command".to_string(),
618                    arguments: serde_json::json!({"command": "npm run dev"}),
619                },
620            },
621        };
622        s.turn = TurnState::ExecutingTools {
623            id: crate::domain::TurnId(1),
624            started: std::time::SystemTime::now(),
625            calls: vec![call],
626            outcomes: vec![None],
627        };
628        let frame = render_to_string(&s);
629        assert!(frame.contains("Running tools"), "got: {frame}");
630        assert!(
631            frame.contains("npm run dev"),
632            "status line must name the in-flight command; got: {frame}"
633        );
634    }
635
636    #[test]
637    fn status_line_appears_during_tool_execution_and_shows_queue() {
638        let mut s = mock_state();
639        s.turn = TurnState::ExecutingTools {
640            id: crate::domain::TurnId(1),
641            started: std::time::SystemTime::now(),
642            calls: Vec::new(),
643            outcomes: Vec::new(),
644        };
645        s.ui.queued_messages
646            .push_back(crate::domain::QueuedMessage {
647                text: "please steer this".to_string(),
648                attachment_ids: Vec::new(),
649            });
650        let frame = render_to_string(&s);
651        assert!(frame.contains("Running tools"), "expected tool status");
652        assert!(
653            frame.contains("please steer this"),
654            "queued busy input must be visible"
655        );
656    }
657
658    #[test]
659    fn reasoning_blocks_are_collapsed_by_default() {
660        let mut s = mock_state();
661        let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
662        first_msg.thinking = Some("first private chain of thought".to_string());
663        s.session.append(first_msg, s.now);
664        let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
665        second_msg.thinking = Some("second private chain of thought".to_string());
666        s.session.append(second_msg, s.now);
667        let frame = render_to_string(&s);
668        // Hidden reasoning is collapsed silently — no placeholder line.
669        assert!(!frame.contains("Reasoning hidden"));
670        assert!(frame.contains("first visible answer"));
671        assert!(frame.contains("second visible answer"));
672        assert!(!frame.contains("first private chain of thought"));
673        assert!(!frame.contains("second private chain of thought"));
674    }
675
676    /// A "thought, then immediately called a tool" turn (hidden reasoning +
677    /// empty text + actions) renders the action directly — the turn is not
678    /// skipped, and there is no "reasoning hidden" placeholder ahead of it.
679    #[test]
680    fn hidden_reasoning_then_action_renders_action_without_placeholder() {
681        let mut s = mock_state();
682        let mut msg = crate::models::ChatMessage::assistant("");
683        msg.thinking = Some("private chain of thought".to_string());
684        msg.actions.push(crate::domain::ActionDisplay {
685            action_type: "Bash".to_string(),
686            target: "dir".to_string(),
687            result: crate::domain::ActionResult::Success {
688                output: "ok".to_string(),
689                images: None,
690            },
691            details: crate::domain::ActionDetails::Simple,
692            duration_seconds: Some(0.015),
693            metadata: None,
694        });
695        s.session.append(msg, s.now);
696        let frame = render_to_string(&s);
697        assert!(
698            !frame.contains("Reasoning hidden"),
699            "no reasoning-hidden placeholder"
700        );
701        assert!(
702            frame.contains("Bash"),
703            "the action still renders even though reasoning is hidden"
704        );
705    }
706
707    #[test]
708    fn committed_message_appears_in_chat_pane() {
709        let mut s = mock_state();
710        s.session.append(
711            crate::models::ChatMessage::user("unique-user-token-xyz"),
712            s.now,
713        );
714        let frame = render_to_string(&s);
715        assert!(frame.contains("unique-user-token-xyz"));
716    }
717
718    #[test]
719    fn palette_renders_when_input_starts_with_slash() {
720        let mut s = mock_state();
721        s.ui.input_buffer = "/help".to_string();
722        s.ui.input_cursor = 5;
723        let frame = render_to_string(&s);
724        // At least one registered command should surface in the overlay.
725        assert!(frame.contains("help"));
726    }
727
728    #[test]
729    fn status_line_helper_maps_idle_to_idle() {
730        assert_eq!(
731            GenerationStatus::from_turn(&TurnState::Idle),
732            GenerationStatus::Idle
733        );
734    }
735}