Skip to main content

supercode_frontend_tui/composer/
render.rs

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
20/// Pure Ratatui projection of composer, request overlay, commands, and footer.
21pub 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    /// Cursor row and display column within the rendered input body. This is
54    /// computed by the exact same word/grapheme layout used by `input_lines`.
55    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}