Skip to main content

supercode_frontend_tui/
app.rs

1//! One full-screen human frontend for every SDK runtime adapter.
2
3use 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/// Why one human frontend stopped. Detach and disconnect never close the
25/// SDK-owned runtime.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum FrontendExit {
28    Detached,
29    Disconnected,
30}
31
32/// Full-screen frontend failure, retaining a primary failure when terminal
33/// restoration also fails.
34#[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
86/// Run the canonical full-screen frontend over a local or remote adapter.
87pub 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
106/// Generic human line frontend for TUI-disabled and non-TTY attachments.
107/// It consumes the same typed runtime contract and never dumps opaque JSON.
108pub 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}