mermaid_cli/render/widgets/
mod.rs1mod 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
56pub(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; 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
85pub(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#[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 #[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 let cjk = "你好世界你好世界"; 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}