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    let conv_list_open = matches!(
129        state.ui.mode,
130        crate::domain::UiMode::ConversationList { .. }
131    );
132    let palette_open = !conv_list_open && state.ui.input_buffer.starts_with('/');
133    let bottom_height = if conv_list_open {
134        12
135    } else if palette_open {
136        let typed = state
137            .ui
138            .input_buffer
139            .trim_start_matches('/')
140            .split_whitespace()
141            .next()
142            .unwrap_or("");
143        let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
144            .len()
145            .clamp(1, 8);
146        (row_count as u16) + 2
147    } else {
148        2
149    };
150
151    // 6-zone vertical layout: chat / status line / attachments /
152    // status banner / input / bottom. The banner sits directly above
153    // input so the eye finds "what just happened" right next to
154    // "what's next to type".
155    use ratatui::layout::{Constraint, Direction, Layout};
156    let chunks = Layout::default()
157        .direction(Direction::Vertical)
158        .constraints([
159            Constraint::Min(10),
160            Constraint::Length(status_line_height),
161            Constraint::Length(attachment_height),
162            Constraint::Length(status_banner_height),
163            Constraint::Length(input_height),
164            Constraint::Length(bottom_height),
165        ])
166        .split(frame.area());
167
168    // Chat area with 1-cell horizontal padding.
169    let chat_area = chunks[0].inner(Margin {
170        horizontal: 1,
171        vertical: 0,
172    });
173    let committed = state.session.messages().to_vec();
174    let live_messages = build_live_messages(&committed, &state.turn);
175    let chat_widget = ChatWidget {
176        messages: &live_messages,
177        theme: &rstate.theme,
178        markdown_cache: &mut rstate.markdown_cache,
179    };
180    frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
181
182    // Status line (only while generating).
183    if let TurnState::Generating {
184        started,
185        tokens,
186        partial_text,
187        ..
188    } = &state.turn
189    {
190        let elapsed_secs = started.elapsed().map(|d| d.as_secs()).unwrap_or(0);
191        let (tokens_display, tokens_estimated) = if *tokens == 0 && !partial_text.is_empty() {
192            (partial_text.len() / 4, true)
193        } else {
194            (*tokens, false)
195        };
196        let status_line_widget = StatusLineWidget {
197            status: GenerationStatus::from_turn(&state.turn),
198            elapsed_secs,
199            tokens_received: tokens_display,
200            tokens_estimated,
201            theme: &rstate.theme,
202            queued_messages: &state.ui.queued_messages,
203        };
204        frame.render_widget(status_line_widget, chunks[1]);
205    }
206
207    // Attachment bar.
208    if !state.ui.attachments.is_empty() {
209        let attachment_widget = AttachmentWidget {
210            attachments: &state.ui.attachments,
211            theme: &rstate.theme,
212            focused: state.ui.attachment_focused,
213            selected: state.ui.attachment_selected,
214        };
215        frame.render_widget(attachment_widget, chunks[2]);
216    }
217
218    // F9 banner for state.status — above input, below attachments.
219    if let Some(ref status) = state.status {
220        let banner = widgets::StatusBannerWidget {
221            theme: &rstate.theme,
222            status,
223        };
224        frame.render_widget(banner, chunks[3]);
225    }
226
227    // Input box.
228    let input_widget = InputWidget {
229        input: state.ui.input_buffer.as_str(),
230        showing_command_hints: state.ui.input_buffer.starts_with('/'),
231        theme: &rstate.theme,
232        reasoning_active: state.session.reasoning != ReasoningLevel::None,
233    };
234    let mut input_widget_state = InputState {
235        cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
236    };
237    frame.render_stateful_widget(input_widget, chunks[4], &mut input_widget_state);
238
239    // Cursor visible unless focus is on attachments.
240    if !state.ui.attachment_focused {
241        let input_area = chunks[4];
242        let content_width = input_area.width.saturating_sub(2) as usize;
243        let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
244            &state.ui.input_buffer,
245            state.ui.input_cursor.min(state.ui.input_buffer.len()),
246            content_width,
247        );
248        frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
249    }
250
251    // Effective reasoning level. Per-model supported_reasoning cap
252    // isn't threaded through `State` yet; defaults to no snap
253    // indicator until `ProviderFactory::capabilities` reaches here.
254    let requested = state.session.reasoning;
255    let effective = match supported_reasoning_for(state) {
256        Some(ReasoningCapability::Levels(supp)) => {
257            nearest_effort(requested, &supp).unwrap_or(requested)
258        },
259        _ => requested,
260    };
261    let requested_level = if effective == requested {
262        None
263    } else {
264        Some(requested)
265    };
266
267    // Bottom: conversation-list picker, slash-palette overlay, or
268    // persistent status bar — whichever the UI mode dictates.
269    if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
270        use widgets::ConversationListWidget;
271        let widget = ConversationListWidget {
272            theme: &rstate.theme,
273            candidates,
274            cursor: *cursor,
275        };
276        frame.render_widget(widget, chunks[5]);
277    } else if palette_open {
278        let typed = state
279            .ui
280            .input_buffer
281            .trim_start_matches('/')
282            .split_whitespace()
283            .next()
284            .unwrap_or("");
285        let commands = crate::domain::slash_commands::filter_by_prefix(typed);
286        let palette_widget = SlashPaletteWidget {
287            theme: &rstate.theme,
288            commands,
289            selected_index: state.ui.palette_cursor.unwrap_or(0),
290        };
291        frame.render_widget(palette_widget, chunks[5]);
292    } else {
293        let cwd = state.cwd.display().to_string();
294        let status_widget = StatusWidget {
295            theme: &rstate.theme,
296            working_dir: &cwd,
297            context_usage: state.session.context_usage.as_ref(),
298            last_usage: state.session.last_token_usage,
299            session_usage: state.session.cumulative_token_usage,
300            model_name: &state.session.model_id,
301            reasoning_level: effective,
302            requested_level,
303        };
304        frame.render_widget(status_widget, chunks[5]);
305    }
306}
307
308/// Merge the committed message log with any in-flight partial
309/// content from `TurnState::Generating`. The chat widget renders
310/// this as a single stream.
311fn build_live_messages(
312    committed: &[crate::models::ChatMessage],
313    turn: &TurnState,
314) -> Vec<crate::models::ChatMessage> {
315    let mut out = committed.to_vec();
316    if let TurnState::Generating {
317        partial_text,
318        partial_reasoning,
319        ..
320    } = turn
321        && (!partial_text.is_empty() || !partial_reasoning.is_empty())
322    {
323        let thinking = if partial_reasoning.is_empty() {
324            None
325        } else {
326            Some(partial_reasoning.clone())
327        };
328        let msg = crate::models::ChatMessage {
329            role: crate::models::MessageRole::Assistant,
330            content: partial_text.clone(),
331            timestamp: chrono::Local::now(),
332            kind: crate::models::ChatMessageKind::Normal,
333            metadata: None,
334            actions: Vec::new(),
335            thinking,
336            images: None,
337            tool_calls: None,
338            tool_call_id: None,
339            tool_name: None,
340            thinking_signature: None,
341        };
342        out.push(msg);
343    }
344    out
345}
346
347/// Future hook: consult `ProviderFactory` for per-model capabilities.
348/// Today returns `None` — reasoning snap indicator is suppressed
349/// until the factory is threaded through `State` (or an equivalent
350/// capability table).
351fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
352    None
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::app::Config;
359    use crate::domain::{State, StatusKind, StatusLine, TurnState};
360    use ratatui::Terminal;
361    use ratatui::backend::TestBackend;
362    use std::path::PathBuf;
363
364    fn mock_state() -> State {
365        State::new(
366            Config::default(),
367            PathBuf::from("/tmp/p"),
368            "ollama/test".to_string(),
369        )
370    }
371
372    fn render_to_string(state: &State) -> String {
373        let backend = TestBackend::new(80, 24);
374        let mut terminal = Terminal::new(backend).expect("terminal");
375        let mut rstate = RenderCache::new();
376        terminal
377            .draw(|f| render(state, &mut rstate, f))
378            .expect("draw");
379        let buf = terminal.backend().buffer();
380        let mut out = String::new();
381        for y in 0..buf.area.height {
382            for x in 0..buf.area.width {
383                out.push_str(buf[(x, y)].symbol());
384            }
385            out.push('\n');
386        }
387        out
388    }
389
390    #[test]
391    fn idle_state_renders_cwd_and_model_footer() {
392        let s = mock_state();
393        let frame = render_to_string(&s);
394        // Bottom status bar shows cwd + model id somewhere.
395        assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
396        assert!(frame.contains("ollama/test"));
397    }
398
399    #[test]
400    fn status_line_appears_during_generating() {
401        let mut s = mock_state();
402        s.turn = crate::domain::transition::start_generating(crate::domain::TurnId(1));
403        let frame = render_to_string(&s);
404        // Status widget only renders when generating — the bottom bar
405        // should have shifted to show the status line content.
406        assert!(
407            frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
408            "expected generation status in frame"
409        );
410    }
411
412    #[test]
413    fn committed_message_appears_in_chat_pane() {
414        let mut s = mock_state();
415        s.session
416            .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
417        let frame = render_to_string(&s);
418        assert!(frame.contains("unique-user-token-xyz"));
419    }
420
421    #[test]
422    fn palette_renders_when_input_starts_with_slash() {
423        let mut s = mock_state();
424        s.ui.input_buffer = "/help".to_string();
425        s.ui.input_cursor = 5;
426        let frame = render_to_string(&s);
427        // At least one registered command should surface in the overlay.
428        assert!(frame.contains("help"));
429    }
430
431    #[test]
432    fn status_line_helper_maps_idle_to_idle() {
433        assert_eq!(
434            GenerationStatus::from_turn(&TurnState::Idle),
435            GenerationStatus::Idle
436        );
437    }
438
439    /// F9: `state.status` is painted as a banner above input. Before
440    /// this, the reducer would set `state.status` for slash-command
441    /// feedback and MCP errors but no widget displayed it.
442    #[test]
443    fn state_status_renders_as_banner() {
444        let mut s = mock_state();
445        s.status = Some(StatusLine {
446            text: "Reasoning: high".to_string(),
447            kind: StatusKind::Info,
448            shown_at: std::time::SystemTime::now(),
449        });
450        let frame = render_to_string(&s);
451        assert!(
452            frame.contains("Reasoning: high"),
453            "state.status must reach the screen"
454        );
455    }
456
457    #[test]
458    fn unused_status_line_struct_silences_warning() {
459        // Guard against dead_code on the imported StatusLine + StatusKind.
460        let _ = StatusLine {
461            text: "x".to_string(),
462            kind: StatusKind::Info,
463            shown_at: std::time::SystemTime::now(),
464        };
465    }
466}