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    pub fn display_text(&self) -> &str {
80        match self {
81            GenerationStatus::Idle => "Idle",
82            GenerationStatus::Sending => "Sending",
83            GenerationStatus::Thinking => "Thinking",
84            GenerationStatus::Streaming => "Streaming",
85            GenerationStatus::RunningTools => "Running tools",
86            GenerationStatus::Compacting => "Compacting",
87            GenerationStatus::Cancelling => "Cancelling",
88        }
89    }
90
91    /// Convert from the reducer's typed turn state. `TurnState::Idle`
92    /// maps to `Idle`; `Generating.phase` maps 1:1; every other
93    /// active variant maps to `Streaming` (the status-line widget
94    /// doesn't distinguish beyond the basic upstream/downstream
95    /// arrow).
96    pub fn from_turn(turn: &crate::domain::TurnState) -> Self {
97        use crate::domain::{GenPhase, TurnState};
98        match turn {
99            TurnState::Idle => GenerationStatus::Idle,
100            TurnState::Generating { phase, .. } => match phase {
101                GenPhase::Sending => GenerationStatus::Sending,
102                GenPhase::Thinking => GenerationStatus::Thinking,
103                GenPhase::Streaming => GenerationStatus::Streaming,
104            },
105            TurnState::ExecutingTools { .. } => GenerationStatus::RunningTools,
106            TurnState::Compacting { .. } => GenerationStatus::Compacting,
107            TurnState::Cancelling { .. } => GenerationStatus::Cancelling,
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::truncate_to_cells;
115    use unicode_width::UnicodeWidthStr;
116
117    #[test]
118    fn fits_within_width_returns_unchanged() {
119        assert_eq!(truncate_to_cells("hello", 10), "hello");
120        assert_eq!(truncate_to_cells("hello", 5), "hello");
121    }
122
123    #[test]
124    fn ascii_truncates_with_ellipsis_within_budget() {
125        let out = truncate_to_cells("hello world", 5);
126        assert_eq!(out, "hell…");
127        assert!(UnicodeWidthStr::width(out.as_str()) <= 5);
128    }
129
130    #[test]
131    fn wide_glyphs_never_exceed_budget() {
132        // Each CJK char is 2 cells. The old `chars().count()` guard let a
133        // 48-char (96-cell) title slip through a "48" budget and overflow its
134        // row; cell-accurate truncation caps the display width.
135        let cjk = "你好世界你好世界"; // 8 chars = 16 cells
136        let out = truncate_to_cells(cjk, 6);
137        assert!(
138            UnicodeWidthStr::width(out.as_str()) <= 6,
139            "width exceeded budget: {out:?}"
140        );
141        assert!(out.ends_with('…'));
142    }
143
144    #[test]
145    fn zero_width_is_empty() {
146        assert_eq!(truncate_to_cells("anything", 0), "");
147    }
148}