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}