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