Skip to main content

mermaid_cli/render/widgets/
mod.rs

1//! Stateless TUI widgets.
2//!
3//! Each widget takes explicit props; the compose function in
4//! `render::mod` pulls those props from `State` per frame. No widget
5//! holds a reference to any god-object.
6
7mod approval;
8mod attachment;
9mod chat;
10mod conversation_list;
11mod input;
12mod slash_palette;
13mod status;
14mod status_line;
15
16pub use approval::ApprovalModalWidget;
17pub use attachment::AttachmentWidget;
18pub use chat::{ChatState, ChatWidget, ImageClickTarget};
19pub use conversation_list::ConversationListWidget;
20pub use input::{InputState, InputWidget};
21pub use slash_palette::SlashPaletteWidget;
22pub use status::StatusWidget;
23pub use status_line::build_status_lines;
24
25use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
26
27/// Truncate `s` to `width` display cells, appending `…` when it doesn't fit.
28/// Cell-accurate (CJK/emoji safe) so the result never exceeds `width` — unlike a
29/// `chars().count()` guard + byte-index cut, which under-counts wide glyphs (48
30/// CJK chars = 96 cells slips through) and slices on a byte boundary.
31pub(super) fn truncate_to_cells(s: &str, width: usize) -> String {
32    if UnicodeWidthStr::width(s) <= width {
33        return s.to_string();
34    }
35    if width == 0 {
36        return String::new();
37    }
38    let budget = width - 1; // leave a cell for the ellipsis
39    let mut out = String::new();
40    let mut w = 0usize;
41    for ch in s.chars() {
42        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
43        if w + cw > budget {
44            break;
45        }
46        out.push(ch);
47        w += cw;
48    }
49    out.push('…');
50    out
51}
52
53/// Local-to-render-layer generation phase enum. The compose function
54/// converts from `domain::TurnState` + `domain::GenPhase` into one of
55/// these four states; widgets render off this local view so they
56/// don't need to pattern-match the full domain enum.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum GenerationStatus {
59    Idle,
60    Sending,
61    Thinking,
62    Streaming,
63    RunningTools,
64    Compacting,
65    Cancelling,
66}
67
68impl GenerationStatus {
69    pub fn display_text(&self) -> &str {
70        match self {
71            GenerationStatus::Idle => "Idle",
72            GenerationStatus::Sending => "Sending",
73            GenerationStatus::Thinking => "Thinking",
74            GenerationStatus::Streaming => "Streaming",
75            GenerationStatus::RunningTools => "Running tools",
76            GenerationStatus::Compacting => "Compacting",
77            GenerationStatus::Cancelling => "Cancelling",
78        }
79    }
80
81    /// Convert from the reducer's typed turn state. `TurnState::Idle`
82    /// maps to `Idle`; `Generating.phase` maps 1:1; every other
83    /// active variant maps to `Streaming` (the status-line widget
84    /// doesn't distinguish beyond the basic upstream/downstream
85    /// arrow).
86    pub fn from_turn(turn: &crate::domain::TurnState) -> Self {
87        use crate::domain::{GenPhase, TurnState};
88        match turn {
89            TurnState::Idle => GenerationStatus::Idle,
90            TurnState::Generating { phase, .. } => match phase {
91                GenPhase::Sending => GenerationStatus::Sending,
92                GenPhase::Thinking => GenerationStatus::Thinking,
93                GenPhase::Streaming => GenerationStatus::Streaming,
94            },
95            TurnState::ExecutingTools { .. } => GenerationStatus::RunningTools,
96            TurnState::Compacting { .. } => GenerationStatus::Compacting,
97            TurnState::Cancelling { .. } => GenerationStatus::Cancelling,
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::truncate_to_cells;
105    use unicode_width::UnicodeWidthStr;
106
107    #[test]
108    fn fits_within_width_returns_unchanged() {
109        assert_eq!(truncate_to_cells("hello", 10), "hello");
110        assert_eq!(truncate_to_cells("hello", 5), "hello");
111    }
112
113    #[test]
114    fn ascii_truncates_with_ellipsis_within_budget() {
115        let out = truncate_to_cells("hello world", 5);
116        assert_eq!(out, "hell…");
117        assert!(UnicodeWidthStr::width(out.as_str()) <= 5);
118    }
119
120    #[test]
121    fn wide_glyphs_never_exceed_budget() {
122        // Each CJK char is 2 cells. The old `chars().count()` guard let a
123        // 48-char (96-cell) title slip through a "48" budget and overflow its
124        // row; cell-accurate truncation caps the display width.
125        let cjk = "你好世界你好世界"; // 8 chars = 16 cells
126        let out = truncate_to_cells(cjk, 6);
127        assert!(
128            UnicodeWidthStr::width(out.as_str()) <= 6,
129            "width exceeded budget: {out:?}"
130        );
131        assert!(out.ends_with('…'));
132    }
133
134    #[test]
135    fn zero_width_is_empty() {
136        assert_eq!(truncate_to_cells("anything", 0), "");
137    }
138}