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