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