1use std::fmt;
4use std::io;
5use std::io::Write;
6use std::sync::Arc;
7
8use crossterm::event::{EventStream, KeyCode, KeyModifiers};
9use futures::StreamExt;
10use ratatui::backend::CrosstermBackend;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::{Frame, Terminal};
13use supercode::frontend::{FrontendRuntime, FrontendRuntimeError};
14use supercode::Role;
15
16use crate::composer::{ComposerAction, ComposerModel, ComposerRenderer, ComposerTurnState};
17use crate::human::{bounded_terminal_metadata, sanitize_terminal_text};
18use crate::runtime::{is_interrupted_submit_error, TerminalRuntimeView};
19use crate::terminal::event_stream::map_crossterm_event;
20use crate::terminal::lifecycle::{CrosstermTerminalOps, TerminalGuard};
21use crate::terminal::palette::ColorCapabilities;
22use crate::transcript::{TranscriptModel, TranscriptRenderer};
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum FrontendExit {
28 Detached,
29 Disconnected,
30}
31
32#[derive(Debug)]
35pub struct FrontendAppError {
36 primary: Option<String>,
37 restore: Option<String>,
38}
39
40impl FrontendAppError {
41 fn primary(error: impl fmt::Display) -> Self {
42 Self {
43 primary: Some(sanitize_terminal_text(&error.to_string())),
44 restore: None,
45 }
46 }
47
48 fn restored(primary: Option<Self>, restore: io::Error) -> Self {
49 Self {
50 primary: primary.and_then(|error| error.primary),
51 restore: Some(sanitize_terminal_text(&restore.to_string())),
52 }
53 }
54}
55
56impl fmt::Display for FrontendAppError {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match (&self.primary, &self.restore) {
59 (Some(primary), Some(restore)) => {
60 write!(
61 formatter,
62 "{primary}; terminal restoration also failed: {restore}"
63 )
64 }
65 (Some(primary), None) => formatter.write_str(primary),
66 (None, Some(restore)) => write!(formatter, "terminal restoration failed: {restore}"),
67 (None, None) => formatter.write_str("frontend failed"),
68 }
69 }
70}
71
72impl std::error::Error for FrontendAppError {}
73
74enum ActionCompletion {
75 Simple {
76 action: &'static str,
77 result: Result<(), FrontendRuntimeError>,
78 },
79 Respond {
80 request: supercode::frontend::FrontendRequest,
81 resolution: String,
82 result: Result<(), FrontendRuntimeError>,
83 },
84}
85
86pub async fn run(runtime: Arc<dyn FrontendRuntime>) -> Result<FrontendExit, FrontendAppError> {
88 let view = TerminalRuntimeView::attach(runtime, 1_000)
89 .await
90 .map_err(FrontendAppError::primary)?;
91 let mut guard =
92 TerminalGuard::enter(CrosstermTerminalOps).map_err(FrontendAppError::primary)?;
93 let backend = CrosstermBackend::new(io::stdout());
94 let mut terminal = Terminal::new(backend).map_err(FrontendAppError::primary)?;
95 terminal.clear().map_err(FrontendAppError::primary)?;
96
97 let result = run_attached(&mut terminal, view).await;
98 let restore = guard.restore();
99 match (result, restore) {
100 (Ok(exit), Ok(())) => Ok(exit),
101 (Err(primary), Ok(())) => Err(primary),
102 (result, Err(restore)) => Err(FrontendAppError::restored(result.err(), restore)),
103 }
104}
105
106pub async fn run_line(runtime: Arc<dyn FrontendRuntime>) -> Result<FrontendExit, FrontendAppError> {
109 use tokio::io::AsyncBufReadExt;
110
111 let mut view = TerminalRuntimeView::attach(runtime, 1_000)
112 .await
113 .map_err(FrontendAppError::primary)?;
114 let controller = view.controller();
115 writeln!(
116 io::stdout(),
117 "attached to {}",
118 bounded_terminal_metadata(&view.descriptor().session_id, 256)
119 )
120 .map_err(FrontendAppError::primary)?;
121 for message in view.history() {
122 let label = match message.role {
123 Role::User => "you",
124 Role::Assistant => "agent",
125 Role::System => "system",
126 Role::Tool => "tool",
127 };
128 if let Some(content) = message.content.as_deref().filter(|text| !text.is_empty()) {
129 writeln!(io::stdout(), "{label}> {}", sanitize_terminal_text(content))
130 .map_err(FrontendAppError::primary)?;
131 }
132 }
133 io::stdout().flush().map_err(FrontendAppError::primary)?;
134
135 let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
136 let (completed_tx, mut completed_rx) = tokio::sync::mpsc::unbounded_channel();
137 loop {
138 tokio::select! {
139 line = lines.next_line() => {
140 let Some(line) = line.map_err(FrontendAppError::primary)? else {
141 return Ok(FrontendExit::Detached);
142 };
143 match line.trim() {
144 "/detach" | "/quit" | "/exit" => return Ok(FrontendExit::Detached),
145 "/interrupt" => {
146 let runtime = controller.clone();
147 let completed_tx = completed_tx.clone();
148 tokio::spawn(async move {
149 let _ = completed_tx.send(runtime.interrupt().await.map(|_| ()));
150 });
151 }
152 "" => {}
153 prompt => {
154 let runtime = controller.clone();
155 let prompt = prompt.to_string();
156 let completed_tx = completed_tx.clone();
157 tokio::spawn(async move {
158 let _ = completed_tx.send(runtime.submit(prompt).await.map(|_| ()));
159 });
160 }
161 }
162 }
163 result = completed_rx.recv() => {
164 if let Some(Err(error)) = result {
165 if !is_interrupted_submit_error(&error) {
166 writeln!(io::stderr(), "error: {}", sanitize_terminal_text(&error.to_string()))
167 .map_err(FrontendAppError::primary)?;
168 }
169 }
170 }
171 event = view.next_event() => {
172 match event {
173 Ok(event) if event.kind == "runtime_disconnected" => {
174 writeln!(io::stderr(), "runtime disconnected")
175 .map_err(FrontendAppError::primary)?;
176 return Ok(FrontendExit::Disconnected);
177 }
178 Ok(event) => render_line_event(&event)?,
179 Err(FrontendRuntimeError::Closed) => return Ok(FrontendExit::Disconnected),
180 Err(error) => return Err(FrontendAppError::primary(error)),
181 }
182 }
183 }
184 }
185}
186
187fn render_line_event(event: &supercode::frontend::FrontendEvent) -> Result<(), FrontendAppError> {
188 let payload = &event.payload;
189 match event.kind.as_str() {
190 "user_message" => {}
191 "text_delta" => {
192 write!(
193 io::stdout(),
194 "{}",
195 sanitize_terminal_text(
196 payload
197 .get("text")
198 .and_then(serde_json::Value::as_str)
199 .unwrap_or_default()
200 )
201 )
202 .map_err(FrontendAppError::primary)?;
203 io::stdout().flush().map_err(FrontendAppError::primary)?;
204 }
205 "turn_succeeded" => {
206 writeln!(io::stdout()).map_err(FrontendAppError::primary)?;
207 }
208 "turn_interrupted" => {
209 writeln!(io::stdout(), "\n[turn interrupted]").map_err(FrontendAppError::primary)?;
210 }
211 "turn_failed" => {
212 let message = bounded_terminal_metadata(
213 payload
214 .get("message")
215 .and_then(serde_json::Value::as_str)
216 .unwrap_or("runtime error"),
217 256,
218 );
219 writeln!(io::stdout(), "\n[turn failed: {message}]")
220 .map_err(FrontendAppError::primary)?;
221 }
222 "tool_call_started" => {
223 let name = bounded_terminal_metadata(
224 payload
225 .get("name")
226 .and_then(serde_json::Value::as_str)
227 .unwrap_or("tool"),
228 256,
229 );
230 writeln!(io::stdout(), "[working: {name}]").map_err(FrontendAppError::primary)?;
231 }
232 "tool_call_completed" => {
233 let name = bounded_terminal_metadata(
234 payload
235 .get("name")
236 .and_then(serde_json::Value::as_str)
237 .unwrap_or("tool"),
238 256,
239 );
240 let status =
241 if payload.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
242 "failed"
243 } else {
244 "done"
245 };
246 writeln!(io::stdout(), "[{name}: {status}]").map_err(FrontendAppError::primary)?;
247 }
248 "request" => {
249 writeln!(
250 io::stdout(),
251 "[runtime input requested; attach from a TTY to respond]"
252 )
253 .map_err(FrontendAppError::primary)?;
254 }
255 _ => {
256 writeln!(io::stdout(), "{}", unknown_line_event_summary(event))
257 .map_err(FrontendAppError::primary)?;
258 }
259 }
260 Ok(())
261}
262
263async fn run_attached(
264 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
265 mut view: TerminalRuntimeView,
266) -> Result<FrontendExit, FrontendAppError> {
267 let mut transcript = view.transcript_model();
268 let mut composer = ComposerModel::new(view.descriptor());
269 while let Some(event) = view.next_replay_event() {
270 transcript.apply_event(&event);
271 composer.apply_event(&event);
272 }
273 let controller = view.controller();
274 let mut input = EventStream::new();
275 let (completion_tx, mut completion_rx) = tokio::sync::mpsc::unbounded_channel();
276
277 loop {
278 terminal
279 .draw(|frame| render(frame, &transcript, &composer))
280 .map_err(FrontendAppError::primary)?;
281 tokio::select! {
282 event = view.next_event() => {
283 match event {
284 Ok(event) if event.kind == "runtime_disconnected" => {
285 transcript.apply_event(&event);
286 composer.apply_event(&event);
287 terminal.draw(|frame| render(frame, &transcript, &composer))
288 .map_err(FrontendAppError::primary)?;
289 return Ok(FrontendExit::Disconnected);
290 }
291 Ok(event) => {
292 transcript.apply_event(&event);
293 composer.apply_event(&event);
294 }
295 Err(FrontendRuntimeError::Closed) => return Ok(FrontendExit::Disconnected),
296 Err(error) => return Err(FrontendAppError::primary(error)),
297 }
298 }
299 event = input.next() => {
300 let Some(event) = event else {
301 return Ok(FrontendExit::Detached);
302 };
303 let event = event.map_err(FrontendAppError::primary)?;
304 if let crossterm::event::Event::Key(key) = &event {
305 if local_detach_requested(key, &composer) {
306 return Ok(FrontendExit::Detached);
307 }
308 if key.code == KeyCode::Char('\u{3}')
309 && matches!(composer.turn_state(), ComposerTurnState::Working { .. })
310 && composer.capabilities().can_interrupt
311 {
312 spawn_action(
313 controller.clone(),
314 ComposerAction::Interrupt,
315 completion_tx.clone(),
316 );
317 continue;
318 }
319 }
320 if let Some(event) = map_crossterm_event(event) {
321 if let Some(action) = composer.handle_terminal_event(event) {
322 spawn_action(controller.clone(), action, completion_tx.clone());
323 }
324 }
325 }
326 completion = completion_rx.recv() => {
327 let Some(completion) = completion else { continue; };
328 match completion {
329 ActionCompletion::Simple { action, result } => {
330 if should_record_action_failure(action, &result) {
331 composer.record_dispatch_failure(action);
332 }
333 }
334 ActionCompletion::Respond { request, resolution, result } => {
335 if result.is_ok() {
336 transcript.resolve_frontend_request(request.id, &resolution);
337 } else {
338 composer.restore_request(request);
339 }
340 }
341 }
342 }
343 }
344 }
345}
346
347fn unknown_line_event_summary(event: &supercode::frontend::FrontendEvent) -> String {
348 format!(
349 "[event: {}; details unavailable in line mode]",
350 bounded_terminal_metadata(&event.kind, 128)
351 )
352}
353
354fn should_record_action_failure(action: &str, result: &Result<(), FrontendRuntimeError>) -> bool {
355 result.is_err()
356 && !(action == "submit"
357 && result
358 .as_ref()
359 .err()
360 .is_some_and(is_interrupted_submit_error))
361}
362
363fn local_detach_requested(key: &crossterm::event::KeyEvent, composer: &ComposerModel) -> bool {
364 if composer.overlay().is_some() {
365 return false;
366 }
367 let typed_detach = (key.code == KeyCode::Char('d')
368 && key.modifiers.contains(KeyModifiers::CONTROL))
369 || key.code == KeyCode::Char('\u{4}');
370 (typed_detach && composer.input().is_empty())
371 || (key.code == KeyCode::Enter
372 && matches!(composer.input().trim(), "/detach" | "/quit" | "/exit"))
373}
374
375fn spawn_action(
376 runtime: Arc<dyn FrontendRuntime>,
377 action: ComposerAction,
378 completed: tokio::sync::mpsc::UnboundedSender<ActionCompletion>,
379) {
380 tokio::spawn(async move {
381 let completion = match action {
382 ComposerAction::Submit(prompt) => ActionCompletion::Simple {
383 action: "submit",
384 result: runtime.submit(prompt).await.map(|_| ()),
385 },
386 ComposerAction::Invoke(operation) => ActionCompletion::Simple {
387 action: "invoke",
388 result: runtime.invoke(operation).await.map(|_| ()),
389 },
390 ComposerAction::Steer(prompt) => ActionCompletion::Simple {
391 action: "steer",
392 result: runtime.steer(prompt).await,
393 },
394 ComposerAction::Interrupt => ActionCompletion::Simple {
395 action: "interrupt",
396 result: runtime.interrupt().await.map(|_| ()),
397 },
398 ComposerAction::Respond {
399 response,
400 request,
401 resolution,
402 } => ActionCompletion::Respond {
403 request,
404 resolution,
405 result: runtime.respond(response).await,
406 },
407 };
408 let _ = completed.send(completion);
409 });
410}
411
412fn render(frame: &mut Frame<'_>, transcript: &TranscriptModel, composer: &ComposerModel) {
413 let area = frame.area();
414 let colors = terminal_colors();
415 let composer_renderer = ComposerRenderer::new(composer, colors);
416 let composer_height = composer_renderer
417 .desired_height(area.width)
418 .min(area.height.saturating_sub(1));
419 let areas = Layout::default()
420 .direction(Direction::Vertical)
421 .constraints([
422 Constraint::Min(1),
423 Constraint::Length(composer_height.max(1)),
424 ])
425 .split(area);
426
427 TranscriptRenderer::new(transcript, colors).render_tail_buffer(areas[0], frame.buffer_mut());
428 let input_cursor = composer_renderer.cursor_position(areas[1].width);
429 frame.render_widget(composer_renderer, areas[1]);
430 if composer.overlay().is_none() {
431 let (x, y) = composer_cursor(areas[1], input_cursor);
432 frame.set_cursor_position((x, y));
433 }
434}
435
436fn composer_cursor(area: Rect, (row, column): (usize, usize)) -> (u16, u16) {
437 let x = area
438 .x
439 .saturating_add(2)
440 .saturating_add(column as u16)
441 .min(area.right().saturating_sub(1));
442 let y = area
443 .y
444 .saturating_add(1)
445 .saturating_add(row as u16)
446 .min(area.bottom().saturating_sub(2));
447 (x, y)
448}
449
450fn terminal_colors() -> ColorCapabilities {
451 ColorCapabilities::resolve(
452 std::env::var_os("NO_COLOR").is_some(),
453 std::env::var("TERM").ok().as_deref(),
454 std::env::var("COLORTERM").ok().as_deref(),
455 false,
456 )
457}
458
459#[cfg(test)]
460mod tests {
461 use crossterm::event::KeyEvent;
462 use ratatui::backend::TestBackend;
463 use serde_json::json;
464 use supercode::frontend::{
465 FrontendActions, FrontendConnectionState, FrontendDisplayCapabilities, FrontendEvent,
466 FrontendRuntimeDescriptor, FrontendTurnState, FRONTEND_RUNTIME_SCHEMA_VERSION,
467 };
468
469 use super::*;
470
471 fn descriptor() -> FrontendRuntimeDescriptor {
472 FrontendRuntimeDescriptor {
473 schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
474 session_id: "shared".into(),
475 source_harness: None,
476 emulation_profile: None,
477 active_modules: vec!["tui".into()],
478 commands: vec![],
479 operations: vec![],
480 actions: FrontendActions {
481 submit: true,
482 interrupt: true,
483 steer: true,
484 respond: true,
485 detach: true,
486 close: false,
487 },
488 display: FrontendDisplayCapabilities {
489 event_kinds: vec![],
490 opaque_fallback: true,
491 },
492 model: "test".into(),
493 turn_state: FrontendTurnState::Idle,
494 connection_state: FrontendConnectionState::Connected,
495 extensions: Default::default(),
496 }
497 }
498
499 #[test]
500 fn shared_layout_follows_the_tail_and_places_the_cursor() {
501 let mut transcript = TranscriptModel::default();
502 for sequence in 1..=40 {
503 transcript.apply_event(&FrontendEvent {
504 sequence,
505 kind: "text_delta".into(),
506 payload: json!({"type":"text_delta", "text":format!("line-{sequence}\n")}),
507 });
508 }
509 let mut composer = ComposerModel::new(&descriptor());
510 composer.set_input("hello");
511 let backend = TestBackend::new(40, 12);
512 let mut terminal = Terminal::new(backend).unwrap();
513 terminal
514 .draw(|frame| render(frame, &transcript, &composer))
515 .unwrap();
516 let contents = terminal.backend().to_string();
517 assert!(contents.contains("line-40"), "{contents}");
518 assert!(contents.contains("hello"), "{contents}");
519 }
520
521 #[test]
522 fn ctrl_d_and_detach_command_are_local_and_never_become_runtime_actions() {
523 let mut composer = ComposerModel::new(&descriptor());
524 assert!(local_detach_requested(
525 &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
526 &composer
527 ));
528 assert!(local_detach_requested(
529 &KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::NONE),
530 &composer
531 ));
532 composer.set_input("/detach");
533 assert!(local_detach_requested(
534 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
535 &composer
536 ));
537 assert_eq!(composer.input(), "/detach");
538 }
539
540 #[test]
541 fn interrupted_submit_is_a_turn_outcome_not_submit_failure() {
542 let interrupted = Err(FrontendRuntimeError::Submit(
543 supercode::server::RuntimeSubmitError::Interrupted,
544 ));
545 assert!(!should_record_action_failure("submit", &interrupted));
546 assert!(should_record_action_failure("steer", &interrupted));
547 }
548
549 #[test]
550 fn unknown_line_fallback_is_bounded_and_sanitized() {
551 let summary = unknown_line_event_summary(&FrontendEvent {
552 sequence: 1,
553 kind: format!("\u{1b}]0;owned\u{7}{}", "x".repeat(10_000)),
554 payload: json!({"nested":"y".repeat(10_000)}),
555 });
556 assert!(summary.chars().count() < 200, "{}", summary.len());
557 assert!(!summary.contains('\u{1b}'), "{summary:?}");
558 assert!(!summary.contains("nested"), "{summary}");
559 }
560
561 #[test]
562 fn real_app_render_path_preserves_hyperlink_annotations() {
563 let mut transcript = TranscriptModel::default();
564 transcript.apply_event(&FrontendEvent {
565 sequence: 1,
566 kind: "text_delta".into(),
567 payload: json!({"text":"See https://example.com/path"}),
568 });
569 let composer = ComposerModel::new(&descriptor());
570 let backend = TestBackend::new(50, 10);
571 let mut terminal = Terminal::new(backend).unwrap();
572 terminal
573 .draw(|frame| render(frame, &transcript, &composer))
574 .unwrap();
575 let symbols = terminal
576 .backend()
577 .buffer()
578 .content()
579 .iter()
580 .map(|cell| cell.symbol())
581 .collect::<String>();
582 assert!(
583 symbols.contains("\x1b]8;;https://example.com/path"),
584 "{symbols:?}"
585 );
586 }
587}