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