use std::fmt;
use std::io;
use std::io::Write;
use std::sync::Arc;
use crossterm::event::{EventStream, KeyCode, KeyModifiers};
use futures::StreamExt;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::{Frame, Terminal};
use supercode::frontend::{FrontendRuntime, FrontendRuntimeError};
use supercode::Role;
use crate::composer::{ComposerAction, ComposerModel, ComposerRenderer, ComposerTurnState};
use crate::human::{bounded_terminal_metadata, sanitize_terminal_text};
use crate::runtime::{is_interrupted_submit_error, TerminalRuntimeView};
use crate::terminal::event_stream::map_crossterm_event;
use crate::terminal::lifecycle::{CrosstermTerminalOps, TerminalGuard};
use crate::terminal::palette::ColorCapabilities;
use crate::transcript::{TranscriptModel, TranscriptRenderer};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrontendExit {
Detached,
Disconnected,
}
#[derive(Debug)]
pub struct FrontendAppError {
primary: Option<String>,
restore: Option<String>,
}
impl FrontendAppError {
fn primary(error: impl fmt::Display) -> Self {
Self {
primary: Some(sanitize_terminal_text(&error.to_string())),
restore: None,
}
}
fn restored(primary: Option<Self>, restore: io::Error) -> Self {
Self {
primary: primary.and_then(|error| error.primary),
restore: Some(sanitize_terminal_text(&restore.to_string())),
}
}
}
impl fmt::Display for FrontendAppError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match (&self.primary, &self.restore) {
(Some(primary), Some(restore)) => {
write!(
formatter,
"{primary}; terminal restoration also failed: {restore}"
)
}
(Some(primary), None) => formatter.write_str(primary),
(None, Some(restore)) => write!(formatter, "terminal restoration failed: {restore}"),
(None, None) => formatter.write_str("frontend failed"),
}
}
}
impl std::error::Error for FrontendAppError {}
enum ActionCompletion {
Simple {
action: &'static str,
result: Result<(), FrontendRuntimeError>,
},
Respond {
request: supercode::frontend::FrontendRequest,
resolution: String,
result: Result<(), FrontendRuntimeError>,
},
}
pub async fn run(runtime: Arc<dyn FrontendRuntime>) -> Result<FrontendExit, FrontendAppError> {
let view = TerminalRuntimeView::attach(runtime, 1_000)
.await
.map_err(FrontendAppError::primary)?;
let mut guard =
TerminalGuard::enter(CrosstermTerminalOps).map_err(FrontendAppError::primary)?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).map_err(FrontendAppError::primary)?;
terminal.clear().map_err(FrontendAppError::primary)?;
let result = run_attached(&mut terminal, view).await;
let restore = guard.restore();
match (result, restore) {
(Ok(exit), Ok(())) => Ok(exit),
(Err(primary), Ok(())) => Err(primary),
(result, Err(restore)) => Err(FrontendAppError::restored(result.err(), restore)),
}
}
pub async fn run_line(runtime: Arc<dyn FrontendRuntime>) -> Result<FrontendExit, FrontendAppError> {
use tokio::io::AsyncBufReadExt;
let mut view = TerminalRuntimeView::attach(runtime, 1_000)
.await
.map_err(FrontendAppError::primary)?;
let controller = view.controller();
writeln!(
io::stdout(),
"attached to {}",
bounded_terminal_metadata(&view.descriptor().session_id, 256)
)
.map_err(FrontendAppError::primary)?;
for message in view.history() {
let label = match message.role {
Role::User => "you",
Role::Assistant => "agent",
Role::System => "system",
Role::Tool => "tool",
};
if let Some(content) = message.content.as_deref().filter(|text| !text.is_empty()) {
writeln!(io::stdout(), "{label}> {}", sanitize_terminal_text(content))
.map_err(FrontendAppError::primary)?;
}
}
io::stdout().flush().map_err(FrontendAppError::primary)?;
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
let (completed_tx, mut completed_rx) = tokio::sync::mpsc::unbounded_channel();
loop {
tokio::select! {
line = lines.next_line() => {
let Some(line) = line.map_err(FrontendAppError::primary)? else {
return Ok(FrontendExit::Detached);
};
match line.trim() {
"/detach" | "/quit" | "/exit" => return Ok(FrontendExit::Detached),
"/interrupt" => {
let runtime = controller.clone();
let completed_tx = completed_tx.clone();
tokio::spawn(async move {
let _ = completed_tx.send(runtime.interrupt().await.map(|_| ()));
});
}
"" => {}
prompt => {
let runtime = controller.clone();
let prompt = prompt.to_string();
let completed_tx = completed_tx.clone();
tokio::spawn(async move {
let _ = completed_tx.send(runtime.submit(prompt).await.map(|_| ()));
});
}
}
}
result = completed_rx.recv() => {
if let Some(Err(error)) = result {
if !is_interrupted_submit_error(&error) {
writeln!(io::stderr(), "error: {}", sanitize_terminal_text(&error.to_string()))
.map_err(FrontendAppError::primary)?;
}
}
}
event = view.next_event() => {
match event {
Ok(event) if event.kind == "runtime_disconnected" => {
writeln!(io::stderr(), "runtime disconnected")
.map_err(FrontendAppError::primary)?;
return Ok(FrontendExit::Disconnected);
}
Ok(event) => render_line_event(&event)?,
Err(FrontendRuntimeError::Closed) => return Ok(FrontendExit::Disconnected),
Err(error) => return Err(FrontendAppError::primary(error)),
}
}
}
}
}
fn render_line_event(event: &supercode::frontend::FrontendEvent) -> Result<(), FrontendAppError> {
let payload = &event.payload;
match event.kind.as_str() {
"user_message" => {}
"text_delta" => {
write!(
io::stdout(),
"{}",
sanitize_terminal_text(
payload
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
)
)
.map_err(FrontendAppError::primary)?;
io::stdout().flush().map_err(FrontendAppError::primary)?;
}
"turn_succeeded" => {
writeln!(io::stdout()).map_err(FrontendAppError::primary)?;
}
"turn_interrupted" => {
writeln!(io::stdout(), "\n[turn interrupted]").map_err(FrontendAppError::primary)?;
}
"turn_failed" => {
let message = bounded_terminal_metadata(
payload
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("runtime error"),
256,
);
writeln!(io::stdout(), "\n[turn failed: {message}]")
.map_err(FrontendAppError::primary)?;
}
"tool_call_started" => {
let name = bounded_terminal_metadata(
payload
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("tool"),
256,
);
writeln!(io::stdout(), "[working: {name}]").map_err(FrontendAppError::primary)?;
}
"tool_call_completed" => {
let name = bounded_terminal_metadata(
payload
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("tool"),
256,
);
let status =
if payload.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
"failed"
} else {
"done"
};
writeln!(io::stdout(), "[{name}: {status}]").map_err(FrontendAppError::primary)?;
}
"request" => {
writeln!(
io::stdout(),
"[runtime input requested; attach from a TTY to respond]"
)
.map_err(FrontendAppError::primary)?;
}
_ => {
writeln!(io::stdout(), "{}", unknown_line_event_summary(event))
.map_err(FrontendAppError::primary)?;
}
}
Ok(())
}
async fn run_attached(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut view: TerminalRuntimeView,
) -> Result<FrontendExit, FrontendAppError> {
let mut transcript = view.transcript_model();
let mut composer = ComposerModel::new(view.descriptor());
while let Some(event) = view.next_replay_event() {
transcript.apply_event(&event);
composer.apply_event(&event);
}
let controller = view.controller();
let mut input = EventStream::new();
let (completion_tx, mut completion_rx) = tokio::sync::mpsc::unbounded_channel();
loop {
terminal
.draw(|frame| render(frame, &transcript, &composer))
.map_err(FrontendAppError::primary)?;
tokio::select! {
event = view.next_event() => {
match event {
Ok(event) if event.kind == "runtime_disconnected" => {
transcript.apply_event(&event);
composer.apply_event(&event);
terminal.draw(|frame| render(frame, &transcript, &composer))
.map_err(FrontendAppError::primary)?;
return Ok(FrontendExit::Disconnected);
}
Ok(event) => {
transcript.apply_event(&event);
composer.apply_event(&event);
}
Err(FrontendRuntimeError::Closed) => return Ok(FrontendExit::Disconnected),
Err(error) => return Err(FrontendAppError::primary(error)),
}
}
event = input.next() => {
let Some(event) = event else {
return Ok(FrontendExit::Detached);
};
let event = event.map_err(FrontendAppError::primary)?;
if let crossterm::event::Event::Key(key) = &event {
if local_detach_requested(key, &composer) {
return Ok(FrontendExit::Detached);
}
if key.code == KeyCode::Char('\u{3}')
&& matches!(composer.turn_state(), ComposerTurnState::Working { .. })
&& composer.capabilities().can_interrupt
{
spawn_action(
controller.clone(),
ComposerAction::Interrupt,
completion_tx.clone(),
);
continue;
}
}
if let Some(event) = map_crossterm_event(event) {
if let Some(action) = composer.handle_terminal_event(event) {
spawn_action(controller.clone(), action, completion_tx.clone());
}
}
}
completion = completion_rx.recv() => {
let Some(completion) = completion else { continue; };
match completion {
ActionCompletion::Simple { action, result } => {
if should_record_action_failure(action, &result) {
composer.record_dispatch_failure(action);
}
}
ActionCompletion::Respond { request, resolution, result } => {
if result.is_ok() {
transcript.resolve_frontend_request(request.id, &resolution);
} else {
composer.restore_request(request);
}
}
}
}
}
}
}
fn unknown_line_event_summary(event: &supercode::frontend::FrontendEvent) -> String {
format!(
"[event: {}; details unavailable in line mode]",
bounded_terminal_metadata(&event.kind, 128)
)
}
fn should_record_action_failure(action: &str, result: &Result<(), FrontendRuntimeError>) -> bool {
result.is_err()
&& !(action == "submit"
&& result
.as_ref()
.err()
.is_some_and(is_interrupted_submit_error))
}
fn local_detach_requested(key: &crossterm::event::KeyEvent, composer: &ComposerModel) -> bool {
if composer.overlay().is_some() {
return false;
}
let typed_detach = (key.code == KeyCode::Char('d')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| key.code == KeyCode::Char('\u{4}');
(typed_detach && composer.input().is_empty())
|| (key.code == KeyCode::Enter
&& matches!(composer.input().trim(), "/detach" | "/quit" | "/exit"))
}
fn spawn_action(
runtime: Arc<dyn FrontendRuntime>,
action: ComposerAction,
completed: tokio::sync::mpsc::UnboundedSender<ActionCompletion>,
) {
tokio::spawn(async move {
let completion = match action {
ComposerAction::Submit(prompt) => ActionCompletion::Simple {
action: "submit",
result: runtime.submit(prompt).await.map(|_| ()),
},
ComposerAction::Invoke(operation) => ActionCompletion::Simple {
action: "invoke",
result: runtime.invoke(operation).await.map(|_| ()),
},
ComposerAction::Steer(prompt) => ActionCompletion::Simple {
action: "steer",
result: runtime.steer(prompt).await,
},
ComposerAction::Interrupt => ActionCompletion::Simple {
action: "interrupt",
result: runtime.interrupt().await.map(|_| ()),
},
ComposerAction::Respond {
response,
request,
resolution,
} => ActionCompletion::Respond {
request,
resolution,
result: runtime.respond(response).await,
},
};
let _ = completed.send(completion);
});
}
fn render(frame: &mut Frame<'_>, transcript: &TranscriptModel, composer: &ComposerModel) {
let area = frame.area();
let colors = terminal_colors();
let composer_renderer = ComposerRenderer::new(composer, colors);
let composer_height = composer_renderer
.desired_height(area.width)
.min(area.height.saturating_sub(1));
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(composer_height.max(1)),
])
.split(area);
TranscriptRenderer::new(transcript, colors).render_tail_buffer(areas[0], frame.buffer_mut());
let input_cursor = composer_renderer.cursor_position(areas[1].width);
frame.render_widget(composer_renderer, areas[1]);
if composer.overlay().is_none() {
let (x, y) = composer_cursor(areas[1], input_cursor);
frame.set_cursor_position((x, y));
}
}
fn composer_cursor(area: Rect, (row, column): (usize, usize)) -> (u16, u16) {
let x = area
.x
.saturating_add(2)
.saturating_add(column as u16)
.min(area.right().saturating_sub(1));
let y = area
.y
.saturating_add(1)
.saturating_add(row as u16)
.min(area.bottom().saturating_sub(2));
(x, y)
}
fn terminal_colors() -> ColorCapabilities {
ColorCapabilities::resolve(
std::env::var_os("NO_COLOR").is_some(),
std::env::var("TERM").ok().as_deref(),
std::env::var("COLORTERM").ok().as_deref(),
false,
)
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyEvent;
use ratatui::backend::TestBackend;
use serde_json::json;
use supercode::frontend::{
FrontendActions, FrontendConnectionState, FrontendDisplayCapabilities, FrontendEvent,
FrontendRuntimeDescriptor, FrontendTurnState, FRONTEND_RUNTIME_SCHEMA_VERSION,
};
use super::*;
fn descriptor() -> FrontendRuntimeDescriptor {
FrontendRuntimeDescriptor {
schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
session_id: "shared".into(),
source_harness: None,
emulation_profile: None,
active_modules: vec!["tui".into()],
commands: vec![],
operations: vec![],
actions: FrontendActions {
submit: true,
interrupt: true,
steer: true,
respond: true,
detach: true,
close: false,
},
display: FrontendDisplayCapabilities {
event_kinds: vec![],
opaque_fallback: true,
},
model: "test".into(),
turn_state: FrontendTurnState::Idle,
connection_state: FrontendConnectionState::Connected,
extensions: Default::default(),
}
}
#[test]
fn shared_layout_follows_the_tail_and_places_the_cursor() {
let mut transcript = TranscriptModel::default();
for sequence in 1..=40 {
transcript.apply_event(&FrontendEvent {
sequence,
kind: "text_delta".into(),
payload: json!({"type":"text_delta", "text":format!("line-{sequence}\n")}),
});
}
let mut composer = ComposerModel::new(&descriptor());
composer.set_input("hello");
let backend = TestBackend::new(40, 12);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render(frame, &transcript, &composer))
.unwrap();
let contents = terminal.backend().to_string();
assert!(contents.contains("line-40"), "{contents}");
assert!(contents.contains("hello"), "{contents}");
}
#[test]
fn ctrl_d_and_detach_command_are_local_and_never_become_runtime_actions() {
let mut composer = ComposerModel::new(&descriptor());
assert!(local_detach_requested(
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
&composer
));
assert!(local_detach_requested(
&KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::NONE),
&composer
));
composer.set_input("/detach");
assert!(local_detach_requested(
&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&composer
));
assert_eq!(composer.input(), "/detach");
}
#[test]
fn interrupted_submit_is_a_turn_outcome_not_submit_failure() {
let interrupted = Err(FrontendRuntimeError::Submit(
supercode::server::RuntimeSubmitError::Interrupted,
));
assert!(!should_record_action_failure("submit", &interrupted));
assert!(should_record_action_failure("steer", &interrupted));
}
#[test]
fn unknown_line_fallback_is_bounded_and_sanitized() {
let summary = unknown_line_event_summary(&FrontendEvent {
sequence: 1,
kind: format!("\u{1b}]0;owned\u{7}{}", "x".repeat(10_000)),
payload: json!({"nested":"y".repeat(10_000)}),
});
assert!(summary.chars().count() < 200, "{}", summary.len());
assert!(!summary.contains('\u{1b}'), "{summary:?}");
assert!(!summary.contains("nested"), "{summary}");
}
#[test]
fn real_app_render_path_preserves_hyperlink_annotations() {
let mut transcript = TranscriptModel::default();
transcript.apply_event(&FrontendEvent {
sequence: 1,
kind: "text_delta".into(),
payload: json!({"text":"See https://example.com/path"}),
});
let composer = ComposerModel::new(&descriptor());
let backend = TestBackend::new(50, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render(frame, &transcript, &composer))
.unwrap();
let symbols = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(
symbols.contains("\x1b]8;;https://example.com/path"),
"{symbols:?}"
);
}
}