1use 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}
322
323#[cfg(test)]
324mod tests {
325 use crossterm::event::KeyCode;
326 use crossterm::event::KeyEvent;
327 use crossterm::event::KeyModifiers;
328 use serde_json::json;
329 use supercode::frontend::FrontendActions;
330 use supercode::frontend::FrontendCommandDescriptor;
331 use supercode::frontend::FrontendConnectionState;
332 use supercode::frontend::FrontendDisplayCapabilities;
333 use supercode::frontend::FrontendEvent;
334 use supercode::frontend::FrontendOperationDescriptor;
335 use supercode::frontend::FrontendOperationKind;
336 use supercode::frontend::FrontendRuntimeDescriptor;
337 use supercode::frontend::FrontendTurnState;
338 use supercode::frontend::FRONTEND_RUNTIME_SCHEMA_VERSION;
339
340 use crate::terminal::palette::ColorLevel;
341
342 use super::*;
343
344 fn descriptor(modules: &[&str]) -> FrontendRuntimeDescriptor {
345 FrontendRuntimeDescriptor {
346 schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
347 session_id: "test".into(),
348 source_harness: None,
349 emulation_profile: None,
350 active_modules: modules.iter().map(|module| (*module).into()).collect(),
351 commands: vec![
352 FrontendCommandDescriptor {
353 name: "model".into(),
354 description: Some("choose model".into()),
355 argument_hint: None,
356 },
357 FrontendCommandDescriptor {
358 name: "mention".into(),
359 description: Some("attach file".into()),
360 argument_hint: None,
361 },
362 ],
363 operations: vec![
364 FrontendOperationDescriptor {
365 id: "prompt:model".into(),
366 kind: FrontendOperationKind::Prompt,
367 command: Some(FrontendCommandDescriptor {
368 name: "model".into(),
369 description: Some("choose model".into()),
370 argument_hint: Some("[arguments]".into()),
371 }),
372 },
373 FrontendOperationDescriptor {
374 id: "prompt:mention".into(),
375 kind: FrontendOperationKind::Prompt,
376 command: Some(FrontendCommandDescriptor {
377 name: "mention".into(),
378 description: Some("attach file".into()),
379 argument_hint: Some("<path>".into()),
380 }),
381 },
382 ],
383 actions: FrontendActions {
384 submit: true,
385 interrupt: true,
386 steer: true,
387 respond: true,
388 detach: true,
389 close: true,
390 },
391 display: FrontendDisplayCapabilities {
392 event_kinds: vec![],
393 opaque_fallback: true,
394 },
395 model: "test".into(),
396 turn_state: FrontendTurnState::Idle,
397 connection_state: FrontendConnectionState::Connected,
398 extensions: Default::default(),
399 }
400 }
401
402 fn colors() -> ColorCapabilities {
403 ColorCapabilities {
404 level: ColorLevel::TrueColor,
405 color_enabled: true,
406 }
407 }
408
409 fn visible(lines: Vec<Line<'static>>) -> String {
410 lines
411 .into_iter()
412 .map(|line| {
413 line.spans
414 .into_iter()
415 .map(|span| span.content.into_owned())
416 .collect::<String>()
417 })
418 .collect::<Vec<_>>()
419 .join("\n")
420 }
421
422 #[test]
423 fn footer_exposes_only_invocable_descriptor_actions() {
424 let model = ComposerModel::new(&descriptor(&[
425 "tools_search",
426 "model_catalog",
427 "session_tree",
428 "subagents",
429 "tui",
430 "reduction",
431 ]));
432 let rendered = visible(ComposerRenderer::new(&model, colors()).lines(160));
433 assert!(rendered.contains("Enter send"));
434 assert!(rendered.contains("Shift+Enter newline"));
435 assert!(rendered.contains("Ctrl+C interrupt"));
436 for control in [
437 "@ files",
438 "model picker",
439 "sessions",
440 "agents",
441 "images",
442 "reduce",
443 ] {
444 assert!(!rendered.contains(control), "phantom control: {control}");
445 }
446 }
447
448 #[test]
449 fn runtime_commands_filter_without_a_hardcoded_dispatch_table() {
450 let mut model = ComposerModel::new(&descriptor(&[]));
451 model.set_input("/mo");
452 let rendered = visible(ComposerRenderer::new(&model, colors()).lines(80));
453 assert!(rendered.contains("/model choose model"));
454 assert!(!rendered.contains("/mention"));
455 }
456
457 #[test]
458 fn request_overlay_and_resolution_hints_are_visible() {
459 let mut model = ComposerModel::new(&descriptor(&["permissions"]));
460 model.apply_event(&FrontendEvent {
461 sequence: 1,
462 kind: "request".into(),
463 payload: json!({"type":"request", "request":{"id":3, "kind":"approval", "payload":{"summary":"Run cargo test"}}}),
464 });
465 let rendered = visible(ComposerRenderer::new(&model, colors()).lines(80));
466 assert!(rendered.contains("Approval required"));
467 assert!(rendered.contains("Run cargo test"));
468 assert!(rendered.contains("y allow · a session · n deny"));
469 model.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
470 let rendered = visible(ComposerRenderer::new(&model, colors()).lines(80));
471 assert!(!rendered.contains("Approval required"));
472 }
473
474 #[test]
475 fn composer_reflows_at_terminal_widths_and_sanitizes_controls() {
476 let mut model = ComposerModel::new(&descriptor(&[]));
477 model.set_input("A long prompt with \u{1b}[31mcontrol bytes that must wrap cleanly across terminal sizes.");
478 let narrow = visible(ComposerRenderer::new(&model, colors()).lines(40));
479 let medium = visible(ComposerRenderer::new(&model, colors()).lines(80));
480 let wide = visible(ComposerRenderer::new(&model, colors()).lines(160));
481 assert!(narrow.lines().count() > medium.lines().count());
482 assert!(medium.lines().count() >= wide.lines().count());
483 assert!(!narrow.contains('\u{1b}'));
484 }
485
486 #[test]
487 fn runtime_metadata_and_large_nested_request_are_sanitized_and_bounded() {
488 let mut descriptor = descriptor(&["permissions", "session_tree"]);
489 descriptor.operations[0].command.as_mut().unwrap().name = "\u{1b}]0;owned\u{7}model".into();
490 descriptor.operations[0]
491 .command
492 .as_mut()
493 .unwrap()
494 .description = Some(format!("\u{1b}[31mdescription{}", "x".repeat(10_000)));
495 let mut model = ComposerModel::new(&descriptor);
496 model.set_input("/");
497 let commands = visible(ComposerRenderer::new(&model, colors()).lines(80));
498 assert!(!commands.contains('\u{1b}'), "{commands:?}");
499 assert!(commands.len() < 1_000, "{}", commands.len());
500
501 model.set_input("");
502 model.apply_event(&FrontendEvent {
503 sequence: 1,
504 kind: "request".into(),
505 payload: json!({
506 "request": {
507 "id": 9,
508 "kind": "approval",
509 "payload": {"nested":{"ansi":format!("\u{1b}]0;owned\u{7}{}", "z".repeat(10_000))}}
510 }
511 }),
512 });
513 let overlay = visible(ComposerRenderer::new(&model, colors()).lines(80));
514 assert!(!overlay.contains('\u{1b}'), "{overlay:?}");
515 assert!(overlay.len() < 1_500, "{}", overlay.len());
516 assert!(!overlay.contains("z".repeat(1_000).as_str()), "{overlay}");
517 }
518
519 #[test]
520 fn cursor_uses_the_same_word_and_grapheme_layout_as_rendered_input() {
521 let mut model = ComposerModel::new(&descriptor(&[]));
522 model.set_input("alpha beta 👩💻 gamma");
523 let renderer = ComposerRenderer::new(&model, colors());
524 let layout = input_layout(model.input(), model.cursor(), 9);
525 let rendered_input = layout
526 .lines
527 .iter()
528 .map(|line| line.text.as_str())
529 .collect::<Vec<_>>();
530 assert_eq!(rendered_input, ["alpha ", "beta 👩💻 ", "gamma"]);
531 assert_eq!(renderer.cursor_position(11), (2, 5));
532
533 model.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
534 let renderer = ComposerRenderer::new(&model, colors());
535 assert_eq!(renderer.cursor_position(11), (2, 4));
536 }
537}