Skip to main content

mermaid_cli/render/
mod.rs

1//! Pure view: `fn render(&State, &mut RenderCache, &mut Frame)`.
2//!
3//! Three contracts:
4//!   1. Never mutates `State`. The view is fully derived.
5//!   2. Never performs I/O. All state — model lists, MCP status,
6//!      file contents — is whatever the reducer put in `State`.
7//!   3. Never holds a `&mut App` / `&mut anything` other than the
8//!      `Frame` ratatui owns and the render-layer `RenderCache`
9//!      (which is memoization + scroll-position bookkeeping, not
10//!      reducer state).
11//!
12//! Signature: `fn render(&State, &mut RenderCache, &mut Frame)`.
13//! The `&mut RenderCache` is memoization only (markdown parse
14//! cache, scroll position, theme choice) — it never affects
15//! reducer outcomes or persisted state.
16
17pub mod diff;
18pub mod layout;
19pub mod markdown;
20pub mod theme;
21pub mod widgets;
22
23use ratatui::{Frame, layout::Margin};
24use rustc_hash::FxHashMap;
25use unicode_width::UnicodeWidthChar;
26
27use crate::domain::{State, TurnState};
28use crate::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
29
30use widgets::{
31    AttachmentWidget, ChatState, ChatWidget, GenerationStatus, InputState, InputWidget,
32    SlashPaletteWidget, StatusWidget, build_status_lines,
33};
34
35/// Transient render-layer state that lives across frames but isn't
36/// reducer state. Owned by `app::run_interactive`; passed as `&mut`
37/// to `render()` per frame.
38///
39/// Contents are pure memoization + UI affordances (scroll position,
40/// markdown cache, theme choice). Nothing here affects what the
41/// reducer sees or what ends up on disk — the cache can be dropped
42/// and rebuilt from `&State` at any time.
43pub struct RenderCache {
44    pub chat: ChatState,
45    pub markdown_cache: FxHashMap<u64, Vec<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    // Build the status-line rows up front (wrapped to the terminal width) so
105    // the layout reserves exactly the height they need — a long
106    // `Running tools: <cmd>` plus the trailing `(esc to interrupt …)` fold onto
107    // continuation rows instead of bleeding off the right edge.
108    let status_lines = if state.is_busy() {
109        let elapsed_secs = match &state.turn {
110            TurnState::Generating { started, .. } | TurnState::Compacting { started, .. } => {
111                started.elapsed().map(|d| d.as_secs()).unwrap_or(0)
112            },
113            TurnState::Cancelling { since, .. } => {
114                since.elapsed().map(|d| d.as_secs()).unwrap_or(0)
115            },
116            TurnState::ExecutingTools { started, .. } => {
117                started.elapsed().map(|d| d.as_secs()).unwrap_or(0)
118            },
119            TurnState::Idle => 0,
120        };
121        // While tools run, name the in-flight one(s) so the status line isn't an
122        // opaque "Running tools…". In-flight = the call slots without an outcome.
123        let active_tool = match &state.turn {
124            TurnState::ExecutingTools {
125                calls, outcomes, ..
126            } => {
127                let pending = outcomes.iter().filter(|o| o.is_none()).count();
128                calls
129                    .iter()
130                    .zip(outcomes)
131                    .find(|(_, o)| o.is_none())
132                    .map(|(call, _)| {
133                        let (action, target) = crate::domain::display_info_for(call);
134                        let label = if target.is_empty() {
135                            action
136                        } else {
137                            format!("{action} {target}")
138                        };
139                        if pending > 1 {
140                            format!("{label} (+{} more)", pending - 1)
141                        } else {
142                            label
143                        }
144                    })
145            },
146            _ => None,
147        };
148        let (tokens_display, tokens_estimated) = match &state.turn {
149            TurnState::Generating {
150                tokens,
151                partial_text,
152                ..
153            } if *tokens == 0 && !partial_text.is_empty() => (partial_text.len() / 4, true),
154            TurnState::Generating { tokens, .. } => (*tokens, false),
155            _ => (0, false),
156        };
157        build_status_lines(
158            GenerationStatus::from_turn(&state.turn),
159            elapsed_secs,
160            tokens_display,
161            tokens_estimated,
162            active_tool.as_deref(),
163            &state.ui.queued_messages,
164            &rstate.theme,
165            // Match the 1-cell horizontal pad the status zone is rendered with.
166            frame.area().width.saturating_sub(2),
167        )
168    } else {
169        Vec::new()
170    };
171
172    let attachment_height = if state.ui.attachments.is_empty() {
173        0
174    } else {
175        1
176    };
177
178    // F9: one-row banner for `state.status`. Previously the reducer
179    // set state.status for slash commands, MCP errors, and model-pull
180    // progress but no widget painted it. Height is 1 when a status is
181    // present, 0 otherwise.
182    let status_banner_height: u16 = if state.status.is_some() { 1 } else { 0 };
183
184    // Reserve the status zone's height to match its row count, but never so much
185    // that the input box or bottom bar get evicted on a short terminal: keep room
186    // for the chat floor (Min 10), the input box, the bottom bar (≥2), and the
187    // banner/attachment rows. (The trailing Length zones would otherwise starve
188    // before the Min(10) chat zone does.)
189    let status_reserve = 10 + input_height + 2 + status_banner_height + attachment_height;
190    let status_line_height = (status_lines.len() as u16)
191        .min(6)
192        .min(frame.area().height.saturating_sub(status_reserve));
193
194    // Bottom region: one of three widgets based on UI mode.
195    //   - ConversationList picker: 12-line pane.
196    //   - Slash palette (input starts with `/`): 3–10 lines based on
197    //     filter match count.
198    //   - Otherwise: 2-line status bar.
199    // Precedence: approval modal > confirm modal > ConversationList picker >
200    // slash palette > status bar. Approvals/confirms are interrupts that
201    // overlay regardless of input mode.
202    let approval_item = state.pending_approval.front();
203    let confirm_open = approval_item.is_none() && state.confirm.is_some();
204    let conv_list_open = approval_item.is_none()
205        && !confirm_open
206        && matches!(
207            state.ui.mode,
208            crate::domain::UiMode::ConversationList { .. }
209        );
210    let palette_open = approval_item.is_none()
211        && !confirm_open
212        && !conv_list_open
213        && state.ui.input_buffer.starts_with('/');
214    let bottom_height = if let Some(item) = approval_item {
215        // border(2) + body lines + blank(1) + 3 option lines
216        let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
217        2 + body_lines + 1 + 3
218    } else if confirm_open {
219        6
220    } else if conv_list_open {
221        12
222    } else if palette_open {
223        let typed = state
224            .ui
225            .input_buffer
226            .trim_start_matches('/')
227            .split_whitespace()
228            .next()
229            .unwrap_or("");
230        let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
231            .len()
232            .clamp(1, 8);
233        (row_count as u16) + 2
234    } else {
235        2
236    };
237
238    // 6-zone vertical layout: chat / status line / attachments /
239    // status banner / input / bottom. The banner sits directly above
240    // input so the eye finds "what just happened" right next to
241    // "what's next to type".
242    use ratatui::layout::{Constraint, Direction, Layout};
243    let chunks = Layout::default()
244        .direction(Direction::Vertical)
245        .constraints([
246            Constraint::Min(10),
247            Constraint::Length(status_line_height),
248            Constraint::Length(attachment_height),
249            Constraint::Length(status_banner_height),
250            Constraint::Length(input_height),
251            Constraint::Length(bottom_height),
252        ])
253        .split(frame.area());
254
255    // Chat area with 1-cell horizontal padding.
256    let chat_area = chunks[0].inner(Margin {
257        horizontal: 1,
258        vertical: 0,
259    });
260    let committed = state.session.messages().to_vec();
261    let live_messages = build_live_messages(&committed, &state.turn);
262    let chat_widget = ChatWidget {
263        messages: &live_messages,
264        theme: &rstate.theme,
265        markdown_cache: &mut rstate.markdown_cache,
266        show_reasoning: state.ui.show_reasoning,
267    };
268    frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
269
270    // Status line for every active turn (built above, already fit to width).
271    // Indented 1 cell to align with the chat column's 1-cell pad.
272    if !status_lines.is_empty() {
273        let status_area = chunks[1].inner(Margin {
274            horizontal: 1,
275            vertical: 0,
276        });
277        frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
278    }
279
280    // Attachment bar.
281    if !state.ui.attachments.is_empty() {
282        let attachment_widget = AttachmentWidget {
283            attachments: &state.ui.attachments,
284            theme: &rstate.theme,
285            focused: state.ui.attachment_focused,
286            selected: state.ui.attachment_selected,
287        };
288        frame.render_widget(attachment_widget, chunks[2]);
289    }
290
291    // F9 banner for state.status — above input, below attachments.
292    if let Some(ref status) = state.status {
293        let banner = widgets::StatusBannerWidget {
294            theme: &rstate.theme,
295            status,
296        };
297        frame.render_widget(banner, chunks[3]);
298    }
299
300    // Input box.
301    let input_widget = InputWidget {
302        input: state.ui.input_buffer.as_str(),
303        showing_command_hints: state.ui.input_buffer.starts_with('/'),
304        theme: &rstate.theme,
305        reasoning_active: state.session.reasoning != ReasoningLevel::None,
306    };
307    let mut input_widget_state = InputState {
308        cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
309    };
310    frame.render_stateful_widget(input_widget, chunks[4], &mut input_widget_state);
311
312    // Cursor visible unless focus is on attachments.
313    if !state.ui.attachment_focused {
314        let input_area = chunks[4];
315        let content_width = input_area.width.saturating_sub(2) as usize;
316        let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
317            &state.ui.input_buffer,
318            state.ui.input_cursor.min(state.ui.input_buffer.len()),
319            content_width,
320        );
321        frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
322    }
323
324    // Effective reasoning level. Per-model supported_reasoning cap
325    // isn't threaded through `State` yet; defaults to no snap
326    // indicator until `ProviderFactory::capabilities` reaches here.
327    let requested = state.session.reasoning;
328    let effective = match supported_reasoning_for(state) {
329        Some(ReasoningCapability::Levels(supp)) => {
330            nearest_effort(requested, &supp).unwrap_or(requested)
331        },
332        _ => requested,
333    };
334    let requested_level = if effective == requested {
335        None
336    } else {
337        Some(requested)
338    };
339
340    // Bottom: conversation-list picker, slash-palette overlay, or
341    // persistent status bar — whichever the UI mode dictates.
342    if let Some(item) = state.pending_approval.front() {
343        use widgets::ApprovalModalWidget;
344        let widget = ApprovalModalWidget {
345            theme: &rstate.theme,
346            title: format!("Approval required — {}  [{}]", item.tool, item.risk),
347            body: item.prompt.as_str(),
348            options: vec![
349                "1. Yes".to_string(),
350                format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
351                "3. No  (Esc)".to_string(),
352            ],
353            selected_index: Some(item.selected_option),
354            accent: rstate.theme.colors.warning.to_color(),
355        };
356        frame.render_widget(widget, chunks[5]);
357    } else if let Some(confirm) = &state.confirm {
358        use widgets::ApprovalModalWidget;
359        let widget = ApprovalModalWidget {
360            theme: &rstate.theme,
361            title: "Confirm".to_string(),
362            body: confirm.prompt.as_str(),
363            options: vec!["y. Yes".to_string(), "n. No  (Esc)".to_string()],
364            selected_index: None,
365            accent: rstate.theme.colors.warning.to_color(),
366        };
367        frame.render_widget(widget, chunks[5]);
368    } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
369        use widgets::ConversationListWidget;
370        let widget = ConversationListWidget {
371            theme: &rstate.theme,
372            candidates,
373            cursor: *cursor,
374        };
375        frame.render_widget(widget, chunks[5]);
376    } else if palette_open {
377        let typed = state
378            .ui
379            .input_buffer
380            .trim_start_matches('/')
381            .split_whitespace()
382            .next()
383            .unwrap_or("");
384        let commands = crate::domain::slash_commands::filter_by_prefix(typed);
385        let palette_widget = SlashPaletteWidget {
386            theme: &rstate.theme,
387            commands,
388            selected_index: state.ui.palette_cursor.unwrap_or(0),
389        };
390        frame.render_widget(palette_widget, chunks[5]);
391    } else {
392        let cwd = state.cwd.display().to_string();
393        let status_widget = StatusWidget {
394            theme: &rstate.theme,
395            working_dir: &cwd,
396            context_usage: state.session.context_usage.as_ref(),
397            last_usage: state.session.last_token_usage,
398            session_usage: state.session.cumulative_token_usage,
399            model_name: &state.session.model_id,
400            reasoning_level: effective,
401            requested_level,
402            safety_mode: state.session.safety_mode,
403        };
404        frame.render_widget(status_widget, chunks[5]);
405    }
406}
407
408/// Merge the committed message log with any in-flight partial
409/// content from `TurnState::Generating`. The chat widget renders
410/// this as a single stream.
411fn build_live_messages(
412    committed: &[crate::models::ChatMessage],
413    turn: &TurnState,
414) -> Vec<crate::models::ChatMessage> {
415    let mut out = committed.to_vec();
416    if let TurnState::Generating {
417        partial_text,
418        partial_reasoning,
419        ..
420    } = turn
421        && (!partial_text.is_empty() || !partial_reasoning.is_empty())
422    {
423        let thinking = if partial_reasoning.is_empty() {
424            None
425        } else {
426            Some(partial_reasoning.clone())
427        };
428        let msg = crate::models::ChatMessage {
429            role: crate::models::MessageRole::Assistant,
430            content: partial_text.clone(),
431            timestamp: chrono::Local::now(),
432            kind: crate::models::ChatMessageKind::Normal,
433            metadata: None,
434            actions: Vec::new(),
435            thinking,
436            images: None,
437            tool_calls: None,
438            tool_call_id: None,
439            tool_name: None,
440            thinking_signature: None,
441        };
442        out.push(msg);
443    }
444    out
445}
446
447/// Future hook: consult `ProviderFactory` for per-model capabilities.
448/// Today returns `None` — reasoning snap indicator is suppressed
449/// until the factory is threaded through `State` (or an equivalent
450/// capability table).
451fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
452    None
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::app::Config;
459    use crate::domain::{State, StatusKind, StatusLine, TurnState};
460    use ratatui::Terminal;
461    use ratatui::backend::TestBackend;
462    use std::path::PathBuf;
463
464    fn mock_state() -> State {
465        State::new(
466            Config::default(),
467            PathBuf::from("/tmp/p"),
468            "ollama/test".to_string(),
469        )
470    }
471
472    fn render_to_string(state: &State) -> String {
473        let backend = TestBackend::new(80, 24);
474        let mut terminal = Terminal::new(backend).expect("terminal");
475        let mut rstate = RenderCache::new();
476        terminal
477            .draw(|f| render(state, &mut rstate, f))
478            .expect("draw");
479        let buf = terminal.backend().buffer();
480        let mut out = String::new();
481        for y in 0..buf.area.height {
482            for x in 0..buf.area.width {
483                out.push_str(buf[(x, y)].symbol());
484            }
485            out.push('\n');
486        }
487        out
488    }
489
490    #[test]
491    fn idle_state_renders_cwd_and_model_footer() {
492        let s = mock_state();
493        let frame = render_to_string(&s);
494        // Bottom status bar shows cwd + model id somewhere.
495        assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
496        assert!(frame.contains("ollama/test"));
497    }
498
499    #[test]
500    fn status_line_appears_during_generating() {
501        let mut s = mock_state();
502        s.turn = crate::domain::transition::start_generating(crate::domain::TurnId(1));
503        let frame = render_to_string(&s);
504        assert!(
505            frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
506            "expected generation status in frame"
507        );
508    }
509
510    #[test]
511    fn status_line_names_the_in_flight_tool() {
512        use crate::domain::PendingToolCall;
513        use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
514        let mut s = mock_state();
515        let call = PendingToolCall {
516            call_id: crate::domain::ToolCallId(1),
517            source: ModelToolCall {
518                id: Some("c1".to_string()),
519                function: FunctionCall {
520                    name: "execute_command".to_string(),
521                    arguments: serde_json::json!({"command": "npm run dev"}),
522                },
523            },
524        };
525        s.turn = TurnState::ExecutingTools {
526            id: crate::domain::TurnId(1),
527            started: std::time::SystemTime::now(),
528            calls: vec![call],
529            outcomes: vec![None],
530        };
531        let frame = render_to_string(&s);
532        assert!(frame.contains("Running tools"), "got: {frame}");
533        assert!(
534            frame.contains("npm run dev"),
535            "status line must name the in-flight command; got: {frame}"
536        );
537    }
538
539    #[test]
540    fn status_line_appears_during_tool_execution_and_shows_queue() {
541        let mut s = mock_state();
542        s.turn = TurnState::ExecutingTools {
543            id: crate::domain::TurnId(1),
544            started: std::time::SystemTime::now(),
545            calls: Vec::new(),
546            outcomes: Vec::new(),
547        };
548        s.ui.queued_messages
549            .push_back("please steer this".to_string());
550        let frame = render_to_string(&s);
551        assert!(frame.contains("Running tools"), "expected tool status");
552        assert!(
553            frame.contains("please steer this"),
554            "queued busy input must be visible"
555        );
556    }
557
558    #[test]
559    fn reasoning_blocks_are_collapsed_by_default() {
560        let mut s = mock_state();
561        let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
562        first_msg.thinking = Some("first private chain of thought".to_string());
563        s.session.append(first_msg);
564        let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
565        second_msg.thinking = Some("second private chain of thought".to_string());
566        s.session.append(second_msg);
567        let frame = render_to_string(&s);
568        assert_eq!(frame.matches("Reasoning hidden").count(), 1);
569        assert!(frame.contains("first visible answer"));
570        assert!(frame.contains("second visible answer"));
571        assert!(!frame.contains("first private chain of thought"));
572        assert!(!frame.contains("second private chain of thought"));
573    }
574
575    /// Regression: a "thought, then immediately called a tool" turn (hidden
576    /// reasoning + empty text + actions) must still put one blank line
577    /// between the "Reasoning hidden" placeholder and the first action —
578    /// the same gap every other block pair gets. Previously the placeholder
579    /// rendered flush against the first "● Bash(…)" line.
580    #[test]
581    fn reasoning_placeholder_is_gapped_from_following_action() {
582        let mut s = mock_state();
583        let mut msg = crate::models::ChatMessage::assistant("");
584        msg.thinking = Some("private chain of thought".to_string());
585        msg.actions.push(crate::domain::ActionDisplay {
586            action_type: "Bash".to_string(),
587            target: "dir".to_string(),
588            result: crate::domain::ActionResult::Success {
589                output: "ok".to_string(),
590                images: None,
591            },
592            details: crate::domain::ActionDetails::Simple,
593            duration_seconds: Some(0.015),
594            metadata: None,
595        });
596        s.session.append(msg);
597        let frame = render_to_string(&s);
598        let lines: Vec<&str> = frame.lines().collect();
599        let idx = lines
600            .iter()
601            .position(|l| l.contains("Reasoning hidden"))
602            .expect("placeholder must render");
603        assert!(
604            lines[idx + 1].trim().is_empty(),
605            "a blank line must separate the placeholder from the action; got {:?}",
606            &lines[idx..=(idx + 2).min(lines.len() - 1)]
607        );
608        assert!(
609            lines[idx..].iter().any(|l| l.contains("Bash")),
610            "the action must still render after the placeholder"
611        );
612    }
613
614    #[test]
615    fn scrollbar_shows_when_chat_overflows() {
616        let mut s = mock_state();
617        for i in 0..60 {
618            s.session
619                .append(crate::models::ChatMessage::assistant(format!("msg {i}")));
620        }
621        let frame = render_to_string(&s);
622        // ratatui's Scrollbar renders a "█" thumb in the reserved gutter once
623        // the transcript is taller than the viewport.
624        assert!(
625            frame.contains('█'),
626            "a scrollbar thumb should render when the transcript overflows the viewport"
627        );
628    }
629
630    #[test]
631    fn committed_message_appears_in_chat_pane() {
632        let mut s = mock_state();
633        s.session
634            .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
635        let frame = render_to_string(&s);
636        assert!(frame.contains("unique-user-token-xyz"));
637    }
638
639    #[test]
640    fn palette_renders_when_input_starts_with_slash() {
641        let mut s = mock_state();
642        s.ui.input_buffer = "/help".to_string();
643        s.ui.input_cursor = 5;
644        let frame = render_to_string(&s);
645        // At least one registered command should surface in the overlay.
646        assert!(frame.contains("help"));
647    }
648
649    #[test]
650    fn status_line_helper_maps_idle_to_idle() {
651        assert_eq!(
652            GenerationStatus::from_turn(&TurnState::Idle),
653            GenerationStatus::Idle
654        );
655    }
656
657    /// F9: `state.status` is painted as a banner above input. Before
658    /// this, the reducer would set `state.status` for slash-command
659    /// feedback and MCP errors but no widget displayed it.
660    #[test]
661    fn state_status_renders_as_banner() {
662        let mut s = mock_state();
663        s.status = Some(StatusLine {
664            text: "Reasoning: high".to_string(),
665            kind: StatusKind::Info,
666            shown_at: std::time::SystemTime::now(),
667        });
668        let frame = render_to_string(&s);
669        assert!(
670            frame.contains("Reasoning: high"),
671            "state.status must reach the screen"
672        );
673    }
674
675    #[test]
676    fn unused_status_line_struct_silences_warning() {
677        // Guard against dead_code on the imported StatusLine + StatusKind.
678        let _ = StatusLine {
679            text: "x".to_string(),
680            kind: StatusKind::Info,
681            shown_at: std::time::SystemTime::now(),
682        };
683    }
684}