supercode_frontend_tui/composer/
render.rs1use ratatui::buffer::Buffer;
2use ratatui::layout::Rect;
3use ratatui::style::Modifier;
4use ratatui::style::Style;
5use ratatui::text::Line;
6use ratatui::text::Span;
7use ratatui::widgets::Paragraph;
8use ratatui::widgets::Widget;
9use unicode_segmentation::UnicodeSegmentation;
10use unicode_width::UnicodeWidthStr;
11
12use crate::foundation::wrapping::{adaptive_wrap_lines, RtOptions};
13use crate::human::{bounded_terminal_metadata, bounded_terminal_text, sanitize_terminal_text};
14use crate::terminal::palette::ColorCapabilities;
15
16use super::ComposerModel;
17use super::ComposerOverlayKind;
18use super::ComposerTurnState;
19
20pub struct ComposerRenderer<'a> {
22 model: &'a ComposerModel,
23 colors: ColorCapabilities,
24}
25
26impl<'a> ComposerRenderer<'a> {
27 pub fn new(model: &'a ComposerModel, colors: ColorCapabilities) -> Self {
28 Self { model, colors }
29 }
30
31 pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
32 let width = usize::from(width.max(1));
33 let mut lines = Vec::new();
34 if let Some(overlay) = self.model.overlay() {
35 lines.extend(self.overlay_lines(overlay, width));
36 lines.push(Line::default());
37 }
38 lines.push(self.status_line());
39 lines.extend(self.input_lines(width));
40 lines.extend(self.command_lines());
41 lines.push(self.footer_line(width));
42 lines
43 }
44
45 pub fn desired_height(&self, width: u16) -> u16 {
46 self.lines(width).len().min(usize::from(u16::MAX)) as u16
47 }
48
49 pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
50 Paragraph::new(self.lines(area.width)).render(area, buffer);
51 }
52
53 pub fn cursor_position(&self, width: u16) -> (usize, usize) {
56 let body_width = usize::from(width.saturating_sub(2).max(1));
57 input_layout(self.model.input(), self.model.cursor(), body_width).cursor
58 }
59
60 fn status_line(&self) -> Line<'static> {
61 let (dot, label, color) = match self.model.turn_state() {
62 ComposerTurnState::Idle => ("●", "Ready", (80, 200, 120)),
63 ComposerTurnState::Working { .. } => ("●", "Working", (225, 180, 80)),
64 };
65 let mut spans = vec![
66 Span::styled(dot, Style::default().fg(self.colors.best_color(color))),
67 Span::raw(" "),
68 Span::styled(
69 label,
70 Style::default()
71 .fg(self.colors.best_color(color))
72 .add_modifier(Modifier::BOLD),
73 ),
74 ];
75 if !self.model.queued_steering().is_empty() {
76 spans.push(Span::styled(
77 format!(" · {} queued", self.model.queued_steering().len()),
78 Style::default().fg(self.colors.best_color((145, 150, 160))),
79 ));
80 }
81 if let Some(failure) = self.model.last_failure() {
82 spans.push(Span::styled(
83 format!(" · {}", bounded_terminal_metadata(failure, 128)),
84 Style::default().fg(self.colors.best_color((235, 100, 100))),
85 ));
86 }
87 Line::from(spans)
88 }
89
90 fn input_lines(&self, width: usize) -> Vec<Line<'static>> {
91 let body_width = width.saturating_sub(2).max(1);
92 let layout = input_layout(self.model.input(), self.model.cursor(), body_width);
93 let raw = if layout.text_is_empty {
94 vec![Line::from(Span::styled(
95 "Ask anything…",
96 Style::default().fg(self.colors.best_color((120, 125, 135))),
97 ))]
98 } else {
99 layout
100 .lines
101 .into_iter()
102 .map(|line| Line::from(line.text))
103 .collect()
104 };
105 raw.into_iter()
106 .enumerate()
107 .map(|(index, line)| {
108 let mut spans = Vec::with_capacity(line.spans.len() + 1);
109 spans.push(Span::styled(
110 if index == 0 { "› " } else { " " },
111 Style::default()
112 .fg(self.colors.best_color((120, 190, 255)))
113 .add_modifier(Modifier::BOLD),
114 ));
115 spans.extend(line.spans);
116 Line::from(spans)
117 })
118 .collect()
119 }
120
121 fn command_lines(&self) -> Vec<Line<'static>> {
122 let Some(prefix) = self
123 .model
124 .input()
125 .strip_prefix('/')
126 .filter(|input| !input.contains(char::is_whitespace))
127 else {
128 return Vec::new();
129 };
130 self.model
131 .capabilities()
132 .operations()
133 .iter()
134 .filter_map(|operation| operation.command.as_ref())
135 .filter(|command| command.name.starts_with(prefix))
136 .take(6)
137 .map(|command| {
138 let mut spans = vec![Span::styled(
139 format!(" /{}", bounded_terminal_metadata(&command.name, 128)),
140 Style::default()
141 .fg(self.colors.best_color((175, 140, 245)))
142 .add_modifier(Modifier::BOLD),
143 )];
144 if let Some(description) = &command.description {
145 spans.push(Span::styled(
146 format!(" {}", bounded_terminal_metadata(description, 256)),
147 Style::default().fg(self.colors.best_color((145, 150, 160))),
148 ));
149 }
150 if let Some(argument_hint) = &command.argument_hint {
151 spans.push(Span::styled(
152 format!(" {}", bounded_terminal_metadata(argument_hint, 128)),
153 Style::default().fg(self.colors.best_color((120, 125, 135))),
154 ));
155 }
156 Line::from(spans)
157 })
158 .collect()
159 }
160
161 fn footer_line(&self, width: usize) -> Line<'static> {
162 let capabilities = self.model.capabilities();
163 let mut hints = vec!["Enter send", "Shift+Enter newline"];
164 if capabilities.can_interrupt {
165 hints.push("Ctrl+C interrupt");
166 }
167 let mut text = hints.join(" · ");
168 if text.chars().count() > width {
169 text = text.chars().take(width.saturating_sub(1)).collect();
170 text.push('…');
171 }
172 Line::from(Span::styled(
173 text,
174 Style::default().fg(self.colors.best_color((120, 125, 135))),
175 ))
176 }
177
178 fn overlay_lines(&self, overlay: &super::ComposerOverlay, width: usize) -> Vec<Line<'static>> {
179 let (title, hint) = match overlay.kind {
180 ComposerOverlayKind::Approval => ("Approval required", "y allow · a session · n deny"),
181 ComposerOverlayKind::ChildApproval => {
182 ("Child-agent approval", "y allow · a session · n deny")
183 }
184 ComposerOverlayKind::Elicitation => (
185 "MCP input requested",
186 "Enter accept · Ctrl+C decline · Esc cancel",
187 ),
188 ComposerOverlayKind::Other => (
189 "Runtime input requested",
190 "Enter accept · Ctrl+C decline · Esc cancel",
191 ),
192 };
193 let title_style = Style::default()
194 .fg(self.colors.best_color((235, 175, 80)))
195 .add_modifier(Modifier::BOLD);
196 let mut lines = vec![Line::from(vec![
197 Span::styled("◆ ", title_style),
198 Span::styled(title, title_style),
199 ])];
200 let summary = bounded_terminal_text(&overlay.summary(), 512);
201 lines.extend(adaptive_wrap_lines(
202 [Line::from(format!(" {summary}"))],
203 RtOptions::new(width.max(1)),
204 ));
205 if matches!(
206 overlay.kind,
207 ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other
208 ) {
209 lines.push(Line::from(format!(
210 " › {}",
211 bounded_terminal_text(&overlay.input, 512)
212 )));
213 }
214 lines.push(Line::from(Span::styled(
215 format!(" {hint}"),
216 Style::default().fg(self.colors.best_color((145, 150, 160))),
217 )));
218 lines
219 }
220}
221
222impl Widget for ComposerRenderer<'_> {
223 fn render(self, area: Rect, buffer: &mut Buffer) {
224 self.render_buffer(area, buffer);
225 }
226}
227
228#[derive(Debug)]
229struct InputLayout {
230 lines: Vec<WrappedInputLine>,
231 cursor: (usize, usize),
232 text_is_empty: bool,
233}
234
235#[derive(Debug)]
236struct WrappedInputLine {
237 text: String,
238 start: usize,
239 end: usize,
240}
241
242fn input_layout(input: &str, cursor: usize, width: usize) -> InputLayout {
243 let text = sanitize_terminal_text(input);
244 let cursor = sanitize_terminal_text(&input[..cursor]).len();
245 let mut lines = Vec::new();
246 let mut offset = 0usize;
247 for logical in text.split('\n') {
248 lines.extend(wrap_logical_line(logical, offset, width.max(1)));
249 offset += logical.len() + 1;
250 }
251
252 let mut cursor_position = (0usize, 0usize);
253 for (row, line) in lines.iter().enumerate() {
254 if cursor >= line.start && cursor <= line.end {
255 let byte = cursor.min(line.end).saturating_sub(line.start);
256 let column = UnicodeWidthStr::width(&line.text[..byte]);
257 if column >= width && row + 1 < lines.len() {
258 cursor_position = (row + 1, 0);
259 } else {
260 cursor_position = (row, column);
261 }
262 break;
263 }
264 }
265
266 InputLayout {
267 lines,
268 cursor: cursor_position,
269 text_is_empty: text.is_empty(),
270 }
271}
272
273fn wrap_logical_line(text: &str, offset: usize, width: usize) -> Vec<WrappedInputLine> {
274 if text.is_empty() {
275 return vec![WrappedInputLine {
276 text: String::new(),
277 start: offset,
278 end: offset,
279 }];
280 }
281
282 let graphemes = text.grapheme_indices(true).collect::<Vec<_>>();
283 let mut lines = Vec::new();
284 let mut start = 0usize;
285 while start < graphemes.len() {
286 let mut used = 0usize;
287 let mut fit_end = start;
288 let mut word_break = None;
289 for (index, (_, grapheme)) in graphemes.iter().enumerate().skip(start) {
290 let grapheme_width = UnicodeWidthStr::width(*grapheme);
291 if fit_end > start && used.saturating_add(grapheme_width) > width {
292 break;
293 }
294 used = used.saturating_add(grapheme_width);
295 fit_end = index + 1;
296 if grapheme.chars().all(char::is_whitespace) {
297 word_break = Some(fit_end);
298 }
299 if used >= width {
300 break;
301 }
302 }
303 if fit_end == start {
304 fit_end += 1;
305 }
306 let end = if fit_end < graphemes.len() {
307 word_break.filter(|end| *end > start).unwrap_or(fit_end)
308 } else {
309 fit_end
310 };
311 let start_byte = graphemes[start].0;
312 let end_byte = graphemes.get(end).map_or(text.len(), |(byte, _)| *byte);
313 lines.push(WrappedInputLine {
314 text: text[start_byte..end_byte].to_string(),
315 start: offset + start_byte,
316 end: offset + end_byte,
317 });
318 start = end;
319 }
320 lines
321}