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_harness::frontend::{FrontendRuntime, FrontendRuntimeError};
14use supercode_harness::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_harness::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(
188 event: &supercode_harness::frontend::FrontendEvent,
189) -> Result<(), FrontendAppError> {
190 let payload = &event.payload;
191 match event.kind.as_str() {
192 "user_message" => {}
193 "text_delta" => {
194 write!(
195 io::stdout(),
196 "{}",
197 sanitize_terminal_text(
198 payload
199 .get("text")
200 .and_then(serde_json::Value::as_str)
201 .unwrap_or_default()
202 )
203 )
204 .map_err(FrontendAppError::primary)?;
205 io::stdout().flush().map_err(FrontendAppError::primary)?;
206 }
207 "turn_succeeded" => {
208 writeln!(io::stdout()).map_err(FrontendAppError::primary)?;
209 }
210 "turn_interrupted" => {
211 writeln!(io::stdout(), "\n[turn interrupted]").map_err(FrontendAppError::primary)?;
212 }
213 "turn_failed" => {
214 let message = bounded_terminal_metadata(
215 payload
216 .get("message")
217 .and_then(serde_json::Value::as_str)
218 .unwrap_or("runtime error"),
219 256,
220 );
221 writeln!(io::stdout(), "\n[turn failed: {message}]")
222 .map_err(FrontendAppError::primary)?;
223 }
224 "tool_call_started" => {
225 let name = bounded_terminal_metadata(
226 payload
227 .get("name")
228 .and_then(serde_json::Value::as_str)
229 .unwrap_or("tool"),
230 256,
231 );
232 writeln!(io::stdout(), "[working: {name}]").map_err(FrontendAppError::primary)?;
233 }
234 "tool_call_completed" => {
235 let name = bounded_terminal_metadata(
236 payload
237 .get("name")
238 .and_then(serde_json::Value::as_str)
239 .unwrap_or("tool"),
240 256,
241 );
242 let status =
243 if payload.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
244 "failed"
245 } else {
246 "done"
247 };
248 writeln!(io::stdout(), "[{name}: {status}]").map_err(FrontendAppError::primary)?;
249 }
250 "request" => {
251 writeln!(
252 io::stdout(),
253 "[runtime input requested; attach from a TTY to respond]"
254 )
255 .map_err(FrontendAppError::primary)?;
256 }
257 _ => {
258 writeln!(io::stdout(), "{}", unknown_line_event_summary(event))
259 .map_err(FrontendAppError::primary)?;
260 }
261 }
262 Ok(())
263}
264
265async fn run_attached(
266 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
267 mut view: TerminalRuntimeView,
268) -> Result<FrontendExit, FrontendAppError> {
269 let mut transcript = view.transcript_model();
270 let mut composer = ComposerModel::new(view.descriptor());
271 while let Some(event) = view.next_replay_event() {
272 transcript.apply_event(&event);
273 composer.apply_event(&event);
274 }
275 let controller = view.controller();
276 let mut input = EventStream::new();
277 let (completion_tx, mut completion_rx) = tokio::sync::mpsc::unbounded_channel();
278
279 loop {
280 terminal
281 .draw(|frame| render(frame, &transcript, &composer))
282 .map_err(FrontendAppError::primary)?;
283 tokio::select! {
284 event = view.next_event() => {
285 match event {
286 Ok(event) if event.kind == "runtime_disconnected" => {
287 transcript.apply_event(&event);
288 composer.apply_event(&event);
289 terminal.draw(|frame| render(frame, &transcript, &composer))
290 .map_err(FrontendAppError::primary)?;
291 return Ok(FrontendExit::Disconnected);
292 }
293 Ok(event) => {
294 transcript.apply_event(&event);
295 composer.apply_event(&event);
296 }
297 Err(FrontendRuntimeError::Closed) => return Ok(FrontendExit::Disconnected),
298 Err(error) => return Err(FrontendAppError::primary(error)),
299 }
300 }
301 event = input.next() => {
302 let Some(event) = event else {
303 return Ok(FrontendExit::Detached);
304 };
305 let event = event.map_err(FrontendAppError::primary)?;
306 if let crossterm::event::Event::Key(key) = &event {
307 if local_detach_requested(key, &composer) {
308 return Ok(FrontendExit::Detached);
309 }
310 if key.code == KeyCode::Char('\u{3}')
311 && matches!(composer.turn_state(), ComposerTurnState::Working { .. })
312 && composer.capabilities().can_interrupt
313 {
314 spawn_action(
315 controller.clone(),
316 ComposerAction::Interrupt,
317 completion_tx.clone(),
318 );
319 continue;
320 }
321 }
322 if let Some(event) = map_crossterm_event(event) {
323 if let Some(action) = composer.handle_terminal_event(event) {
324 spawn_action(controller.clone(), action, completion_tx.clone());
325 }
326 }
327 }
328 completion = completion_rx.recv() => {
329 let Some(completion) = completion else { continue; };
330 match completion {
331 ActionCompletion::Simple { action, result } => {
332 if should_record_action_failure(action, &result) {
333 composer.record_dispatch_failure(action);
334 }
335 }
336 ActionCompletion::Respond { request, resolution, result } => {
337 if result.is_ok() {
338 transcript.resolve_frontend_request(request.id, &resolution);
339 } else {
340 composer.restore_request(request);
341 }
342 }
343 }
344 }
345 }
346 }
347}
348
349fn unknown_line_event_summary(event: &supercode_harness::frontend::FrontendEvent) -> String {
350 format!(
351 "[event: {}; details unavailable in line mode]",
352 bounded_terminal_metadata(&event.kind, 128)
353 )
354}
355
356fn should_record_action_failure(action: &str, result: &Result<(), FrontendRuntimeError>) -> bool {
357 result.is_err()
358 && !(action == "submit"
359 && result
360 .as_ref()
361 .err()
362 .is_some_and(is_interrupted_submit_error))
363}
364
365fn local_detach_requested(key: &crossterm::event::KeyEvent, composer: &ComposerModel) -> bool {
366 if composer.overlay().is_some() {
367 return false;
368 }
369 let typed_detach = (key.code == KeyCode::Char('d')
370 && key.modifiers.contains(KeyModifiers::CONTROL))
371 || key.code == KeyCode::Char('\u{4}');
372 (typed_detach && composer.input().is_empty())
373 || (key.code == KeyCode::Enter
374 && matches!(composer.input().trim(), "/detach" | "/quit" | "/exit"))
375}
376
377fn spawn_action(
378 runtime: Arc<dyn FrontendRuntime>,
379 action: ComposerAction,
380 completed: tokio::sync::mpsc::UnboundedSender<ActionCompletion>,
381) {
382 tokio::spawn(async move {
383 let completion = match action {
384 ComposerAction::Submit(prompt) => ActionCompletion::Simple {
385 action: "submit",
386 result: runtime.submit(prompt).await.map(|_| ()),
387 },
388 ComposerAction::Invoke(operation) => ActionCompletion::Simple {
389 action: "invoke",
390 result: runtime.invoke(operation).await.map(|_| ()),
391 },
392 ComposerAction::Steer(prompt) => ActionCompletion::Simple {
393 action: "steer",
394 result: runtime.steer(prompt).await,
395 },
396 ComposerAction::Interrupt => ActionCompletion::Simple {
397 action: "interrupt",
398 result: runtime.interrupt().await.map(|_| ()),
399 },
400 ComposerAction::Respond {
401 response,
402 request,
403 resolution,
404 } => ActionCompletion::Respond {
405 request,
406 resolution,
407 result: runtime.respond(response).await,
408 },
409 };
410 let _ = completed.send(completion);
411 });
412}
413
414fn render(frame: &mut Frame<'_>, transcript: &TranscriptModel, composer: &ComposerModel) {
415 let area = frame.area();
416 let colors = terminal_colors();
417 let composer_renderer = ComposerRenderer::new(composer, colors);
418 let composer_height = composer_renderer
419 .desired_height(area.width)
420 .min(area.height.saturating_sub(1));
421 let areas = Layout::default()
422 .direction(Direction::Vertical)
423 .constraints([
424 Constraint::Min(1),
425 Constraint::Length(composer_height.max(1)),
426 ])
427 .split(area);
428
429 TranscriptRenderer::new(transcript, colors).render_tail_buffer(areas[0], frame.buffer_mut());
430 let input_cursor = composer_renderer.cursor_position(areas[1].width);
431 frame.render_widget(composer_renderer, areas[1]);
432 if composer.overlay().is_none() {
433 let (x, y) = composer_cursor(areas[1], input_cursor);
434 frame.set_cursor_position((x, y));
435 }
436}
437
438fn composer_cursor(area: Rect, (row, column): (usize, usize)) -> (u16, u16) {
439 let x = area
440 .x
441 .saturating_add(2)
442 .saturating_add(column as u16)
443 .min(area.right().saturating_sub(1));
444 let y = area
445 .y
446 .saturating_add(1)
447 .saturating_add(row as u16)
448 .min(area.bottom().saturating_sub(2));
449 (x, y)
450}
451
452fn terminal_colors() -> ColorCapabilities {
453 ColorCapabilities::resolve(
454 std::env::var_os("NO_COLOR").is_some(),
455 std::env::var("TERM").ok().as_deref(),
456 std::env::var("COLORTERM").ok().as_deref(),
457 false,
458 )
459}
460
461#[cfg(test)]
462mod tests {
463 use crossterm::event::KeyEvent;
464 use ratatui::backend::TestBackend;
465 use serde_json::json;
466 use supercode_harness::frontend::{
467 FrontendActions, FrontendConnectionState, FrontendDisplayCapabilities, FrontendEvent,
468 FrontendRuntimeDescriptor, FrontendTurnState, FRONTEND_RUNTIME_SCHEMA_VERSION,
469 };
470
471 use super::*;
472
473 fn descriptor() -> FrontendRuntimeDescriptor {
474 FrontendRuntimeDescriptor {
475 schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
476 session_id: "shared".into(),
477 source_harness: None,
478 emulation_profile: None,
479 active_modules: vec!["tui".into()],
480 commands: vec![],
481 operations: vec![],
482 actions: FrontendActions {
483 submit: true,
484 interrupt: true,
485 steer: true,
486 respond: true,
487 detach: true,
488 close: false,
489 },
490 display: FrontendDisplayCapabilities {
491 event_kinds: vec![],
492 opaque_fallback: true,
493 },
494 model: "test".into(),
495 turn_state: FrontendTurnState::Idle,
496 connection_state: FrontendConnectionState::Connected,
497 extensions: Default::default(),
498 }
499 }
500
501 #[test]
502 fn shared_layout_follows_the_tail_and_places_the_cursor() {
503 let mut transcript = TranscriptModel::default();
504 for sequence in 1..=40 {
505 transcript.apply_event(&FrontendEvent {
506 sequence,
507 kind: "text_delta".into(),
508 payload: json!({"type":"text_delta", "text":format!("line-{sequence}\n")}),
509 });
510 }
511 let mut composer = ComposerModel::new(&descriptor());
512 composer.set_input("hello");
513 let backend = TestBackend::new(40, 12);
514 let mut terminal = Terminal::new(backend).unwrap();
515 terminal
516 .draw(|frame| render(frame, &transcript, &composer))
517 .unwrap();
518 let contents = terminal.backend().to_string();
519 assert!(contents.contains("line-40"), "{contents}");
520 assert!(contents.contains("hello"), "{contents}");
521 }
522
523 #[test]
524 fn ctrl_d_and_detach_command_are_local_and_never_become_runtime_actions() {
525 let mut composer = ComposerModel::new(&descriptor());
526 assert!(local_detach_requested(
527 &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
528 &composer
529 ));
530 assert!(local_detach_requested(
531 &KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::NONE),
532 &composer
533 ));
534 composer.set_input("/detach");
535 assert!(local_detach_requested(
536 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
537 &composer
538 ));
539 assert_eq!(composer.input(), "/detach");
540 }
541
542 #[test]
543 fn interrupted_submit_is_a_turn_outcome_not_submit_failure() {
544 let interrupted = Err(FrontendRuntimeError::Submit(
545 supercode_harness::server::RuntimeSubmitError::Interrupted,
546 ));
547 assert!(!should_record_action_failure("submit", &interrupted));
548 assert!(should_record_action_failure("steer", &interrupted));
549 }
550
551 #[test]
552 fn unknown_line_fallback_is_bounded_and_sanitized() {
553 let summary = unknown_line_event_summary(&FrontendEvent {
554 sequence: 1,
555 kind: format!("\u{1b}]0;owned\u{7}{}", "x".repeat(10_000)),
556 payload: json!({"nested":"y".repeat(10_000)}),
557 });
558 assert!(summary.chars().count() < 200, "{}", summary.len());
559 assert!(!summary.contains('\u{1b}'), "{summary:?}");
560 assert!(!summary.contains("nested"), "{summary}");
561 }
562
563 #[test]
564 fn real_app_render_path_preserves_hyperlink_annotations() {
565 let mut transcript = TranscriptModel::default();
566 transcript.apply_event(&FrontendEvent {
567 sequence: 1,
568 kind: "text_delta".into(),
569 payload: json!({"text":"See https://example.com/path"}),
570 });
571 let composer = ComposerModel::new(&descriptor());
572 let backend = TestBackend::new(50, 10);
573 let mut terminal = Terminal::new(backend).unwrap();
574 terminal
575 .draw(|frame| render(frame, &transcript, &composer))
576 .unwrap();
577 let symbols = terminal
578 .backend()
579 .buffer()
580 .content()
581 .iter()
582 .map(|cell| cell.symbol())
583 .collect::<String>();
584 assert!(
585 symbols.contains("\x1b]8;;https://example.com/path"),
586 "{symbols:?}"
587 );
588 }
589}