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//!
7//! The glyph vocabulary, one meaning each (no emoji, no dingbats -- CI bans
8//! them; geometric shapes and box drawing are below the banned ranges):
9//!
10//! - `>`  the user's prompt
11//! - `●`  the start of a block: an assistant reply, a tool call
12//! - `⎿`  the result or continuation under a block
13//! - `◦`  a live child row (a running agent)
14//! - `√ ■ □ ⊘`  checklist states: done, in progress, pending, blocked
15//! - `◐ ◓ ◑ ◒`  the spinner
16//! - `·`  the only separator inside meta text
17//!
18//! Every meta surface -- footer, session header, spinner meta, agent rows,
19//! checklist meta, run summary, system notices -- uses the theme's single
20//! `text_meta` colour, undimmed.
21
22mod approval;
23mod chat;
24mod conversation_list;
25mod file_picker;
26mod input;
27mod model_picker;
28mod plan_config;
29mod question;
30mod rewind_picker;
31mod session_header;
32mod slash_palette;
33mod status;
34mod status_line;
35mod tasks;
36
37pub use approval::ApprovalModalWidget;
38pub use chat::{ChatState, ChatWidget, ImageClickTarget};
39pub use conversation_list::ConversationListWidget;
40pub use file_picker::FilePickerWidget;
41pub use input::{InputState, InputWidget, rendered_row_count};
42pub use model_picker::{MODEL_PICKER_HEIGHT, ModelPickerWidget};
43pub use plan_config::{PLAN_CONFIG_HEIGHT, PLAN_CONFIG_ROWS, PlanConfigWidget, plan_config_rows};
44pub use question::{QuestionModalWidget, question_modal_height};
45pub use rewind_picker::RewindPickerWidget;
46pub use session_header::{
47    SESSION_HEADER_HEIGHT, abbreviate_home, build_session_header, session_header_visible,
48};
49pub use slash_palette::SlashPaletteWidget;
50pub use status::StatusWidget;
51pub use status_line::{AgentPanelRow, build_status_lines, spinner_glyph};
52pub use tasks::{build_task_lines, tasks_height, tasks_visible};
53
54use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
55
56/// Truncate `s` to `width` display cells, appending `…` when it doesn't fit.
57/// Cell-accurate (CJK/emoji safe) so the result never exceeds `width` — unlike a
58/// `chars().count()` guard + byte-index cut, which under-counts wide glyphs (48
59/// CJK chars = 96 cells slips through) and slices on a byte boundary.
60pub(super) fn truncate_to_cells(s: &str, width: usize) -> String {
61    if UnicodeWidthStr::width(s) <= width {
62        return s.to_string();
63    }
64    if width == 0 {
65        return String::new();
66    }
67    let budget = width - 1; // leave a cell for the ellipsis
68    let mut out = String::new();
69    let mut w = 0usize;
70    for ch in s.chars() {
71        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
72        if w + cw > budget {
73            break;
74        }
75        out.push(ch);
76        w += cw;
77    }
78    out.push('…');
79    out
80}
81
82use ratatui::style::Style;
83use ratatui::text::{Line, Span};
84
85/// Truncate a styled [`Line`] to `width` display cells, appending `…` when it overflows.
86pub(super) fn truncate_line_to_cells(line: Line<'static>, width: usize) -> Line<'static> {
87    let total_w: usize = line
88        .spans
89        .iter()
90        .map(|s| UnicodeWidthStr::width(s.content.as_ref()))
91        .sum();
92    if total_w <= width {
93        return line;
94    }
95    if width == 0 {
96        return Line::from(Vec::new());
97    }
98    let budget = width.saturating_sub(1);
99    let mut out_spans = Vec::new();
100    let mut current_w = 0usize;
101    let mut last_style = Style::default();
102    for span in line.spans {
103        last_style = span.style;
104        let mut buf = String::new();
105        for ch in span.content.chars() {
106            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
107            if current_w + cw > budget {
108                break;
109            }
110            buf.push(ch);
111            current_w += cw;
112        }
113        if !buf.is_empty() {
114            out_spans.push(Span::styled(buf, span.style));
115        }
116        if current_w >= budget {
117            break;
118        }
119    }
120    out_spans.push(Span::styled("…", last_style));
121    Line::from(out_spans)
122}
123
124/// Local-to-render-layer generation phase enum. The compose function
125/// converts from `domain::TurnState` + `domain::GenPhase` into one of
126/// these four states; widgets render off this local view so they
127/// don't need to pattern-match the full domain enum.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum GenerationStatus {
130    Idle,
131    Sending,
132    Thinking,
133    Streaming,
134    RunningTools,
135    Compacting,
136    Cancelling,
137}
138
139impl GenerationStatus {
140    #[must_use]
141    pub fn display_text(&self) -> &str {
142        match self {
143            Self::Idle => "Idle",
144            Self::Sending => "Sending",
145            Self::Thinking => "Thinking",
146            Self::Streaming => "Streaming",
147            Self::RunningTools => "Running tools",
148            Self::Compacting => "Compacting",
149            Self::Cancelling => "Cancelling",
150        }
151    }
152
153    /// Convert from the reducer's typed turn state. `TurnState::Idle`
154    /// maps to `Idle`; `Generating.phase` maps 1:1; every other
155    /// active variant maps to `Streaming` (the status-line widget
156    /// doesn't distinguish beyond the basic upstream/downstream
157    /// arrow).
158    #[must_use]
159    pub fn from_turn(turn: &mermaid_domain::TurnState) -> Self {
160        use mermaid_domain::{GenPhase, TurnState};
161        match turn {
162            TurnState::Idle => Self::Idle,
163            TurnState::Generating { phase, .. } => match phase {
164                GenPhase::Sending => Self::Sending,
165                GenPhase::Thinking => Self::Thinking,
166                GenPhase::Streaming => Self::Streaming,
167            },
168            TurnState::ExecutingTools { .. } => Self::RunningTools,
169            TurnState::Compacting { .. } => Self::Compacting,
170            TurnState::Cancelling { .. } => Self::Cancelling,
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::truncate_to_cells;
178    use unicode_width::UnicodeWidthStr;
179
180    #[test]
181    fn fits_within_width_returns_unchanged() {
182        assert_eq!(truncate_to_cells("hello", 10), "hello");
183        assert_eq!(truncate_to_cells("hello", 5), "hello");
184    }
185
186    #[test]
187    fn ascii_truncates_with_ellipsis_within_budget() {
188        let out = truncate_to_cells("hello world", 5);
189        assert_eq!(out, "hell…");
190        assert!(UnicodeWidthStr::width(out.as_str()) <= 5);
191    }
192
193    #[test]
194    fn wide_glyphs_never_exceed_budget() {
195        // Each CJK char is 2 cells. The old `chars().count()` guard let a
196        // 48-char (96-cell) title slip through a "48" budget and overflow its
197        // row; cell-accurate truncation caps the display width.
198        let cjk = "你好世界你好世界"; // 8 chars = 16 cells
199        let out = truncate_to_cells(cjk, 6);
200        assert!(
201            UnicodeWidthStr::width(out.as_str()) <= 6,
202            "width exceeded budget: {out:?}"
203        );
204        assert!(out.ends_with('…'));
205    }
206
207    #[test]
208    fn zero_width_is_empty() {
209        assert_eq!(truncate_to_cells("anything", 0), "");
210    }
211}