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_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/// 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_harness::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(
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}