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, StatusLineWidget, StatusWidget,
33};
34
35/// Transient render-layer state that lives across frames but isn't
36/// reducer state. Owned by `app::run_interactive`; passed as `&mut`
37/// to `render()` per frame.
38///
39/// Contents are pure memoization + UI affordances (scroll position,
40/// markdown cache, theme choice). Nothing here affects what the
41/// reducer sees or what ends up on disk — the cache can be dropped
42/// and rebuilt from `&State` at any time.
43pub struct RenderCache {
44    pub chat: ChatState,
45    pub markdown_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
46    pub theme: theme::Theme,
47    /// F13: last `state.ui.mouse_scroll_accum` value we applied to
48    /// `chat.scroll_up/down`. Diffing lets the reducer stay pure —
49    /// it just publishes a counter; render owns the chat-state side.
50    last_mouse_scroll_accum: i32,
51}
52
53impl Default for RenderCache {
54    fn default() -> Self {
55        Self {
56            chat: ChatState::new(),
57            markdown_cache: FxHashMap::default(),
58            theme: theme::Theme::dark(),
59            last_mouse_scroll_accum: 0,
60        }
61    }
62}
63
64impl RenderCache {
65    pub fn new() -> Self {
66        Self::default()
67    }
68}
69
70/// The entrypoint. Call once per render pass from the main loop.
71pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
72    // F13: consume any pending mouse-scroll accumulator. The reducer
73    // publishes a monotonic counter on `ui.mouse_scroll_accum`; we
74    // apply the delta to `ChatState` since the reducer isn't allowed
75    // to touch render-layer state directly.
76    let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
77    if pending > 0 {
78        rstate.chat.scroll_up(pending as u16);
79    } else if pending < 0 {
80        rstate.chat.scroll_down((-pending) as u16);
81    }
82    rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
83
84    // Input height: content-aware, respecting CJK/emoji widths.
85    let terminal_width = frame.area().width.saturating_sub(4) as usize;
86    let input_lines = if state.ui.input_buffer.is_empty() {
87        1
88    } else {
89        let mut lines = 1usize;
90        let mut col = 0usize;
91        for ch in state.ui.input_buffer.chars() {
92            let w = ch.width().unwrap_or(0);
93            if ch == '\n' || col >= terminal_width {
94                lines += 1;
95                col = if ch == '\n' { 0 } else { w };
96            } else {
97                col += w;
98            }
99        }
100        lines.min(5)
101    };
102    let input_height = (input_lines + 2) as u16;
103
104    let queued_count = state.ui.queued_messages.len();
105    let status_line_height = if state.is_busy() {
106        (1 + queued_count).min(6) as u16
107    } else {
108        0
109    };
110
111    let attachment_height = if state.ui.attachments.is_empty() {
112        0
113    } else {
114        1
115    };
116
117    // F9: one-row banner for `state.status`. Previously the reducer
118    // set state.status for slash commands, MCP errors, and model-pull
119    // progress but no widget painted it. Height is 1 when a status is
120    // present, 0 otherwise.
121    let status_banner_height: u16 = if state.status.is_some() { 1 } else { 0 };
122
123    // Bottom region: one of three widgets based on UI mode.
124    //   - ConversationList picker: 12-line pane.
125    //   - Slash palette (input starts with `/`): 3–10 lines based on
126    //     filter match count.
127    //   - Otherwise: 2-line status bar.
128    // Precedence: approval modal > confirm modal > ConversationList picker >
129    // slash palette > status bar. Approvals/confirms are interrupts that
130    // overlay regardless of input mode.
131    let approval_item = state.pending_approval.front();
132    let confirm_open = approval_item.is_none() && state.confirm.is_some();
133    let conv_list_open = approval_item.is_none()
134        && !confirm_open
135        && matches!(
136            state.ui.mode,
137            crate::domain::UiMode::ConversationList { .. }
138        );
139    let palette_open = approval_item.is_none()
140        && !confirm_open
141        && !conv_list_open
142        && state.ui.input_buffer.starts_with('/');
143    let bottom_height = if let Some(item) = approval_item {
144        // border(2) + body lines + blank(1) + 3 option lines
145        let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
146        2 + body_lines + 1 + 3
147    } else if confirm_open {
148        6
149    } else if conv_list_open {
150        12
151    } else if palette_open {
152        let typed = state
153            .ui
154            .input_buffer
155            .trim_start_matches('/')
156            .split_whitespace()
157            .next()
158            .unwrap_or("");
159        let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
160            .len()
161            .clamp(1, 8);
162        (row_count as u16) + 2
163    } else {
164        2
165    };
166
167    // 6-zone vertical layout: chat / status line / attachments /
168    // status banner / input / bottom. The banner sits directly above
169    // input so the eye finds "what just happened" right next to
170    // "what's next to type".
171    use ratatui::layout::{Constraint, Direction, Layout};
172    let chunks = Layout::default()
173        .direction(Direction::Vertical)
174        .constraints([
175            Constraint::Min(10),
176            Constraint::Length(status_line_height),
177            Constraint::Length(attachment_height),
178            Constraint::Length(status_banner_height),
179            Constraint::Length(input_height),
180            Constraint::Length(bottom_height),
181        ])
182        .split(frame.area());
183
184    // Chat area with 1-cell horizontal padding.
185    let chat_area = chunks[0].inner(Margin {
186        horizontal: 1,
187        vertical: 0,
188    });
189    let committed = state.session.messages().to_vec();
190    let live_messages = build_live_messages(&committed, &state.turn);
191    let chat_widget = ChatWidget {
192        messages: &live_messages,
193        theme: &rstate.theme,
194        markdown_cache: &mut rstate.markdown_cache,
195        show_reasoning: state.ui.show_reasoning,
196    };
197    frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
198
199    // Status line for every active turn. Tool execution previously
200    // reserved the row but did not paint it, which made queued input
201    // look like it had disappeared.
202    if state.is_busy() {
203        let elapsed_secs = match &state.turn {
204            TurnState::Generating { started, .. } | TurnState::Compacting { started, .. } => {
205                started.elapsed().map(|d| d.as_secs()).unwrap_or(0)
206            },
207            TurnState::Cancelling { since, .. } => {
208                since.elapsed().map(|d| d.as_secs()).unwrap_or(0)
209            },
210            TurnState::ExecutingTools { .. } | TurnState::Idle => 0,
211        };
212        let (tokens_display, tokens_estimated) = match &state.turn {
213            TurnState::Generating {
214                tokens,
215                partial_text,
216                ..
217            } if *tokens == 0 && !partial_text.is_empty() => (partial_text.len() / 4, true),
218            TurnState::Generating { tokens, .. } => (*tokens, false),
219            _ => (0, false),
220        };
221        let status_line_widget = StatusLineWidget {
222            status: GenerationStatus::from_turn(&state.turn),
223            elapsed_secs,
224            tokens_received: tokens_display,
225            tokens_estimated,
226            theme: &rstate.theme,
227            queued_messages: &state.ui.queued_messages,
228        };
229        frame.render_widget(status_line_widget, chunks[1]);
230    }
231
232    // Attachment bar.
233    if !state.ui.attachments.is_empty() {
234        let attachment_widget = AttachmentWidget {
235            attachments: &state.ui.attachments,
236            theme: &rstate.theme,
237            focused: state.ui.attachment_focused,
238            selected: state.ui.attachment_selected,
239        };
240        frame.render_widget(attachment_widget, chunks[2]);
241    }
242
243    // F9 banner for state.status — above input, below attachments.
244    if let Some(ref status) = state.status {
245        let banner = widgets::StatusBannerWidget {
246            theme: &rstate.theme,
247            status,
248        };
249        frame.render_widget(banner, chunks[3]);
250    }
251
252    // Input box.
253    let input_widget = InputWidget {
254        input: state.ui.input_buffer.as_str(),
255        showing_command_hints: state.ui.input_buffer.starts_with('/'),
256        theme: &rstate.theme,
257        reasoning_active: state.session.reasoning != ReasoningLevel::None,
258    };
259    let mut input_widget_state = InputState {
260        cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
261    };
262    frame.render_stateful_widget(input_widget, chunks[4], &mut input_widget_state);
263
264    // Cursor visible unless focus is on attachments.
265    if !state.ui.attachment_focused {
266        let input_area = chunks[4];
267        let content_width = input_area.width.saturating_sub(2) as usize;
268        let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
269            &state.ui.input_buffer,
270            state.ui.input_cursor.min(state.ui.input_buffer.len()),
271            content_width,
272        );
273        frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
274    }
275
276    // Effective reasoning level. Per-model supported_reasoning cap
277    // isn't threaded through `State` yet; defaults to no snap
278    // indicator until `ProviderFactory::capabilities` reaches here.
279    let requested = state.session.reasoning;
280    let effective = match supported_reasoning_for(state) {
281        Some(ReasoningCapability::Levels(supp)) => {
282            nearest_effort(requested, &supp).unwrap_or(requested)
283        },
284        _ => requested,
285    };
286    let requested_level = if effective == requested {
287        None
288    } else {
289        Some(requested)
290    };
291
292    // Bottom: conversation-list picker, slash-palette overlay, or
293    // persistent status bar — whichever the UI mode dictates.
294    if let Some(item) = state.pending_approval.front() {
295        use widgets::ApprovalModalWidget;
296        let widget = ApprovalModalWidget {
297            theme: &rstate.theme,
298            title: format!("Approval required — {}  [{}]", item.tool, item.risk),
299            body: item.prompt.as_str(),
300            options: vec![
301                "1. Yes".to_string(),
302                format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
303                "3. No  (Esc)".to_string(),
304            ],
305            selected_index: Some(item.selected_option),
306            accent: rstate.theme.colors.warning.to_color(),
307        };
308        frame.render_widget(widget, chunks[5]);
309    } else if let Some(confirm) = &state.confirm {
310        use widgets::ApprovalModalWidget;
311        let widget = ApprovalModalWidget {
312            theme: &rstate.theme,
313            title: "Confirm".to_string(),
314            body: confirm.prompt.as_str(),
315            options: vec!["y. Yes".to_string(), "n. No  (Esc)".to_string()],
316            selected_index: None,
317            accent: rstate.theme.colors.warning.to_color(),
318        };
319        frame.render_widget(widget, chunks[5]);
320    } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
321        use widgets::ConversationListWidget;
322        let widget = ConversationListWidget {
323            theme: &rstate.theme,
324            candidates,
325            cursor: *cursor,
326        };
327        frame.render_widget(widget, chunks[5]);
328    } else if palette_open {
329        let typed = state
330            .ui
331            .input_buffer
332            .trim_start_matches('/')
333            .split_whitespace()
334            .next()
335            .unwrap_or("");
336        let commands = crate::domain::slash_commands::filter_by_prefix(typed);
337        let palette_widget = SlashPaletteWidget {
338            theme: &rstate.theme,
339            commands,
340            selected_index: state.ui.palette_cursor.unwrap_or(0),
341        };
342        frame.render_widget(palette_widget, chunks[5]);
343    } else {
344        let cwd = state.cwd.display().to_string();
345        let status_widget = StatusWidget {
346            theme: &rstate.theme,
347            working_dir: &cwd,
348            context_usage: state.session.context_usage.as_ref(),
349            last_usage: state.session.last_token_usage,
350            session_usage: state.session.cumulative_token_usage,
351            model_name: &state.session.model_id,
352            reasoning_level: effective,
353            requested_level,
354            safety_mode: state.session.safety_mode,
355        };
356        frame.render_widget(status_widget, chunks[5]);
357    }
358}
359
360/// Merge the committed message log with any in-flight partial
361/// content from `TurnState::Generating`. The chat widget renders
362/// this as a single stream.
363fn build_live_messages(
364    committed: &[crate::models::ChatMessage],
365    turn: &TurnState,
366) -> Vec<crate::models::ChatMessage> {
367    let mut out = committed.to_vec();
368    if let TurnState::Generating {
369        partial_text,
370        partial_reasoning,
371        ..
372    } = turn
373        && (!partial_text.is_empty() || !partial_reasoning.is_empty())
374    {
375        let thinking = if partial_reasoning.is_empty() {
376            None
377        } else {
378            Some(partial_reasoning.clone())
379        };
380        let msg = crate::models::ChatMessage {
381            role: crate::models::MessageRole::Assistant,
382            content: partial_text.clone(),
383            timestamp: chrono::Local::now(),
384            kind: crate::models::ChatMessageKind::Normal,
385            metadata: None,
386            actions: Vec::new(),
387            thinking,
388            images: None,
389            tool_calls: None,
390            tool_call_id: None,
391            tool_name: None,
392            thinking_signature: None,
393        };
394        out.push(msg);
395    }
396    out
397}
398
399/// Future hook: consult `ProviderFactory` for per-model capabilities.
400/// Today returns `None` — reasoning snap indicator is suppressed
401/// until the factory is threaded through `State` (or an equivalent
402/// capability table).
403fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
404    None
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::app::Config;
411    use crate::domain::{State, StatusKind, StatusLine, TurnState};
412    use ratatui::Terminal;
413    use ratatui::backend::TestBackend;
414    use std::path::PathBuf;
415
416    fn mock_state() -> State {
417        State::new(
418            Config::default(),
419            PathBuf::from("/tmp/p"),
420            "ollama/test".to_string(),
421        )
422    }
423
424    fn render_to_string(state: &State) -> String {
425        let backend = TestBackend::new(80, 24);
426        let mut terminal = Terminal::new(backend).expect("terminal");
427        let mut rstate = RenderCache::new();
428        terminal
429            .draw(|f| render(state, &mut rstate, f))
430            .expect("draw");
431        let buf = terminal.backend().buffer();
432        let mut out = String::new();
433        for y in 0..buf.area.height {
434            for x in 0..buf.area.width {
435                out.push_str(buf[(x, y)].symbol());
436            }
437            out.push('\n');
438        }
439        out
440    }
441
442    #[test]
443    fn idle_state_renders_cwd_and_model_footer() {
444        let s = mock_state();
445        let frame = render_to_string(&s);
446        // Bottom status bar shows cwd + model id somewhere.
447        assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
448        assert!(frame.contains("ollama/test"));
449    }
450
451    #[test]
452    fn status_line_appears_during_generating() {
453        let mut s = mock_state();
454        s.turn = crate::domain::transition::start_generating(crate::domain::TurnId(1));
455        let frame = render_to_string(&s);
456        assert!(
457            frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
458            "expected generation status in frame"
459        );
460    }
461
462    #[test]
463    fn status_line_appears_during_tool_execution_and_shows_queue() {
464        let mut s = mock_state();
465        s.turn = TurnState::ExecutingTools {
466            id: crate::domain::TurnId(1),
467            calls: Vec::new(),
468            outcomes: Vec::new(),
469        };
470        s.ui.queued_messages
471            .push_back("please steer this".to_string());
472        let frame = render_to_string(&s);
473        assert!(frame.contains("Running tools"), "expected tool status");
474        assert!(
475            frame.contains("please steer this"),
476            "queued busy input must be visible"
477        );
478    }
479
480    #[test]
481    fn reasoning_blocks_are_collapsed_by_default() {
482        let mut s = mock_state();
483        let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
484        first_msg.thinking = Some("first private chain of thought".to_string());
485        s.session.append(first_msg);
486        let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
487        second_msg.thinking = Some("second private chain of thought".to_string());
488        s.session.append(second_msg);
489        let frame = render_to_string(&s);
490        assert_eq!(frame.matches("Reasoning hidden").count(), 1);
491        assert!(frame.contains("first visible answer"));
492        assert!(frame.contains("second visible answer"));
493        assert!(!frame.contains("first private chain of thought"));
494        assert!(!frame.contains("second private chain of thought"));
495    }
496
497    /// Regression: a "thought, then immediately called a tool" turn (hidden
498    /// reasoning + empty text + actions) must still put one blank line
499    /// between the "Reasoning hidden" placeholder and the first action —
500    /// the same gap every other block pair gets. Previously the placeholder
501    /// rendered flush against the first "● Bash(…)" line.
502    #[test]
503    fn reasoning_placeholder_is_gapped_from_following_action() {
504        let mut s = mock_state();
505        let mut msg = crate::models::ChatMessage::assistant("");
506        msg.thinking = Some("private chain of thought".to_string());
507        msg.actions.push(crate::domain::ActionDisplay {
508            action_type: "Bash".to_string(),
509            target: "dir".to_string(),
510            result: crate::domain::ActionResult::Success {
511                output: "ok".to_string(),
512                images: None,
513            },
514            details: crate::domain::ActionDetails::Simple,
515            duration_seconds: Some(0.015),
516            metadata: None,
517        });
518        s.session.append(msg);
519        let frame = render_to_string(&s);
520        let lines: Vec<&str> = frame.lines().collect();
521        let idx = lines
522            .iter()
523            .position(|l| l.contains("Reasoning hidden"))
524            .expect("placeholder must render");
525        assert!(
526            lines[idx + 1].trim().is_empty(),
527            "a blank line must separate the placeholder from the action; got {:?}",
528            &lines[idx..=(idx + 2).min(lines.len() - 1)]
529        );
530        assert!(
531            lines[idx..].iter().any(|l| l.contains("Bash")),
532            "the action must still render after the placeholder"
533        );
534    }
535
536    #[test]
537    fn scrollbar_shows_when_chat_overflows() {
538        let mut s = mock_state();
539        for i in 0..60 {
540            s.session
541                .append(crate::models::ChatMessage::assistant(format!("msg {i}")));
542        }
543        let frame = render_to_string(&s);
544        // ratatui's Scrollbar renders a "█" thumb in the reserved gutter once
545        // the transcript is taller than the viewport.
546        assert!(
547            frame.contains('█'),
548            "a scrollbar thumb should render when the transcript overflows the viewport"
549        );
550    }
551
552    #[test]
553    fn committed_message_appears_in_chat_pane() {
554        let mut s = mock_state();
555        s.session
556            .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
557        let frame = render_to_string(&s);
558        assert!(frame.contains("unique-user-token-xyz"));
559    }
560
561    #[test]
562    fn palette_renders_when_input_starts_with_slash() {
563        let mut s = mock_state();
564        s.ui.input_buffer = "/help".to_string();
565        s.ui.input_cursor = 5;
566        let frame = render_to_string(&s);
567        // At least one registered command should surface in the overlay.
568        assert!(frame.contains("help"));
569    }
570
571    #[test]
572    fn status_line_helper_maps_idle_to_idle() {
573        assert_eq!(
574            GenerationStatus::from_turn(&TurnState::Idle),
575            GenerationStatus::Idle
576        );
577    }
578
579    /// F9: `state.status` is painted as a banner above input. Before
580    /// this, the reducer would set `state.status` for slash-command
581    /// feedback and MCP errors but no widget displayed it.
582    #[test]
583    fn state_status_renders_as_banner() {
584        let mut s = mock_state();
585        s.status = Some(StatusLine {
586            text: "Reasoning: high".to_string(),
587            kind: StatusKind::Info,
588            shown_at: std::time::SystemTime::now(),
589        });
590        let frame = render_to_string(&s);
591        assert!(
592            frame.contains("Reasoning: high"),
593            "state.status must reach the screen"
594        );
595    }
596
597    #[test]
598    fn unused_status_line_struct_silences_warning() {
599        // Guard against dead_code on the imported StatusLine + StatusKind.
600        let _ = StatusLine {
601            text: "x".to_string(),
602            kind: StatusKind::Info,
603            shown_at: std::time::SystemTime::now(),
604        };
605    }
606}