use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::foundation::wrapping::{adaptive_wrap_lines, RtOptions};
use crate::human::{bounded_terminal_metadata, bounded_terminal_text, sanitize_terminal_text};
use crate::terminal::palette::ColorCapabilities;
use super::ComposerModel;
use super::ComposerOverlayKind;
use super::ComposerTurnState;
pub struct ComposerRenderer<'a> {
model: &'a ComposerModel,
colors: ColorCapabilities,
}
impl<'a> ComposerRenderer<'a> {
pub fn new(model: &'a ComposerModel, colors: ColorCapabilities) -> Self {
Self { model, colors }
}
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
let width = usize::from(width.max(1));
let mut lines = Vec::new();
if let Some(overlay) = self.model.overlay() {
lines.extend(self.overlay_lines(overlay, width));
lines.push(Line::default());
}
lines.push(self.status_line());
lines.extend(self.input_lines(width));
lines.extend(self.command_lines());
lines.push(self.footer_line(width));
lines
}
pub fn desired_height(&self, width: u16) -> u16 {
self.lines(width).len().min(usize::from(u16::MAX)) as u16
}
pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
Paragraph::new(self.lines(area.width)).render(area, buffer);
}
pub fn cursor_position(&self, width: u16) -> (usize, usize) {
let body_width = usize::from(width.saturating_sub(2).max(1));
input_layout(self.model.input(), self.model.cursor(), body_width).cursor
}
fn status_line(&self) -> Line<'static> {
let (dot, label, color) = match self.model.turn_state() {
ComposerTurnState::Idle => ("●", "Ready", (80, 200, 120)),
ComposerTurnState::Working { .. } => ("●", "Working", (225, 180, 80)),
};
let mut spans = vec![
Span::styled(dot, Style::default().fg(self.colors.best_color(color))),
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(self.colors.best_color(color))
.add_modifier(Modifier::BOLD),
),
];
if !self.model.queued_steering().is_empty() {
spans.push(Span::styled(
format!(" · {} queued", self.model.queued_steering().len()),
Style::default().fg(self.colors.best_color((145, 150, 160))),
));
}
if let Some(failure) = self.model.last_failure() {
spans.push(Span::styled(
format!(" · {}", bounded_terminal_metadata(failure, 128)),
Style::default().fg(self.colors.best_color((235, 100, 100))),
));
}
Line::from(spans)
}
fn input_lines(&self, width: usize) -> Vec<Line<'static>> {
let body_width = width.saturating_sub(2).max(1);
let layout = input_layout(self.model.input(), self.model.cursor(), body_width);
let raw = if layout.text_is_empty {
vec![Line::from(Span::styled(
"Ask anything…",
Style::default().fg(self.colors.best_color((120, 125, 135))),
))]
} else {
layout
.lines
.into_iter()
.map(|line| Line::from(line.text))
.collect()
};
raw.into_iter()
.enumerate()
.map(|(index, line)| {
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled(
if index == 0 { "› " } else { " " },
Style::default()
.fg(self.colors.best_color((120, 190, 255)))
.add_modifier(Modifier::BOLD),
));
spans.extend(line.spans);
Line::from(spans)
})
.collect()
}
fn command_lines(&self) -> Vec<Line<'static>> {
let Some(prefix) = self
.model
.input()
.strip_prefix('/')
.filter(|input| !input.contains(char::is_whitespace))
else {
return Vec::new();
};
self.model
.capabilities()
.operations()
.iter()
.filter_map(|operation| operation.command.as_ref())
.filter(|command| command.name.starts_with(prefix))
.take(6)
.map(|command| {
let mut spans = vec![Span::styled(
format!(" /{}", bounded_terminal_metadata(&command.name, 128)),
Style::default()
.fg(self.colors.best_color((175, 140, 245)))
.add_modifier(Modifier::BOLD),
)];
if let Some(description) = &command.description {
spans.push(Span::styled(
format!(" {}", bounded_terminal_metadata(description, 256)),
Style::default().fg(self.colors.best_color((145, 150, 160))),
));
}
if let Some(argument_hint) = &command.argument_hint {
spans.push(Span::styled(
format!(" {}", bounded_terminal_metadata(argument_hint, 128)),
Style::default().fg(self.colors.best_color((120, 125, 135))),
));
}
Line::from(spans)
})
.collect()
}
fn footer_line(&self, width: usize) -> Line<'static> {
let capabilities = self.model.capabilities();
let mut hints = vec!["Enter send", "Shift+Enter newline"];
if capabilities.can_interrupt {
hints.push("Ctrl+C interrupt");
}
let mut text = hints.join(" · ");
if text.chars().count() > width {
text = text.chars().take(width.saturating_sub(1)).collect();
text.push('…');
}
Line::from(Span::styled(
text,
Style::default().fg(self.colors.best_color((120, 125, 135))),
))
}
fn overlay_lines(&self, overlay: &super::ComposerOverlay, width: usize) -> Vec<Line<'static>> {
let (title, hint) = match overlay.kind {
ComposerOverlayKind::Approval => ("Approval required", "y allow · a session · n deny"),
ComposerOverlayKind::ChildApproval => {
("Child-agent approval", "y allow · a session · n deny")
}
ComposerOverlayKind::Elicitation => (
"MCP input requested",
"Enter accept · Ctrl+C decline · Esc cancel",
),
ComposerOverlayKind::Other => (
"Runtime input requested",
"Enter accept · Ctrl+C decline · Esc cancel",
),
};
let title_style = Style::default()
.fg(self.colors.best_color((235, 175, 80)))
.add_modifier(Modifier::BOLD);
let mut lines = vec![Line::from(vec![
Span::styled("◆ ", title_style),
Span::styled(title, title_style),
])];
let summary = bounded_terminal_text(&overlay.summary(), 512);
lines.extend(adaptive_wrap_lines(
[Line::from(format!(" {summary}"))],
RtOptions::new(width.max(1)),
));
if matches!(
overlay.kind,
ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other
) {
lines.push(Line::from(format!(
" › {}",
bounded_terminal_text(&overlay.input, 512)
)));
}
lines.push(Line::from(Span::styled(
format!(" {hint}"),
Style::default().fg(self.colors.best_color((145, 150, 160))),
)));
lines
}
}
impl Widget for ComposerRenderer<'_> {
fn render(self, area: Rect, buffer: &mut Buffer) {
self.render_buffer(area, buffer);
}
}
#[derive(Debug)]
struct InputLayout {
lines: Vec<WrappedInputLine>,
cursor: (usize, usize),
text_is_empty: bool,
}
#[derive(Debug)]
struct WrappedInputLine {
text: String,
start: usize,
end: usize,
}
fn input_layout(input: &str, cursor: usize, width: usize) -> InputLayout {
let text = sanitize_terminal_text(input);
let cursor = sanitize_terminal_text(&input[..cursor]).len();
let mut lines = Vec::new();
let mut offset = 0usize;
for logical in text.split('\n') {
lines.extend(wrap_logical_line(logical, offset, width.max(1)));
offset += logical.len() + 1;
}
let mut cursor_position = (0usize, 0usize);
for (row, line) in lines.iter().enumerate() {
if cursor >= line.start && cursor <= line.end {
let byte = cursor.min(line.end).saturating_sub(line.start);
let column = UnicodeWidthStr::width(&line.text[..byte]);
if column >= width && row + 1 < lines.len() {
cursor_position = (row + 1, 0);
} else {
cursor_position = (row, column);
}
break;
}
}
InputLayout {
lines,
cursor: cursor_position,
text_is_empty: text.is_empty(),
}
}
fn wrap_logical_line(text: &str, offset: usize, width: usize) -> Vec<WrappedInputLine> {
if text.is_empty() {
return vec![WrappedInputLine {
text: String::new(),
start: offset,
end: offset,
}];
}
let graphemes = text.grapheme_indices(true).collect::<Vec<_>>();
let mut lines = Vec::new();
let mut start = 0usize;
while start < graphemes.len() {
let mut used = 0usize;
let mut fit_end = start;
let mut word_break = None;
for (index, (_, grapheme)) in graphemes.iter().enumerate().skip(start) {
let grapheme_width = UnicodeWidthStr::width(*grapheme);
if fit_end > start && used.saturating_add(grapheme_width) > width {
break;
}
used = used.saturating_add(grapheme_width);
fit_end = index + 1;
if grapheme.chars().all(char::is_whitespace) {
word_break = Some(fit_end);
}
if used >= width {
break;
}
}
if fit_end == start {
fit_end += 1;
}
let end = if fit_end < graphemes.len() {
word_break.filter(|end| *end > start).unwrap_or(fit_end)
} else {
fit_end
};
let start_byte = graphemes[start].0;
let end_byte = graphemes.get(end).map_or(text.len(), |(byte, _)| *byte);
lines.push(WrappedInputLine {
text: text[start_byte..end_byte].to_string(),
start: offset + start_byte,
end: offset + end_byte,
});
start = end;
}
lines
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use serde_json::json;
use supercode::frontend::FrontendActions;
use supercode::frontend::FrontendCommandDescriptor;
use supercode::frontend::FrontendConnectionState;
use supercode::frontend::FrontendDisplayCapabilities;
use supercode::frontend::FrontendEvent;
use supercode::frontend::FrontendOperationDescriptor;
use supercode::frontend::FrontendOperationKind;
use supercode::frontend::FrontendRuntimeDescriptor;
use supercode::frontend::FrontendTurnState;
use supercode::frontend::FRONTEND_RUNTIME_SCHEMA_VERSION;
use crate::terminal::palette::ColorLevel;
use super::*;
fn descriptor(modules: &[&str]) -> FrontendRuntimeDescriptor {
FrontendRuntimeDescriptor {
schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
session_id: "test".into(),
source_harness: None,
emulation_profile: None,
active_modules: modules.iter().map(|module| (*module).into()).collect(),
commands: vec![
FrontendCommandDescriptor {
name: "model".into(),
description: Some("choose model".into()),
argument_hint: None,
},
FrontendCommandDescriptor {
name: "mention".into(),
description: Some("attach file".into()),
argument_hint: None,
},
],
operations: vec![
FrontendOperationDescriptor {
id: "prompt:model".into(),
kind: FrontendOperationKind::Prompt,
command: Some(FrontendCommandDescriptor {
name: "model".into(),
description: Some("choose model".into()),
argument_hint: Some("[arguments]".into()),
}),
},
FrontendOperationDescriptor {
id: "prompt:mention".into(),
kind: FrontendOperationKind::Prompt,
command: Some(FrontendCommandDescriptor {
name: "mention".into(),
description: Some("attach file".into()),
argument_hint: Some("<path>".into()),
}),
},
],
actions: FrontendActions {
submit: true,
interrupt: true,
steer: true,
respond: true,
detach: true,
close: true,
},
display: FrontendDisplayCapabilities {
event_kinds: vec![],
opaque_fallback: true,
},
model: "test".into(),
turn_state: FrontendTurnState::Idle,
connection_state: FrontendConnectionState::Connected,
extensions: Default::default(),
}
}
fn colors() -> ColorCapabilities {
ColorCapabilities {
level: ColorLevel::TrueColor,
color_enabled: true,
}
}
fn visible(lines: Vec<Line<'static>>) -> String {
lines
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn footer_exposes_only_invocable_descriptor_actions() {
let model = ComposerModel::new(&descriptor(&[
"tools_search",
"model_catalog",
"session_tree",
"subagents",
"tui",
"reduction",
]));
let rendered = visible(ComposerRenderer::new(&model, colors()).lines(160));
assert!(rendered.contains("Enter send"));
assert!(rendered.contains("Shift+Enter newline"));
assert!(rendered.contains("Ctrl+C interrupt"));
for control in [
"@ files",
"model picker",
"sessions",
"agents",
"images",
"reduce",
] {
assert!(!rendered.contains(control), "phantom control: {control}");
}
}
#[test]
fn runtime_commands_filter_without_a_hardcoded_dispatch_table() {
let mut model = ComposerModel::new(&descriptor(&[]));
model.set_input("/mo");
let rendered = visible(ComposerRenderer::new(&model, colors()).lines(80));
assert!(rendered.contains("/model choose model"));
assert!(!rendered.contains("/mention"));
}
#[test]
fn request_overlay_and_resolution_hints_are_visible() {
let mut model = ComposerModel::new(&descriptor(&["permissions"]));
model.apply_event(&FrontendEvent {
sequence: 1,
kind: "request".into(),
payload: json!({"type":"request", "request":{"id":3, "kind":"approval", "payload":{"summary":"Run cargo test"}}}),
});
let rendered = visible(ComposerRenderer::new(&model, colors()).lines(80));
assert!(rendered.contains("Approval required"));
assert!(rendered.contains("Run cargo test"));
assert!(rendered.contains("y allow · a session · n deny"));
model.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let rendered = visible(ComposerRenderer::new(&model, colors()).lines(80));
assert!(!rendered.contains("Approval required"));
}
#[test]
fn composer_reflows_at_terminal_widths_and_sanitizes_controls() {
let mut model = ComposerModel::new(&descriptor(&[]));
model.set_input("A long prompt with \u{1b}[31mcontrol bytes that must wrap cleanly across terminal sizes.");
let narrow = visible(ComposerRenderer::new(&model, colors()).lines(40));
let medium = visible(ComposerRenderer::new(&model, colors()).lines(80));
let wide = visible(ComposerRenderer::new(&model, colors()).lines(160));
assert!(narrow.lines().count() > medium.lines().count());
assert!(medium.lines().count() >= wide.lines().count());
assert!(!narrow.contains('\u{1b}'));
}
#[test]
fn runtime_metadata_and_large_nested_request_are_sanitized_and_bounded() {
let mut descriptor = descriptor(&["permissions", "session_tree"]);
descriptor.operations[0].command.as_mut().unwrap().name = "\u{1b}]0;owned\u{7}model".into();
descriptor.operations[0]
.command
.as_mut()
.unwrap()
.description = Some(format!("\u{1b}[31mdescription{}", "x".repeat(10_000)));
let mut model = ComposerModel::new(&descriptor);
model.set_input("/");
let commands = visible(ComposerRenderer::new(&model, colors()).lines(80));
assert!(!commands.contains('\u{1b}'), "{commands:?}");
assert!(commands.len() < 1_000, "{}", commands.len());
model.set_input("");
model.apply_event(&FrontendEvent {
sequence: 1,
kind: "request".into(),
payload: json!({
"request": {
"id": 9,
"kind": "approval",
"payload": {"nested":{"ansi":format!("\u{1b}]0;owned\u{7}{}", "z".repeat(10_000))}}
}
}),
});
let overlay = visible(ComposerRenderer::new(&model, colors()).lines(80));
assert!(!overlay.contains('\u{1b}'), "{overlay:?}");
assert!(overlay.len() < 1_500, "{}", overlay.len());
assert!(!overlay.contains("z".repeat(1_000).as_str()), "{overlay}");
}
#[test]
fn cursor_uses_the_same_word_and_grapheme_layout_as_rendered_input() {
let mut model = ComposerModel::new(&descriptor(&[]));
model.set_input("alpha beta 👩💻 gamma");
let renderer = ComposerRenderer::new(&model, colors());
let layout = input_layout(model.input(), model.cursor(), 9);
let rendered_input = layout
.lines
.iter()
.map(|line| line.text.as_str())
.collect::<Vec<_>>();
assert_eq!(rendered_input, ["alpha ", "beta 👩💻 ", "gamma"]);
assert_eq!(renderer.cursor_position(11), (2, 5));
model.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
let renderer = ComposerRenderer::new(&model, colors());
assert_eq!(renderer.cursor_position(11), (2, 4));
}
}