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