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