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