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