diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a92543f9..1aa1eff7 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -8,56 +8,23 @@ mod multi_pod; mod picker; mod pod_list; mod scroll; +mod single_pod; mod spawn; mod task; mod tool; mod ui; mod view_mode; -use std::future::Future; use std::io; use std::path::PathBuf; use std::process::ExitCode; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; -use std::thread; -use std::time::Duration; -use crossterm::event::{ - self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, -}; +use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste}; use crossterm::execute; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; -use protocol::{Method, PodStatus}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; +use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use session_store::SegmentId; -use tokio::sync::mpsc; -use client::{PodClient, PodRuntimeCommand}; - -use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; -use crate::picker::PickerOutcome; -use crate::spawn::{SpawnOutcome, SpawnReady}; - -type FullscreenTerminal = Terminal>; - -fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { - if let Some(p) = override_path { - return p; - } - manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| { - PathBuf::from("/tmp") - .join("insomnia") - .join(pod_name) - .join("sock") - }) -} +use client::PodRuntimeCommand; #[derive(Debug, Clone)] pub struct LaunchOptions { @@ -106,14 +73,18 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } let result = match mode { - LaunchMode::Spawn { profile } => run_spawn(None, profile, runtime_command).await, + LaunchMode::Spawn { profile } => { + single_pod::run_spawn(None, profile, runtime_command).await + } LaunchMode::PodName { pod_name, socket_override, - } => run_pod_name(pod_name, socket_override, runtime_command).await, - LaunchMode::Resume => run_resume(runtime_command).await, - LaunchMode::ResumeWithSession(id) => run_spawn(Some(id), None, runtime_command).await, - LaunchMode::Multi => run_multi(runtime_command).await, + } => single_pod::run_pod_name(pod_name, socket_override, runtime_command).await, + LaunchMode::Resume => single_pod::run_resume(runtime_command).await, + LaunchMode::ResumeWithSession(id) => { + single_pod::run_spawn(Some(id), None, runtime_command).await + } + LaunchMode::Multi => single_pod::run_multi(runtime_command).await, }; // Always restore the terminal first so any pending eprintln below @@ -144,1722 +115,3 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } } } - -async fn run_pod_name( - pod_name: String, - socket_override: Option, - runtime_command: PodRuntimeCommand, -) -> Result<(), Box> { - if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { - let mut terminal = enter_fullscreen()?; - run_connected_pod(&mut terminal, pod_name, client).await?; - return Ok(()); - } - - let ready = match spawn::run_pod_name(pod_name, runtime_command).await? { - SpawnOutcome::Ready(r) => r, - SpawnOutcome::Cancelled => return Ok(()), - }; - let mut terminal = enter_fullscreen()?; - terminal.clear()?; - let result = run_ready_pod(&mut terminal, ready).await; - let _ = leave_fullscreen(&mut terminal); - result -} - -async fn run_connected_pod( - terminal: &mut FullscreenTerminal, - pod_name: String, - client: PodClient, -) -> Result<(), Box> { - let mut app = App::new(pod_name); - app.connected = true; - run_loop(terminal, &mut app, client).await -} - -async fn run_pod_name_nested( - terminal: &mut FullscreenTerminal, - request: multi_pod::OpenPodRequest, - runtime_command: PodRuntimeCommand, -) -> Result<(), Box> { - let multi_pod::OpenPodRequest { - pod_name, - socket_override, - } = request; - - if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await { - return run_connected_pod(terminal, pod_name, client).await; - } - - let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command).await?; - run_ready_pod(terminal, ready).await -} - -async fn spawn_pod_name_from_fullscreen( - terminal: &mut FullscreenTerminal, - pod_name: &str, - runtime_command: PodRuntimeCommand, -) -> Result> { - leave_fullscreen(terminal)?; - let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await; - enter_fullscreen_existing(terminal)?; - terminal.clear()?; - - match outcome? { - SpawnOutcome::Ready(ready) => Ok(ready), - SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)), - } -} - -async fn try_connect_live_pod( - pod_name: &str, - socket_override: Option, -) -> Option { - let preferred_socket = resolve_socket(pod_name, socket_override.clone()); - connect_live_pod(pod_name, preferred_socket, socket_override.is_none()) - .await - .map(|(_, client)| client) -} - -#[derive(Debug)] -struct NestedOpenCancelled; - -impl std::fmt::Display for NestedOpenCancelled { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Pod open was cancelled") - } -} - -impl std::error::Error for NestedOpenCancelled {} - -async fn run_ready_pod( - terminal: &mut FullscreenTerminal, - ready: SpawnReady, -) -> Result<(), Box> { - let SpawnReady { - pod_name, - socket_path, - } = ready; - run(terminal, pod_name, &socket_path).await -} - -async fn connect_live_pod( - pod_name: &str, - preferred_socket: PathBuf, - allow_registry_fallback: bool, -) -> Option<(PathBuf, PodClient)> { - if let Ok(client) = PodClient::connect(&preferred_socket).await { - return Some((preferred_socket, client)); - } - - if !allow_registry_fallback { - return None; - } - let registry_socket = picker::live_socket_for_pod(pod_name)?; - if registry_socket == preferred_socket { - return None; - } - PodClient::connect(®istry_socket) - .await - .ok() - .map(|client| (registry_socket, client)) -} - -async fn run_resume(runtime_command: PodRuntimeCommand) -> Result<(), Box> { - // Pick a Pod in its own inline viewport, dropping the viewport before - // attaching/restoring so each phase gets fresh vertical room. - let (pod_name, socket_override) = match picker::run().await? { - PickerOutcome::Picked { - pod_name, - socket_override, - } => (pod_name, socket_override), - PickerOutcome::Cancelled => return Ok(()), - }; - run_pod_name(pod_name, socket_override, runtime_command).await -} - -async fn run_multi(runtime_command: PodRuntimeCommand) -> Result<(), Box> { - let mut app = multi_pod::load_app().await?; - let mut terminal = enter_fullscreen()?; - - loop { - match multi_pod::run(&mut terminal, &mut app).await? { - multi_pod::MultiPodOutcome::Quit => { - let _ = leave_fullscreen(&mut terminal); - return Ok(()); - } - multi_pod::MultiPodOutcome::Open(request) => { - let pod_name = request.pod_name.clone(); - match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await { - Ok(()) => app.finish_open(&pod_name, Ok(())), - Err(error) if is_recoverable_multi_open_error(error.as_ref()) => { - app.finish_open(&pod_name, Err(error.as_ref())); - } - Err(error) => { - let _ = leave_fullscreen(&mut terminal); - return Err(error); - } - } - app.reload_or_notice().await; - } - } - } -} - -fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool { - error.is::() || error.is::() -} - -async fn run_spawn( - resume_from: Option, - profile: Option, - runtime_command: PodRuntimeCommand, -) -> Result<(), Box> { - let ready = match spawn::run(resume_from, profile, runtime_command).await? { - SpawnOutcome::Ready(r) => r, - SpawnOutcome::Cancelled => return Ok(()), - }; - - let SpawnReady { - pod_name, - socket_path, - } = ready; - - let mut terminal = enter_fullscreen()?; - let result = run(&mut terminal, pod_name, &socket_path).await; - - // Leave alt-screen explicitly before `main`'s terminal restore path. - let _ = execute!( - terminal.backend_mut(), - DisableMouseCapture, - LeaveAlternateScreen - ); - - result -} - -fn enter_fullscreen() -> Result> { - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - Ok(Terminal::new(backend)?) -} - -fn enter_fullscreen_existing( - terminal: &mut FullscreenTerminal, -) -> Result<(), Box> { - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - Ok(()) -} - -fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> { - execute!( - terminal.backend_mut(), - DisableMouseCapture, - LeaveAlternateScreen - ) -} - -async fn run( - terminal: &mut FullscreenTerminal, - pod_name: String, - socket_path: &std::path::Path, -) -> Result<(), Box> { - let mut app = App::new(pod_name); - - match PodClient::connect(socket_path).await { - Ok(client) => { - app.connected = true; - // The Pod sends `Event::Snapshot` automatically on connect; - // no explicit method call is required to fetch history. - run_loop(terminal, &mut app, client).await?; - } - Err(e) => { - app.push_error(format!( - "Failed to connect to {}: {e}", - socket_path.display() - )); - terminal.draw(|f| ui::draw(f, &mut app))?; - run_disconnected(&mut app)?; - } - } - Ok(()) -} - -type TerminalEventResult = io::Result; - -const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50); -const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64; -const POD_EVENT_DRAIN_LIMIT: usize = 32; - -struct TerminalEventReader { - stop: Arc, - thread: Option>, -} - -impl TerminalEventReader { - fn spawn() -> io::Result<(Self, mpsc::UnboundedReceiver)> { - let (tx, rx) = mpsc::unbounded_channel(); - let stop = Arc::new(AtomicBool::new(false)); - let thread_stop = Arc::clone(&stop); - let thread = thread::Builder::new() - .name("insomnia-tui-terminal-reader".to_string()) - .spawn(move || read_terminal_events(thread_stop, tx))?; - - Ok(( - Self { - stop, - thread: Some(thread), - }, - rx, - )) - } -} - -impl Drop for TerminalEventReader { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender) { - while !stop.load(Ordering::Relaxed) { - match event::poll(TERMINAL_POLL_INTERVAL) { - Ok(false) => {} - Ok(true) => { - let event = event::read(); - let should_stop = event.is_err(); - if tx.send(event).is_err() || should_stop { - break; - } - } - Err(e) => { - let _ = tx.send(Err(e)); - break; - } - } - } -} - -enum LoopInput

{ - Terminal(TerminalEventResult), - Pod(Option

), -} - -async fn next_loop_input( - term_rx: &mut mpsc::UnboundedReceiver, - connected: bool, - pod_next: F, -) -> LoopInput

-where - F: Future>, -{ - tokio::select! { - biased; - - term_event = term_rx.recv() => { - LoopInput::Terminal(term_event.unwrap_or_else(|| { - Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "terminal event reader stopped", - )) - })) - } - event = pod_next, if connected => LoopInput::Pod(event), - } -} - -async fn drain_terminal_events( - app: &mut App, - client: &mut PodClient, - term_rx: &mut mpsc::UnboundedReceiver, -) -> Result> { - let mut handled = false; - for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { - match term_rx.try_recv() { - Ok(event) => { - handled = true; - handle_terminal_event(app, client, event?).await?; - if app.quit { - break; - } - } - Err(mpsc::error::TryRecvError::Empty) => break, - Err(mpsc::error::TryRecvError::Disconnected) => { - return Err(Box::new(io::Error::new( - io::ErrorKind::UnexpectedEof, - "terminal event reader stopped", - ))); - } - } - } - Ok(handled) -} - -async fn drain_pod_events( - app: &mut App, - client: &mut PodClient, -) -> Result> { - let mut handled = false; - for _ in 0..POD_EVENT_DRAIN_LIMIT { - match client.try_next_event() { - Some(ev) => { - handled = true; - if let Some(method) = app.handle_pod_event(ev) { - client.send(&method).await?; - } - } - None => break, - } - } - Ok(handled) -} - -async fn run_loop( - terminal: &mut Terminal>, - app: &mut App, - mut client: PodClient, -) -> Result<(), Box> { - let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; - - terminal.draw(|f| ui::draw(f, app))?; - - loop { - if app.quit { - break; - } - - let handled_term_event = drain_terminal_events(app, &mut client, &mut term_rx).await?; - if app.quit { - break; - } - let handled_pod_event = drain_pod_events(app, &mut client).await?; - if handled_term_event || handled_pod_event { - terminal.draw(|f| ui::draw(f, app))?; - continue; - } - - match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { - LoopInput::Terminal(term_event) => { - handle_terminal_event(app, &mut client, term_event?).await?; - } - LoopInput::Pod(event) => match event { - Some(ev) => { - if let Some(method) = app.handle_pod_event(ev) { - client.send(&method).await?; - } - } - None => { - app.connected = false; - app.mark_orphan_compacts_incomplete(); - app.push_error("Connection lost"); - } - }, - } - - terminal.draw(|f| ui::draw(f, app))?; - } - - Ok(()) -} - -async fn handle_terminal_event( - app: &mut App, - client: &mut PodClient, - event: TermEvent, -) -> Result<(), Box> { - match event { - TermEvent::Key(key) => { - if let Some(method) = handle_key(app, key) { - client.send(&method).await?; - } - } - TermEvent::Mouse(mouse) => { - handle_mouse(app, mouse); - } - TermEvent::Paste(s) => { - app.insert_paste(s); - } - TermEvent::Resize(_, _) => { - // No-op: next draw repaints in full. - } - _ => {} - } - Ok(()) -} - -fn run_disconnected(_app: &mut App) -> Result<(), Box> { - loop { - if event::poll(std::time::Duration::from_millis(100))? - && let TermEvent::Key(key) = event::read()? - && let KeyCode::Char('c') = key.code - && key.modifiers.contains(KeyModifiers::CONTROL) - { - break; - } - } - Ok(()) -} - -/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so -/// hand-rolling through long histories isn't tedious, but slow enough -/// that a single notch doesn't blow past the section the user is -/// looking for. -const WHEEL_LINES: usize = 3; - -/// Lines to advance per PageUp / PageDown when the task side pane is -/// open. Calibrated so a couple of presses moves through one entry's -/// subject + description block. -const PANE_SCROLL_LINES: usize = 5; - -fn handle_mouse(app: &mut App, mouse: MouseEvent) { - match mouse.kind { - MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES), - MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES), - _ => {} - } -} - -fn handle_key(app: &mut App, key: KeyEvent) -> Option { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let shift = key.modifiers.contains(KeyModifiers::SHIFT); - let alt = key.modifiers.contains(KeyModifiers::ALT); - - // Modifier-key bindings. - if let Some(method) = match key.code { - KeyCode::Up if shift => { - app.scroll.scroll_up(1); - Some(None) - } - KeyCode::Down if shift => { - app.scroll.scroll_down(1); - Some(None) - } - KeyCode::Home if ctrl => { - app.scroll.to_top(); - Some(None) - } - KeyCode::End if ctrl => { - app.scroll.to_bottom(); - Some(None) - } - KeyCode::Char('[') if ctrl => { - app.scroll.jump_prev_turn(); - Some(None) - } - KeyCode::Char(']') if ctrl => { - app.scroll.jump_next_turn(); - Some(None) - } - KeyCode::Char('o') if ctrl => { - app.mode = app.mode.cycle(); - Some(None) - } - KeyCode::Char('t') if ctrl => { - app.toggle_task_pane(); - Some(None) - } - KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => { - Some(app.request_rewind_picker()) - } - KeyCode::Char('a') if ctrl => { - app.move_cursor_start(); - Some(app.refresh_completion()) - } - KeyCode::Char('u') if ctrl && app.is_command_mode() => { - app.clear_command_input(); - Some(None) - } - KeyCode::Char(c) - if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() => - { - if app.restore_next_queued_input_to_composer() { - Some(app.refresh_completion()) - } else { - Some(None) - } - } - KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => { - app.clear_queued_inputs(); - Some(None) - } - KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), - KeyCode::Char('x') if ctrl => Some(match app.pod_status { - PodStatus::Running => { - app.clear_queued_inputs(); - Some(Method::Cancel) - } - PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown), - }), - KeyCode::Char('d') if ctrl => { - app.quit = true; - Some(None) - } - KeyCode::Enter if alt => { - if app.is_command_mode() { - Some(None) - } else { - app.insert_newline(); - Some(app.refresh_completion()) - } - } - _ => None, - } { - return method; - } - - // Unbound Ctrl+Char keys are ignored before the text-input path so - // holding Ctrl while typing never inserts control characters. - if ctrl && matches!(key.code, KeyCode::Char(_)) { - return None; - } - - // Scroll / navigation. PageUp / PageDown defaults to history; while - // the task pane is open it scrolls the pane instead so the user can - // browse past entries without first closing the pane. - match key.code { - KeyCode::PageUp => { - if app.task_pane_open { - app.scroll_task_pane_up(PANE_SCROLL_LINES); - } else { - app.scroll.page_up(); - } - return None; - } - KeyCode::PageDown => { - if app.task_pane_open { - app.scroll_task_pane_down(PANE_SCROLL_LINES); - } else { - app.scroll.page_down(); - } - return None; - } - _ => {} - } - - if app.is_command_mode() { - return handle_command_key(app, key); - } - - if app.rewind_picker.is_some() { - match key.code { - KeyCode::Esc => { - app.close_rewind_picker(); - return None; - } - KeyCode::Enter => return app.submit_rewind_picker(), - KeyCode::Up => { - app.rewind_picker_up(); - return None; - } - KeyCode::Down => { - app.rewind_picker_down(); - return None; - } - _ => {} - } - } - - // Completion popup overrides — only when there's something to - // navigate / commit. An empty popup (request in flight) falls - // through to the default behaviour. - if app.completion.as_ref().is_some_and(|c| c.is_active()) { - match key.code { - KeyCode::Tab if !alt => { - // Insert the selected entry as raw text and let the - // re-triggered popup fetch fresh candidates (drill-in - // for directories, narrow-to-exact for files). - return app.apply_completion_text(); - } - KeyCode::Enter if !alt => { - // While the popup has selectable entries, Enter - // commits the selection rather than submitting the - // message. The selected entry wins regardless of how - // much of its value the user has typed — Enter on a - // popup entry is "accept this suggestion". Directory - // entries are the exception: they fall through to - // text insertion so the popup re-fetches children - // for drill-in. After a successful chip we append a - // trailing space so the user can keep writing without - // a manual separator (the Space path already has the - // space the user typed, so it's not needed there). - if app.chipify_selected_completion_if_committable() { - app.insert_char(' '); - return None; - } - return app.apply_completion_text(); - } - KeyCode::Up => { - app.move_completion_up(); - return None; - } - KeyCode::Down => { - app.move_completion_down(); - return None; - } - KeyCode::Esc => { - app.cancel_completion(); - return None; - } - _ => {} - } - } - - match key.code { - KeyCode::Esc => { - // Close the popup if it's still showing (covers the - // request-in-flight case where `is_active()` was false). - app.cancel_completion(); - None - } - KeyCode::Enter => app.submit_input(), - KeyCode::Backspace => { - app.delete_char_before(); - app.refresh_completion() - } - KeyCode::Delete => { - app.delete_char_after(); - app.refresh_completion() - } - KeyCode::Left => { - app.move_cursor_left(); - app.refresh_completion() - } - KeyCode::Right => { - app.move_cursor_right(); - app.refresh_completion() - } - KeyCode::Up => { - if app.can_browse_input_history_older() && app.browse_input_history_older() { - app.refresh_completion() - } else { - app.move_cursor_up(); - app.refresh_completion() - } - } - KeyCode::Down => { - if app.can_browse_input_history_newer() && app.browse_input_history_newer() { - app.refresh_completion() - } else { - app.move_cursor_down(); - app.refresh_completion() - } - } - KeyCode::Home => { - app.move_cursor_home(); - app.refresh_completion() - } - KeyCode::End => { - app.move_cursor_end(); - app.refresh_completion() - } - KeyCode::Char(':') if !alt && app.input.is_empty() => { - app.enter_command_mode(); - None - } - KeyCode::Char(c) => { - // Whitespace ends an in-flight completion token. Try the - // auto-confirm path first so an exact match (e.g. typed - // `@src/main.rs` matches the only popup entry) becomes a - // chip on the way out. Directories also commit here — - // ending with a space is an explicit "I want this dir" - // signal, not a drill-in. - if c.is_whitespace() { - app.chipify_completion_if_exact_match(); - } - app.insert_char(c); - app.refresh_completion() - } - _ => None, - } -} - -fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { - match key.code { - KeyCode::Esc => { - app.exit_command_mode(); - None - } - KeyCode::Enter => app.submit_command_with_completion(), - KeyCode::Backspace => { - if app.command_text().is_empty() { - app.exit_command_mode(); - } else { - app.delete_char_before(); - } - None - } - KeyCode::Delete => { - app.delete_char_after(); - None - } - KeyCode::Left => { - app.move_cursor_left(); - None - } - KeyCode::Right => { - app.move_cursor_right(); - None - } - KeyCode::Up => { - if app.command_completion_active() { - app.move_command_completion_up(); - } else { - app.move_cursor_up(); - } - None - } - KeyCode::Down => { - if app.command_completion_active() { - app.move_command_completion_down(); - } else { - app.move_cursor_down(); - } - None - } - KeyCode::Home => { - app.move_cursor_home(); - None - } - KeyCode::End => { - app.move_cursor_end(); - None - } - KeyCode::Tab => { - app.apply_command_completion(); - None - } - KeyCode::Char(c) => { - if key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - { - return None; - } - app.insert_char(c); - None - } - _ => None, - } -} - -const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); - -/// Running → send `Method::Pause`. -/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running). -fn handle_pause_or_quit(app: &mut App) -> Option { - if app.pod_status == PodStatus::Running { - app.clear_queued_inputs(); - return Some(Method::Pause); - } - if let Some(t) = app.quit_confirm - && t.elapsed() < CONFIRM_TIMEOUT - { - app.quit_confirm = None; - app.quit = true; - return None; - } - app.quit_confirm = Some(std::time::Instant::now()); - app.flash_actionbar_notice( - "Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).", - ActionbarNoticeLevel::Warn, - ActionbarNoticeSource::Tui, - CONFIRM_TIMEOUT, - ); - None -} - -#[cfg(test)] -mod tests { - use super::*; - use protocol::{Event, RewindTarget, RewindTargetId, Segment}; - - #[tokio::test] - async fn terminal_event_is_selected_before_ready_pod_event() { - let (tx, mut rx) = mpsc::unbounded_channel(); - tx.send(Ok(TermEvent::Key(KeyEvent::new( - KeyCode::Char('x'), - KeyModifiers::NONE, - )))) - .unwrap(); - - match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await { - LoopInput::Terminal(Ok(TermEvent::Key(key))) => { - assert_eq!(key.code, KeyCode::Char('x')); - } - _ => panic!("ready terminal input should win over a ready Pod event"), - } - } - - #[tokio::test] - async fn terminal_event_is_preserved_after_pod_event_wins() { - let (tx, mut rx) = mpsc::unbounded_channel(); - - match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await { - LoopInput::Pod(Some(1)) => {} - _ => panic!("expected the first ready Pod event to win before any terminal input"), - } - - tx.send(Ok(TermEvent::Key(KeyEvent::new( - KeyCode::Char('y'), - KeyModifiers::NONE, - )))) - .unwrap(); - - match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await { - LoopInput::Terminal(Ok(TermEvent::Key(key))) => { - assert_eq!(key.code, KeyCode::Char('y')); - } - _ => panic!("queued terminal input should not be lost to subsequent Pod events"), - } - } - - #[test] - fn running_status_still_allows_text_editing() { - let mut app = App::new("agent".to_string()); - app.set_pod_status(PodStatus::Running); - - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)).is_none()); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE) - ) - .is_none() - ); - - assert_eq!( - protocol::Segment::flatten_to_text(&app.input.submit_segments()), - "abc" - ); - } - - #[test] - fn running_enter_queues_instead_of_sending_run() { - let mut app = App::new("agent".to_string()); - app.set_pod_status(PodStatus::Running); - for c in "queued".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); - - assert_eq!(app.queued_input_count(), 1); - assert_eq!(app.next_queued_input_preview(), Some("queued")); - assert_eq!(input_text(&app), ""); - } - - #[test] - fn queued_input_keybindings_restore_and_clear() { - let mut app = App::new("agent".to_string()); - app.set_pod_status(PodStatus::Running); - for c in "edit queued".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); - - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT) - ) - .is_none() - ); - assert_eq!(app.queued_input_count(), 0); - assert_eq!(input_text(&app), "edit queued"); - - app.input.clear(); - for c in "clear queued".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); - assert_eq!(app.queued_input_count(), 1); - - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT) - ) - .is_none() - ); - assert_eq!(app.queued_input_count(), 0); - } - - #[test] - fn pause_and_cancel_clear_queued_input() { - let mut app = App::new("agent".to_string()); - app.set_pod_status(PodStatus::Running); - for c in "queued".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); - assert_eq!(app.queued_input_count(), 1); - - let pause = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - assert!(matches!(pause, Some(Method::Pause))); - assert_eq!(app.queued_input_count(), 0); - - for c in "queued again".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); - assert_eq!(app.queued_input_count(), 1); - - let cancel = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), - ); - assert!(matches!(cancel, Some(Method::Cancel))); - assert_eq!(app.queued_input_count(), 0); - } - - #[test] - fn command_mode_enters_with_colon_and_esc_restores_composer() { - let mut app = App::new("agent".to_string()); - app.insert_char('d'); - app.insert_char('r'); - app.insert_char('a'); - app.insert_char('f'); - app.insert_char('t'); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), "draft:"); - - app.input.clear(); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!(app.is_command_mode()); - for c in "help".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - assert_eq!(input_text(&app), ""); - assert_eq!(app.command_text(), "help"); - - assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none()); - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), ""); - } - - #[test] - fn command_mode_empty_backspace_restores_composer() { - let mut app = App::new("agent".to_string()); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!(app.is_command_mode()); - assert_eq!(app.command_text(), ""); - - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) - ) - .is_none() - ); - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), ""); - } - - #[test] - fn command_mode_non_empty_backspace_keeps_command_mode() { - let mut app = App::new("agent".to_string()); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE) - ) - .is_none() - ); - - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) - ) - .is_none() - ); - assert!(app.is_command_mode()); - assert_eq!(app.command_text(), ""); - } - - #[test] - fn unknown_command_is_not_sent_as_user_message() { - let mut app = App::new("agent".to_string()); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - for c in "does-not-exist".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - - let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(method.is_none()); - assert!(app.is_command_mode()); - assert_eq!(input_text(&app), ""); - assert_eq!(app.queued_input_count(), 0); - assert!(app.blocks.iter().any(|block| match block { - crate::block::Block::Alert { message, .. } => message.contains("Unknown command"), - _ => false, - })); - } - - #[test] - fn command_enter_dispatches_registry_without_run() { - let mut app = App::new("agent".to_string()); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - for c in "noop".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - - let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(method.is_none()); - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), ""); - assert!(app.blocks.iter().any(|block| match block { - crate::block::Block::Alert { message, .. } => message.contains("noop: no action"), - _ => false, - })); - } - - #[test] - fn compact_command_sends_compact_method_without_run() { - let mut app = App::new("agent".to_string()); - app.connected = true; - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - for c in "compact".chars() { - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) - ) - .is_none() - ); - } - - let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!(method, Some(protocol::Method::Compact))); - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), ""); - assert_eq!(app.queued_input_count(), 0); - assert!(app.blocks.iter().any(|block| match block { - crate::block::Block::Alert { message, .. } => message.contains("compact requested"), - _ => false, - })); - } - - #[test] - fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() { - let mut app = App::new("agent".to_string()); - app.set_pod_status(PodStatus::Idle); - - let method = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - - assert!(method.is_none()); - assert!(!app.quit); - let notice = app - .current_actionbar_notice(std::time::Instant::now()) - .expect("quit guard notice is active"); - assert!(notice.text.contains("Pod keeps running")); - assert_eq!(notice.level, ActionbarNoticeLevel::Warn); - assert_eq!(notice.source, ActionbarNoticeSource::Tui); - assert!(!has_alert(&app, "Pod keeps running")); - - let method = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - assert!(method.is_none()); - assert!(app.quit); - } - - #[test] - fn ctrl_r_requests_rewind_picker_when_idle_or_paused() { - let mut app = App::new("agent".to_string()); - app.connected = true; - let idle = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), - ); - assert!(matches!(idle, Some(Method::ListRewindTargets))); - - app.set_pod_status(PodStatus::Paused); - let paused = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), - ); - assert!(matches!(paused, Some(Method::ListRewindTargets))); - } - - #[test] - fn ctrl_r_is_rejected_while_running() { - let mut app = App::new("agent".to_string()); - app.connected = true; - app.set_pod_status(PodStatus::Running); - - let method = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), - ); - - assert!(method.is_none()); - assert!(has_alert(&app, "cannot rewind while the Pod is running")); - } - - #[test] - fn rewind_picker_close_returns_to_history_view() { - let mut app = App::new("agent".to_string()); - app.connected = true; - app.handle_pod_event(Event::RewindTargets { - head_entries: 1, - targets: vec![], - }); - assert!(app.rewind_picker.is_none()); - - let method = handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), - ); - assert!(matches!(method, Some(Method::ListRewindTargets))); - app.handle_pod_event(Event::RewindTargets { - head_entries: 1, - targets: vec![], - }); - assert!(app.rewind_picker.is_some()); - - let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(method.is_none()); - assert!(app.rewind_picker.is_none()); - } - - #[test] - fn rewind_applied_reseeds_display_and_restores_composer() { - let mut app = App::new("agent".to_string()); - app.handle_pod_event(Event::Snapshot { - greeting: test_greeting(), - entries: vec![], - status: PodStatus::Idle, - }); - app.handle_pod_event(Event::RewindApplied { - entries: vec![], - input: vec![Segment::Text { - content: "retry this".into(), - }], - summary: protocol::RewindSummary { - truncated_to_entries: 0, - discarded_entries: 2, - tool_side_effect_warning: true, - }, - }); - - assert_eq!(input_text(&app), "retry this"); - assert!(app.rewind_picker.is_none()); - assert!(has_alert(&app, "tool side effects")); - } - - #[test] - fn rewind_applied_keeps_non_empty_composer() { - let mut app = App::new("agent".to_string()); - app.handle_pod_event(Event::Snapshot { - greeting: test_greeting(), - entries: vec![], - status: PodStatus::Idle, - }); - type_keys(&mut app, "draft"); - - app.handle_pod_event(Event::RewindApplied { - entries: vec![], - input: vec![Segment::Text { - content: "retry this".into(), - }], - summary: protocol::RewindSummary { - truncated_to_entries: 0, - discarded_entries: 2, - tool_side_effect_warning: false, - }, - }); - - assert_eq!(input_text(&app), "draft"); - assert!(has_alert( - &app, - "composer not overwritten because it was not empty" - )); - } - - #[test] - fn rewind_apply_rejects_non_empty_composer_and_paused_status() { - let mut app = App::new("agent".to_string()); - app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); - type_keys(&mut app, "draft"); - assert!(app.submit_rewind_picker().is_none()); - assert!(has_alert(&app, "composer is not empty")); - - let mut app = App::new("agent".to_string()); - app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); - app.set_pod_status(PodStatus::Paused); - assert!(app.submit_rewind_picker().is_none()); - assert!(has_alert( - &app, - "cannot apply rewind while the Pod is paused" - )); - } - - #[test] - fn rewind_picker_draw_does_not_overwrite_history_scroll_state() { - let mut app = App::new("agent".to_string()); - app.scroll.top_offset = 3; - app.scroll.turn_starts = vec![0, 5, 9]; - app.scroll.total_lines = 42; - app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); - let original_top_offset = app.scroll.top_offset; - let original_turn_starts = app.scroll.turn_starts.clone(); - let original_total_lines = app.scroll.total_lines; - - let backend = ratatui::backend::TestBackend::new(80, 24); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| crate::ui::draw(frame, &mut app)) - .unwrap(); - app.close_rewind_picker(); - - assert_eq!(app.scroll.top_offset, original_top_offset); - assert_eq!(app.scroll.turn_starts, original_turn_starts); - assert_eq!(app.scroll.total_lines, original_total_lines); - } - - fn rewind_target() -> RewindTarget { - RewindTarget { - id: RewindTargetId { - segment_id: uuid::Uuid::nil(), - user_input_entry_index: 0, - }, - expected_head_entries: 1, - truncate_entries: 0, - turn_index: 1, - timestamp_ms: Some(1), - preview: "retry this".into(), - eligible: true, - disabled_reason: None, - warning: None, - } - } - - fn test_greeting() -> protocol::Greeting { - protocol::Greeting { - pod_name: "agent".into(), - cwd: "/tmp".into(), - provider: "test".into(), - model: "test".into(), - scope_summary: "".into(), - tools: vec![], - context_window: 0, - context_tokens: 0, - } - } - - #[test] - fn command_registry_suggestions_are_available() { - let mut app = App::new("agent".to_string()); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) - ) - .is_none() - ); - assert!( - app.command_suggestions() - .iter() - .any(|candidate| candidate.name == "help") - ); - assert!( - handle_key( - &mut app, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE) - ) - .is_none() - ); - let suggestions = app.command_suggestions(); - assert_eq!(suggestions.len(), 1); - assert_eq!(suggestions[0].name, "noop"); - } - - #[test] - fn command_completion_tab_applies_unambiguous_candidate() { - let mut app = App::new("agent".to_string()); - enter_command_mode(&mut app); - type_keys(&mut app, "no"); - - assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); - - assert!(app.is_command_mode()); - assert_eq!(app.command_text(), "noop "); - assert_eq!(input_text(&app), ""); - } - - #[test] - fn command_completion_enter_applies_and_executes_unambiguous_candidate() { - let mut app = App::new("agent".to_string()); - enter_command_mode(&mut app); - type_keys(&mut app, "no"); - - let method = handle_key(&mut app, key(KeyCode::Enter)); - - assert!(method.is_none()); - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), ""); - assert!(has_alert(&app, "noop: no action")); - } - - #[test] - fn command_completion_ambiguous_candidate_requires_selection_or_more_input() { - let mut app = App::new("agent".to_string()); - register_test_command(&mut app, "open", "open", parse_no_args, "open executed"); - register_test_command( - &mut app, - "options", - "options", - parse_no_args, - "options executed", - ); - enter_command_mode(&mut app); - type_keys(&mut app, "o"); - - assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); - assert_eq!(app.command_text(), "o"); - assert!(app.is_command_mode()); - assert!(has_alert(&app, "Ambiguous command completion")); - - let before = app.blocks.len(); - let method = handle_key(&mut app, key(KeyCode::Enter)); - assert!(method.is_none()); - assert_eq!(app.command_text(), "o"); - assert!(app.is_command_mode()); - assert!(app.blocks.len() > before); - assert!(!has_alert(&app, "open executed")); - assert!(!has_alert(&app, "options executed")); - } - - #[test] - fn command_completion_selected_candidate_applies_on_enter() { - let mut app = App::new("agent".to_string()); - register_test_command(&mut app, "open", "open", parse_no_args, "open executed"); - register_test_command( - &mut app, - "options", - "options", - parse_no_args, - "options executed", - ); - enter_command_mode(&mut app); - type_keys(&mut app, "o"); - - assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); - let method = handle_key(&mut app, key(KeyCode::Enter)); - - assert!(method.is_none()); - assert!(!app.is_command_mode()); - assert!(has_alert(&app, "open executed")); - assert!(!has_alert(&app, "options executed")); - } - - #[test] - fn command_completion_argument_required_keeps_command_mode_after_name_completion() { - let mut app = App::new("agent".to_string()); - register_test_command( - &mut app, - "open", - "open ", - parse_required_arg, - "open executed", - ); - enter_command_mode(&mut app); - type_keys(&mut app, "op"); - - let method = handle_key(&mut app, key(KeyCode::Enter)); - - assert!(method.is_none()); - assert!(app.is_command_mode()); - assert_eq!(app.command_text(), "open "); - assert!(has_alert(&app, "Invalid arguments. Usage: open ")); - assert!(!has_alert(&app, "open executed")); - assert_eq!(input_text(&app), ""); - } - - #[test] - fn command_completion_does_not_affect_normal_composer_without_popup() { - let mut app = App::new("agent".to_string()); - type_keys(&mut app, "hello"); - - assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); - - assert!(!app.is_command_mode()); - assert_eq!(input_text(&app), "hello"); - } - - #[test] - fn up_at_start_with_empty_history_preserves_draft_without_browsing() { - let mut app = App::new("agent".to_string()); - type_keys(&mut app, "draft"); - app.move_cursor_start(); - - assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); - - assert_eq!(input_text(&app), "draft"); - assert!(!app.input_history_is_browsing()); - } - - #[test] - fn up_from_empty_composer_recalls_history_and_down_restores_empty_draft() { - let mut app = App::new("agent".to_string()); - type_keys(&mut app, "first"); - assert!(matches!( - handle_key(&mut app, key(KeyCode::Enter)), - Some(Method::Run { .. }) - )); - type_keys(&mut app, "second"); - assert!(matches!( - handle_key(&mut app, key(KeyCode::Enter)), - Some(Method::Run { .. }) - )); - - assert_eq!(input_text(&app), ""); - assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); - assert_eq!(input_text(&app), "second"); - assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); - assert_eq!(input_text(&app), "first"); - assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); - assert_eq!(input_text(&app), "second"); - assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); - assert_eq!(input_text(&app), ""); - } - - #[test] - fn up_inside_multiline_preserves_existing_cursor_up_behavior() { - let mut app = App::new("agent".to_string()); - type_keys(&mut app, "ab\ncd"); - - assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); - assert!(handle_key(&mut app, key(KeyCode::Char('X'))).is_none()); - - assert_eq!(input_text(&app), "abX\ncd"); - } - - #[test] - fn up_at_start_of_multiline_recalls_history() { - let mut app = App::new("agent".to_string()); - type_keys(&mut app, "sent"); - assert!(matches!( - handle_key(&mut app, key(KeyCode::Enter)), - Some(Method::Run { .. }) - )); - type_keys(&mut app, "draft\nbody"); - app.move_cursor_start(); - - assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); - - assert_eq!(input_text(&app), "sent"); - } - - fn enter_command_mode(app: &mut App) { - assert!(handle_key(app, key(KeyCode::Char(':'))).is_none()); - assert!(app.is_command_mode()); - } - - fn type_keys(app: &mut App, text: &str) { - for c in text.chars() { - assert!(handle_key(app, key(KeyCode::Char(c))).is_none()); - } - } - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) - } - - fn has_alert(app: &App, needle: &str) -> bool { - app.blocks.iter().any(|block| match block { - crate::block::Block::Alert { message, .. } => message.contains(needle), - _ => false, - }) - } - - fn register_test_command( - app: &mut App, - name: &'static str, - usage: &'static str, - argument_parser: crate::command::ArgumentParser, - message: &'static str, - ) { - app.command_registry.register(crate::command::CommandSpec { - name, - aliases: &[], - usage, - description: "test command", - argument_parser, - can_execute: test_command_available, - executor: test_command_executor, - }); - TEST_COMMAND_MESSAGES.with(|messages| messages.borrow_mut().push((name, message))); - } - - thread_local! { - static TEST_COMMAND_MESSAGES: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; - } - - fn parse_no_args( - raw: &str, - ) -> Result { - Ok(crate::command::CommandArgs::parse_whitespace(raw)) - } - - fn parse_required_arg( - raw: &str, - ) -> Result { - let args = crate::command::CommandArgs::parse_whitespace(raw); - if args.argv().is_empty() { - return Err(crate::command::CommandDiagnostic::new( - "Invalid arguments. Usage: open ", - )); - } - Ok(args) - } - - fn test_command_available( - _environment: &crate::command::CommandEnvironment, - ) -> Result<(), crate::command::CommandDiagnostic> { - Ok(()) - } - - fn test_command_executor( - invocation: crate::command::CommandInvocation<'_>, - ) -> crate::command::CommandExecution { - let message = TEST_COMMAND_MESSAGES - .with(|messages| { - messages - .borrow() - .iter() - .find(|(name, _)| *name == invocation.command.name) - .map(|(_, message)| *message) - }) - .unwrap_or("test command executed"); - crate::command::CommandExecution::notice(message) - } - - fn input_text(app: &App) -> String { - protocol::Segment::flatten_to_text(&app.input.submit_segments()) - } -} diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs new file mode 100644 index 00000000..74b494ee --- /dev/null +++ b/crates/tui/src/single_pod.rs @@ -0,0 +1,1765 @@ +use std::future::Future; +use std::io; +use std::path::PathBuf; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use std::time::Duration; + +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, + KeyModifiers, MouseEvent, MouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; +use protocol::{Method, PodStatus}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use session_store::SegmentId; +use tokio::sync::mpsc; + +use client::{PodClient, PodRuntimeCommand}; + +use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; +use crate::picker::PickerOutcome; +use crate::spawn::{SpawnOutcome, SpawnReady}; +use crate::{multi_pod, picker, spawn, ui}; + +type FullscreenTerminal = Terminal>; + +fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { + if let Some(p) = override_path { + return p; + } + manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| { + PathBuf::from("/tmp") + .join("insomnia") + .join(pod_name) + .join("sock") + }) +} + +pub(crate) async fn run_pod_name( + pod_name: String, + socket_override: Option, + runtime_command: PodRuntimeCommand, +) -> Result<(), Box> { + if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { + let mut terminal = enter_fullscreen()?; + run_connected_pod(&mut terminal, pod_name, client).await?; + return Ok(()); + } + + let ready = match spawn::run_pod_name(pod_name, runtime_command).await? { + SpawnOutcome::Ready(r) => r, + SpawnOutcome::Cancelled => return Ok(()), + }; + let mut terminal = enter_fullscreen()?; + terminal.clear()?; + let result = run_ready_pod(&mut terminal, ready).await; + let _ = leave_fullscreen(&mut terminal); + result +} + +async fn run_connected_pod( + terminal: &mut FullscreenTerminal, + pod_name: String, + client: PodClient, +) -> Result<(), Box> { + let mut app = App::new(pod_name); + app.connected = true; + run_loop(terminal, &mut app, client).await +} + +async fn run_pod_name_nested( + terminal: &mut FullscreenTerminal, + request: multi_pod::OpenPodRequest, + runtime_command: PodRuntimeCommand, +) -> Result<(), Box> { + let multi_pod::OpenPodRequest { + pod_name, + socket_override, + } = request; + + if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await { + return run_connected_pod(terminal, pod_name, client).await; + } + + let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command).await?; + run_ready_pod(terminal, ready).await +} + +async fn spawn_pod_name_from_fullscreen( + terminal: &mut FullscreenTerminal, + pod_name: &str, + runtime_command: PodRuntimeCommand, +) -> Result> { + leave_fullscreen(terminal)?; + let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await; + enter_fullscreen_existing(terminal)?; + terminal.clear()?; + + match outcome? { + SpawnOutcome::Ready(ready) => Ok(ready), + SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)), + } +} + +async fn try_connect_live_pod( + pod_name: &str, + socket_override: Option, +) -> Option { + let preferred_socket = resolve_socket(pod_name, socket_override.clone()); + connect_live_pod(pod_name, preferred_socket, socket_override.is_none()) + .await + .map(|(_, client)| client) +} + +#[derive(Debug)] +struct NestedOpenCancelled; + +impl std::fmt::Display for NestedOpenCancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Pod open was cancelled") + } +} + +impl std::error::Error for NestedOpenCancelled {} + +async fn run_ready_pod( + terminal: &mut FullscreenTerminal, + ready: SpawnReady, +) -> Result<(), Box> { + let SpawnReady { + pod_name, + socket_path, + } = ready; + run(terminal, pod_name, &socket_path).await +} + +async fn connect_live_pod( + pod_name: &str, + preferred_socket: PathBuf, + allow_registry_fallback: bool, +) -> Option<(PathBuf, PodClient)> { + if let Ok(client) = PodClient::connect(&preferred_socket).await { + return Some((preferred_socket, client)); + } + + if !allow_registry_fallback { + return None; + } + let registry_socket = picker::live_socket_for_pod(pod_name)?; + if registry_socket == preferred_socket { + return None; + } + PodClient::connect(®istry_socket) + .await + .ok() + .map(|client| (registry_socket, client)) +} + +pub(crate) async fn run_resume( + runtime_command: PodRuntimeCommand, +) -> Result<(), Box> { + // Pick a Pod in its own inline viewport, dropping the viewport before + // attaching/restoring so each phase gets fresh vertical room. + let (pod_name, socket_override) = match picker::run().await? { + PickerOutcome::Picked { + pod_name, + socket_override, + } => (pod_name, socket_override), + PickerOutcome::Cancelled => return Ok(()), + }; + run_pod_name(pod_name, socket_override, runtime_command).await +} + +pub(crate) async fn run_multi( + runtime_command: PodRuntimeCommand, +) -> Result<(), Box> { + let mut app = multi_pod::load_app().await?; + let mut terminal = enter_fullscreen()?; + + loop { + match multi_pod::run(&mut terminal, &mut app).await? { + multi_pod::MultiPodOutcome::Quit => { + let _ = leave_fullscreen(&mut terminal); + return Ok(()); + } + multi_pod::MultiPodOutcome::Open(request) => { + let pod_name = request.pod_name.clone(); + match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await { + Ok(()) => app.finish_open(&pod_name, Ok(())), + Err(error) if is_recoverable_multi_open_error(error.as_ref()) => { + app.finish_open(&pod_name, Err(error.as_ref())); + } + Err(error) => { + let _ = leave_fullscreen(&mut terminal); + return Err(error); + } + } + app.reload_or_notice().await; + } + } + } +} + +fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool { + error.is::() || error.is::() +} + +pub(crate) async fn run_spawn( + resume_from: Option, + profile: Option, + runtime_command: PodRuntimeCommand, +) -> Result<(), Box> { + let ready = match spawn::run(resume_from, profile, runtime_command).await? { + SpawnOutcome::Ready(r) => r, + SpawnOutcome::Cancelled => return Ok(()), + }; + + let SpawnReady { + pod_name, + socket_path, + } = ready; + + let mut terminal = enter_fullscreen()?; + let result = run(&mut terminal, pod_name, &socket_path).await; + + // Leave alt-screen explicitly before `main`'s terminal restore path. + let _ = execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + ); + + result +} + +fn enter_fullscreen() -> Result> { + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + +fn enter_fullscreen_existing( + terminal: &mut FullscreenTerminal, +) -> Result<(), Box> { + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + )?; + Ok(()) +} + +fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> { + execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + ) +} + +async fn run( + terminal: &mut FullscreenTerminal, + pod_name: String, + socket_path: &std::path::Path, +) -> Result<(), Box> { + let mut app = App::new(pod_name); + + match PodClient::connect(socket_path).await { + Ok(client) => { + app.connected = true; + // The Pod sends `Event::Snapshot` automatically on connect; + // no explicit method call is required to fetch history. + run_loop(terminal, &mut app, client).await?; + } + Err(e) => { + app.push_error(format!( + "Failed to connect to {}: {e}", + socket_path.display() + )); + terminal.draw(|f| ui::draw(f, &mut app))?; + run_disconnected(&mut app)?; + } + } + Ok(()) +} + +type TerminalEventResult = io::Result; + +const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50); +const TERMINAL_EVENT_DRAIN_LIMIT: usize = 64; +const POD_EVENT_DRAIN_LIMIT: usize = 32; + +struct TerminalEventReader { + stop: Arc, + thread: Option>, +} + +impl TerminalEventReader { + fn spawn() -> io::Result<(Self, mpsc::UnboundedReceiver)> { + let (tx, rx) = mpsc::unbounded_channel(); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let thread = thread::Builder::new() + .name("insomnia-tui-terminal-reader".to_string()) + .spawn(move || read_terminal_events(thread_stop, tx))?; + + Ok(( + Self { + stop, + thread: Some(thread), + }, + rx, + )) + } +} + +impl Drop for TerminalEventReader { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender) { + while !stop.load(Ordering::Relaxed) { + match event::poll(TERMINAL_POLL_INTERVAL) { + Ok(false) => {} + Ok(true) => { + let event = event::read(); + let should_stop = event.is_err(); + if tx.send(event).is_err() || should_stop { + break; + } + } + Err(e) => { + let _ = tx.send(Err(e)); + break; + } + } + } +} + +enum LoopInput

{ + Terminal(TerminalEventResult), + Pod(Option

), +} + +async fn next_loop_input( + term_rx: &mut mpsc::UnboundedReceiver, + connected: bool, + pod_next: F, +) -> LoopInput

+where + F: Future>, +{ + tokio::select! { + biased; + + term_event = term_rx.recv() => { + LoopInput::Terminal(term_event.unwrap_or_else(|| { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "terminal event reader stopped", + )) + })) + } + event = pod_next, if connected => LoopInput::Pod(event), + } +} + +async fn drain_terminal_events( + app: &mut App, + client: &mut PodClient, + term_rx: &mut mpsc::UnboundedReceiver, +) -> Result> { + let mut handled = false; + for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { + match term_rx.try_recv() { + Ok(event) => { + handled = true; + handle_terminal_event(app, client, event?).await?; + if app.quit { + break; + } + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + return Err(Box::new(io::Error::new( + io::ErrorKind::UnexpectedEof, + "terminal event reader stopped", + ))); + } + } + } + Ok(handled) +} + +async fn drain_pod_events( + app: &mut App, + client: &mut PodClient, +) -> Result> { + let mut handled = false; + for _ in 0..POD_EVENT_DRAIN_LIMIT { + match client.try_next_event() { + Some(ev) => { + handled = true; + if let Some(method) = app.handle_pod_event(ev) { + client.send(&method).await?; + } + } + None => break, + } + } + Ok(handled) +} + +async fn run_loop( + terminal: &mut Terminal>, + app: &mut App, + mut client: PodClient, +) -> Result<(), Box> { + let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; + + terminal.draw(|f| ui::draw(f, app))?; + + loop { + if app.quit { + break; + } + + let handled_term_event = drain_terminal_events(app, &mut client, &mut term_rx).await?; + if app.quit { + break; + } + let handled_pod_event = drain_pod_events(app, &mut client).await?; + if handled_term_event || handled_pod_event { + terminal.draw(|f| ui::draw(f, app))?; + continue; + } + + match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { + LoopInput::Terminal(term_event) => { + handle_terminal_event(app, &mut client, term_event?).await?; + } + LoopInput::Pod(event) => match event { + Some(ev) => { + if let Some(method) = app.handle_pod_event(ev) { + client.send(&method).await?; + } + } + None => { + app.connected = false; + app.mark_orphan_compacts_incomplete(); + app.push_error("Connection lost"); + } + }, + } + + terminal.draw(|f| ui::draw(f, app))?; + } + + Ok(()) +} + +async fn handle_terminal_event( + app: &mut App, + client: &mut PodClient, + event: TermEvent, +) -> Result<(), Box> { + match event { + TermEvent::Key(key) => { + if let Some(method) = handle_key(app, key) { + client.send(&method).await?; + } + } + TermEvent::Mouse(mouse) => { + handle_mouse(app, mouse); + } + TermEvent::Paste(s) => { + app.insert_paste(s); + } + TermEvent::Resize(_, _) => { + // No-op: next draw repaints in full. + } + _ => {} + } + Ok(()) +} + +fn run_disconnected(_app: &mut App) -> Result<(), Box> { + loop { + if event::poll(std::time::Duration::from_millis(100))? + && let TermEvent::Key(key) = event::read()? + && let KeyCode::Char('c') = key.code + && key.modifiers.contains(KeyModifiers::CONTROL) + { + break; + } + } + Ok(()) +} + +/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so +/// hand-rolling through long histories isn't tedious, but slow enough +/// that a single notch doesn't blow past the section the user is +/// looking for. +const WHEEL_LINES: usize = 3; + +/// Lines to advance per PageUp / PageDown when the task side pane is +/// open. Calibrated so a couple of presses moves through one entry's +/// subject + description block. +const PANE_SCROLL_LINES: usize = 5; + +fn handle_mouse(app: &mut App, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES), + MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES), + _ => {} + } +} + +fn handle_key(app: &mut App, key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + // Modifier-key bindings. + if let Some(method) = match key.code { + KeyCode::Up if shift => { + app.scroll.scroll_up(1); + Some(None) + } + KeyCode::Down if shift => { + app.scroll.scroll_down(1); + Some(None) + } + KeyCode::Home if ctrl => { + app.scroll.to_top(); + Some(None) + } + KeyCode::End if ctrl => { + app.scroll.to_bottom(); + Some(None) + } + KeyCode::Char('[') if ctrl => { + app.scroll.jump_prev_turn(); + Some(None) + } + KeyCode::Char(']') if ctrl => { + app.scroll.jump_next_turn(); + Some(None) + } + KeyCode::Char('o') if ctrl => { + app.mode = app.mode.cycle(); + Some(None) + } + KeyCode::Char('t') if ctrl => { + app.toggle_task_pane(); + Some(None) + } + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => { + Some(app.request_rewind_picker()) + } + KeyCode::Char('a') if ctrl => { + app.move_cursor_start(); + Some(app.refresh_completion()) + } + KeyCode::Char('u') if ctrl && app.is_command_mode() => { + app.clear_command_input(); + Some(None) + } + KeyCode::Char(c) + if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() => + { + if app.restore_next_queued_input_to_composer() { + Some(app.refresh_completion()) + } else { + Some(None) + } + } + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => { + app.clear_queued_inputs(); + Some(None) + } + KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), + KeyCode::Char('x') if ctrl => Some(match app.pod_status { + PodStatus::Running => { + app.clear_queued_inputs(); + Some(Method::Cancel) + } + PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown), + }), + KeyCode::Char('d') if ctrl => { + app.quit = true; + Some(None) + } + KeyCode::Enter if alt => { + if app.is_command_mode() { + Some(None) + } else { + app.insert_newline(); + Some(app.refresh_completion()) + } + } + _ => None, + } { + return method; + } + + // Unbound Ctrl+Char keys are ignored before the text-input path so + // holding Ctrl while typing never inserts control characters. + if ctrl && matches!(key.code, KeyCode::Char(_)) { + return None; + } + + // Scroll / navigation. PageUp / PageDown defaults to history; while + // the task pane is open it scrolls the pane instead so the user can + // browse past entries without first closing the pane. + match key.code { + KeyCode::PageUp => { + if app.task_pane_open { + app.scroll_task_pane_up(PANE_SCROLL_LINES); + } else { + app.scroll.page_up(); + } + return None; + } + KeyCode::PageDown => { + if app.task_pane_open { + app.scroll_task_pane_down(PANE_SCROLL_LINES); + } else { + app.scroll.page_down(); + } + return None; + } + _ => {} + } + + if app.is_command_mode() { + return handle_command_key(app, key); + } + + if app.rewind_picker.is_some() { + match key.code { + KeyCode::Esc => { + app.close_rewind_picker(); + return None; + } + KeyCode::Enter => return app.submit_rewind_picker(), + KeyCode::Up => { + app.rewind_picker_up(); + return None; + } + KeyCode::Down => { + app.rewind_picker_down(); + return None; + } + _ => {} + } + } + + // Completion popup overrides — only when there's something to + // navigate / commit. An empty popup (request in flight) falls + // through to the default behaviour. + if app.completion.as_ref().is_some_and(|c| c.is_active()) { + match key.code { + KeyCode::Tab if !alt => { + // Insert the selected entry as raw text and let the + // re-triggered popup fetch fresh candidates (drill-in + // for directories, narrow-to-exact for files). + return app.apply_completion_text(); + } + KeyCode::Enter if !alt => { + // While the popup has selectable entries, Enter + // commits the selection rather than submitting the + // message. The selected entry wins regardless of how + // much of its value the user has typed — Enter on a + // popup entry is "accept this suggestion". Directory + // entries are the exception: they fall through to + // text insertion so the popup re-fetches children + // for drill-in. After a successful chip we append a + // trailing space so the user can keep writing without + // a manual separator (the Space path already has the + // space the user typed, so it's not needed there). + if app.chipify_selected_completion_if_committable() { + app.insert_char(' '); + return None; + } + return app.apply_completion_text(); + } + KeyCode::Up => { + app.move_completion_up(); + return None; + } + KeyCode::Down => { + app.move_completion_down(); + return None; + } + KeyCode::Esc => { + app.cancel_completion(); + return None; + } + _ => {} + } + } + + match key.code { + KeyCode::Esc => { + // Close the popup if it's still showing (covers the + // request-in-flight case where `is_active()` was false). + app.cancel_completion(); + None + } + KeyCode::Enter => app.submit_input(), + KeyCode::Backspace => { + app.delete_char_before(); + app.refresh_completion() + } + KeyCode::Delete => { + app.delete_char_after(); + app.refresh_completion() + } + KeyCode::Left => { + app.move_cursor_left(); + app.refresh_completion() + } + KeyCode::Right => { + app.move_cursor_right(); + app.refresh_completion() + } + KeyCode::Up => { + if app.can_browse_input_history_older() && app.browse_input_history_older() { + app.refresh_completion() + } else { + app.move_cursor_up(); + app.refresh_completion() + } + } + KeyCode::Down => { + if app.can_browse_input_history_newer() && app.browse_input_history_newer() { + app.refresh_completion() + } else { + app.move_cursor_down(); + app.refresh_completion() + } + } + KeyCode::Home => { + app.move_cursor_home(); + app.refresh_completion() + } + KeyCode::End => { + app.move_cursor_end(); + app.refresh_completion() + } + KeyCode::Char(':') if !alt && app.input.is_empty() => { + app.enter_command_mode(); + None + } + KeyCode::Char(c) => { + // Whitespace ends an in-flight completion token. Try the + // auto-confirm path first so an exact match (e.g. typed + // `@src/main.rs` matches the only popup entry) becomes a + // chip on the way out. Directories also commit here — + // ending with a space is an explicit "I want this dir" + // signal, not a drill-in. + if c.is_whitespace() { + app.chipify_completion_if_exact_match(); + } + app.insert_char(c); + app.refresh_completion() + } + _ => None, + } +} + +fn handle_command_key(app: &mut App, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => { + app.exit_command_mode(); + None + } + KeyCode::Enter => app.submit_command_with_completion(), + KeyCode::Backspace => { + if app.command_text().is_empty() { + app.exit_command_mode(); + } else { + app.delete_char_before(); + } + None + } + KeyCode::Delete => { + app.delete_char_after(); + None + } + KeyCode::Left => { + app.move_cursor_left(); + None + } + KeyCode::Right => { + app.move_cursor_right(); + None + } + KeyCode::Up => { + if app.command_completion_active() { + app.move_command_completion_up(); + } else { + app.move_cursor_up(); + } + None + } + KeyCode::Down => { + if app.command_completion_active() { + app.move_command_completion_down(); + } else { + app.move_cursor_down(); + } + None + } + KeyCode::Home => { + app.move_cursor_home(); + None + } + KeyCode::End => { + app.move_cursor_end(); + None + } + KeyCode::Tab => { + app.apply_command_completion(); + None + } + KeyCode::Char(c) => { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return None; + } + app.insert_char(c); + None + } + _ => None, + } +} + +const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + +/// Running → send `Method::Pause`. +/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running). +fn handle_pause_or_quit(app: &mut App) -> Option { + if app.pod_status == PodStatus::Running { + app.clear_queued_inputs(); + return Some(Method::Pause); + } + if let Some(t) = app.quit_confirm + && t.elapsed() < CONFIRM_TIMEOUT + { + app.quit_confirm = None; + app.quit = true; + return None; + } + app.quit_confirm = Some(std::time::Instant::now()); + app.flash_actionbar_notice( + "Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + CONFIRM_TIMEOUT, + ); + None +} + +#[cfg(test)] +mod tests { + use super::*; + use protocol::{Event, RewindTarget, RewindTargetId, Segment}; + + #[tokio::test] + async fn terminal_event_is_selected_before_ready_pod_event() { + let (tx, mut rx) = mpsc::unbounded_channel(); + tx.send(Ok(TermEvent::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + )))) + .unwrap(); + + match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await { + LoopInput::Terminal(Ok(TermEvent::Key(key))) => { + assert_eq!(key.code, KeyCode::Char('x')); + } + _ => panic!("ready terminal input should win over a ready Pod event"), + } + } + + #[tokio::test] + async fn terminal_event_is_preserved_after_pod_event_wins() { + let (tx, mut rx) = mpsc::unbounded_channel(); + + match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await { + LoopInput::Pod(Some(1)) => {} + _ => panic!("expected the first ready Pod event to win before any terminal input"), + } + + tx.send(Ok(TermEvent::Key(KeyEvent::new( + KeyCode::Char('y'), + KeyModifiers::NONE, + )))) + .unwrap(); + + match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await { + LoopInput::Terminal(Ok(TermEvent::Key(key))) => { + assert_eq!(key.code, KeyCode::Char('y')); + } + _ => panic!("queued terminal input should not be lost to subsequent Pod events"), + } + } + + #[test] + fn running_status_still_allows_text_editing() { + let mut app = App::new("agent".to_string()); + app.set_pod_status(PodStatus::Running); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)).is_none()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE) + ) + .is_none() + ); + + assert_eq!( + protocol::Segment::flatten_to_text(&app.input.submit_segments()), + "abc" + ); + } + + #[test] + fn running_enter_queues_instead_of_sending_run() { + let mut app = App::new("agent".to_string()); + app.set_pod_status(PodStatus::Running); + for c in "queued".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); + + assert_eq!(app.queued_input_count(), 1); + assert_eq!(app.next_queued_input_preview(), Some("queued")); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn queued_input_keybindings_restore_and_clear() { + let mut app = App::new("agent".to_string()); + app.set_pod_status(PodStatus::Running); + for c in "edit queued".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT) + ) + .is_none() + ); + assert_eq!(app.queued_input_count(), 0); + assert_eq!(input_text(&app), "edit queued"); + + app.input.clear(); + for c in "clear queued".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); + assert_eq!(app.queued_input_count(), 1); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT) + ) + .is_none() + ); + assert_eq!(app.queued_input_count(), 0); + } + + #[test] + fn pause_and_cancel_clear_queued_input() { + let mut app = App::new("agent".to_string()); + app.set_pod_status(PodStatus::Running); + for c in "queued".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); + assert_eq!(app.queued_input_count(), 1); + + let pause = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(matches!(pause, Some(Method::Pause))); + assert_eq!(app.queued_input_count(), 0); + + for c in "queued again".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); + assert_eq!(app.queued_input_count(), 1); + + let cancel = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!(matches!(cancel, Some(Method::Cancel))); + assert_eq!(app.queued_input_count(), 0); + } + + #[test] + fn command_mode_enters_with_colon_and_esc_restores_composer() { + let mut app = App::new("agent".to_string()); + app.insert_char('d'); + app.insert_char('r'); + app.insert_char('a'); + app.insert_char('f'); + app.insert_char('t'); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), "draft:"); + + app.input.clear(); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(app.is_command_mode()); + for c in "help".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + assert_eq!(input_text(&app), ""); + assert_eq!(app.command_text(), "help"); + + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none()); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_mode_empty_backspace_restores_composer() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), ""); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) + ) + .is_none() + ); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_mode_non_empty_backspace_keeps_command_mode() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE) + ) + .is_none() + ); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) + ) + .is_none() + ); + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), ""); + } + + #[test] + fn unknown_command_is_not_sent_as_user_message() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + for c in "does-not-exist".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(method.is_none()); + assert!(app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert_eq!(app.queued_input_count(), 0); + assert!(app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains("Unknown command"), + _ => false, + })); + } + + #[test] + fn command_enter_dispatches_registry_without_run() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + for c in "noop".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(method.is_none()); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert!(app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains("noop: no action"), + _ => false, + })); + } + + #[test] + fn compact_command_sends_compact_method_without_run() { + let mut app = App::new("agent".to_string()); + app.connected = true; + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + for c in "compact".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(method, Some(protocol::Method::Compact))); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert_eq!(app.queued_input_count(), 0); + assert!(app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains("compact requested"), + _ => false, + })); + } + + #[test] + fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() { + let mut app = App::new("agent".to_string()); + app.set_pod_status(PodStatus::Idle); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + + assert!(method.is_none()); + assert!(!app.quit); + let notice = app + .current_actionbar_notice(std::time::Instant::now()) + .expect("quit guard notice is active"); + assert!(notice.text.contains("Pod keeps running")); + assert_eq!(notice.level, ActionbarNoticeLevel::Warn); + assert_eq!(notice.source, ActionbarNoticeSource::Tui); + assert!(!has_alert(&app, "Pod keeps running")); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!(method.is_none()); + assert!(app.quit); + } + + #[test] + fn ctrl_r_requests_rewind_picker_when_idle_or_paused() { + let mut app = App::new("agent".to_string()); + app.connected = true; + let idle = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + ); + assert!(matches!(idle, Some(Method::ListRewindTargets))); + + app.set_pod_status(PodStatus::Paused); + let paused = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + ); + assert!(matches!(paused, Some(Method::ListRewindTargets))); + } + + #[test] + fn ctrl_r_is_rejected_while_running() { + let mut app = App::new("agent".to_string()); + app.connected = true; + app.set_pod_status(PodStatus::Running); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + ); + + assert!(method.is_none()); + assert!(has_alert(&app, "cannot rewind while the Pod is running")); + } + + #[test] + fn rewind_picker_close_returns_to_history_view() { + let mut app = App::new("agent".to_string()); + app.connected = true; + app.handle_pod_event(Event::RewindTargets { + head_entries: 1, + targets: vec![], + }); + assert!(app.rewind_picker.is_none()); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + ); + assert!(matches!(method, Some(Method::ListRewindTargets))); + app.handle_pod_event(Event::RewindTargets { + head_entries: 1, + targets: vec![], + }); + assert!(app.rewind_picker.is_some()); + + let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(method.is_none()); + assert!(app.rewind_picker.is_none()); + } + + #[test] + fn rewind_applied_reseeds_display_and_restores_composer() { + let mut app = App::new("agent".to_string()); + app.handle_pod_event(Event::Snapshot { + greeting: test_greeting(), + entries: vec![], + status: PodStatus::Idle, + }); + app.handle_pod_event(Event::RewindApplied { + entries: vec![], + input: vec![Segment::Text { + content: "retry this".into(), + }], + summary: protocol::RewindSummary { + truncated_to_entries: 0, + discarded_entries: 2, + tool_side_effect_warning: true, + }, + }); + + assert_eq!(input_text(&app), "retry this"); + assert!(app.rewind_picker.is_none()); + assert!(has_alert(&app, "tool side effects")); + } + + #[test] + fn rewind_applied_keeps_non_empty_composer() { + let mut app = App::new("agent".to_string()); + app.handle_pod_event(Event::Snapshot { + greeting: test_greeting(), + entries: vec![], + status: PodStatus::Idle, + }); + type_keys(&mut app, "draft"); + + app.handle_pod_event(Event::RewindApplied { + entries: vec![], + input: vec![Segment::Text { + content: "retry this".into(), + }], + summary: protocol::RewindSummary { + truncated_to_entries: 0, + discarded_entries: 2, + tool_side_effect_warning: false, + }, + }); + + assert_eq!(input_text(&app), "draft"); + assert!(has_alert( + &app, + "composer not overwritten because it was not empty" + )); + } + + #[test] + fn rewind_apply_rejects_non_empty_composer_and_paused_status() { + let mut app = App::new("agent".to_string()); + app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); + type_keys(&mut app, "draft"); + assert!(app.submit_rewind_picker().is_none()); + assert!(has_alert(&app, "composer is not empty")); + + let mut app = App::new("agent".to_string()); + app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); + app.set_pod_status(PodStatus::Paused); + assert!(app.submit_rewind_picker().is_none()); + assert!(has_alert( + &app, + "cannot apply rewind while the Pod is paused" + )); + } + + #[test] + fn rewind_picker_draw_does_not_overwrite_history_scroll_state() { + let mut app = App::new("agent".to_string()); + app.scroll.top_offset = 3; + app.scroll.turn_starts = vec![0, 5, 9]; + app.scroll.total_lines = 42; + app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); + let original_top_offset = app.scroll.top_offset; + let original_turn_starts = app.scroll.turn_starts.clone(); + let original_total_lines = app.scroll.total_lines; + + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| crate::ui::draw(frame, &mut app)) + .unwrap(); + app.close_rewind_picker(); + + assert_eq!(app.scroll.top_offset, original_top_offset); + assert_eq!(app.scroll.turn_starts, original_turn_starts); + assert_eq!(app.scroll.total_lines, original_total_lines); + } + + fn rewind_target() -> RewindTarget { + RewindTarget { + id: RewindTargetId { + segment_id: uuid::Uuid::nil(), + user_input_entry_index: 0, + }, + expected_head_entries: 1, + truncate_entries: 0, + turn_index: 1, + timestamp_ms: Some(1), + preview: "retry this".into(), + eligible: true, + disabled_reason: None, + warning: None, + } + } + + fn test_greeting() -> protocol::Greeting { + protocol::Greeting { + pod_name: "agent".into(), + cwd: "/tmp".into(), + provider: "test".into(), + model: "test".into(), + scope_summary: "".into(), + tools: vec![], + context_window: 0, + context_tokens: 0, + } + } + + #[test] + fn command_registry_suggestions_are_available() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + assert!( + app.command_suggestions() + .iter() + .any(|candidate| candidate.name == "help") + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE) + ) + .is_none() + ); + let suggestions = app.command_suggestions(); + assert_eq!(suggestions.len(), 1); + assert_eq!(suggestions[0].name, "noop"); + } + + #[test] + fn command_completion_tab_applies_unambiguous_candidate() { + let mut app = App::new("agent".to_string()); + enter_command_mode(&mut app); + type_keys(&mut app, "no"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), "noop "); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_completion_enter_applies_and_executes_unambiguous_candidate() { + let mut app = App::new("agent".to_string()); + enter_command_mode(&mut app); + type_keys(&mut app, "no"); + + let method = handle_key(&mut app, key(KeyCode::Enter)); + + assert!(method.is_none()); + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), ""); + assert!(has_alert(&app, "noop: no action")); + } + + #[test] + fn command_completion_ambiguous_candidate_requires_selection_or_more_input() { + let mut app = App::new("agent".to_string()); + register_test_command(&mut app, "open", "open", parse_no_args, "open executed"); + register_test_command( + &mut app, + "options", + "options", + parse_no_args, + "options executed", + ); + enter_command_mode(&mut app); + type_keys(&mut app, "o"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + assert_eq!(app.command_text(), "o"); + assert!(app.is_command_mode()); + assert!(has_alert(&app, "Ambiguous command completion")); + + let before = app.blocks.len(); + let method = handle_key(&mut app, key(KeyCode::Enter)); + assert!(method.is_none()); + assert_eq!(app.command_text(), "o"); + assert!(app.is_command_mode()); + assert!(app.blocks.len() > before); + assert!(!has_alert(&app, "open executed")); + assert!(!has_alert(&app, "options executed")); + } + + #[test] + fn command_completion_selected_candidate_applies_on_enter() { + let mut app = App::new("agent".to_string()); + register_test_command(&mut app, "open", "open", parse_no_args, "open executed"); + register_test_command( + &mut app, + "options", + "options", + parse_no_args, + "options executed", + ); + enter_command_mode(&mut app); + type_keys(&mut app, "o"); + + assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); + let method = handle_key(&mut app, key(KeyCode::Enter)); + + assert!(method.is_none()); + assert!(!app.is_command_mode()); + assert!(has_alert(&app, "open executed")); + assert!(!has_alert(&app, "options executed")); + } + + #[test] + fn command_completion_argument_required_keeps_command_mode_after_name_completion() { + let mut app = App::new("agent".to_string()); + register_test_command( + &mut app, + "open", + "open ", + parse_required_arg, + "open executed", + ); + enter_command_mode(&mut app); + type_keys(&mut app, "op"); + + let method = handle_key(&mut app, key(KeyCode::Enter)); + + assert!(method.is_none()); + assert!(app.is_command_mode()); + assert_eq!(app.command_text(), "open "); + assert!(has_alert(&app, "Invalid arguments. Usage: open ")); + assert!(!has_alert(&app, "open executed")); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn command_completion_does_not_affect_normal_composer_without_popup() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "hello"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + + assert!(!app.is_command_mode()); + assert_eq!(input_text(&app), "hello"); + } + + #[test] + fn up_at_start_with_empty_history_preserves_draft_without_browsing() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "draft"); + app.move_cursor_start(); + + assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); + + assert_eq!(input_text(&app), "draft"); + assert!(!app.input_history_is_browsing()); + } + + #[test] + fn up_from_empty_composer_recalls_history_and_down_restores_empty_draft() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "first"); + assert!(matches!( + handle_key(&mut app, key(KeyCode::Enter)), + Some(Method::Run { .. }) + )); + type_keys(&mut app, "second"); + assert!(matches!( + handle_key(&mut app, key(KeyCode::Enter)), + Some(Method::Run { .. }) + )); + + assert_eq!(input_text(&app), ""); + assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); + assert_eq!(input_text(&app), "second"); + assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); + assert_eq!(input_text(&app), "first"); + assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); + assert_eq!(input_text(&app), "second"); + assert!(handle_key(&mut app, key(KeyCode::Down)).is_none()); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn up_inside_multiline_preserves_existing_cursor_up_behavior() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "ab\ncd"); + + assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); + assert!(handle_key(&mut app, key(KeyCode::Char('X'))).is_none()); + + assert_eq!(input_text(&app), "abX\ncd"); + } + + #[test] + fn up_at_start_of_multiline_recalls_history() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "sent"); + assert!(matches!( + handle_key(&mut app, key(KeyCode::Enter)), + Some(Method::Run { .. }) + )); + type_keys(&mut app, "draft\nbody"); + app.move_cursor_start(); + + assert!(handle_key(&mut app, key(KeyCode::Up)).is_none()); + + assert_eq!(input_text(&app), "sent"); + } + + fn enter_command_mode(app: &mut App) { + assert!(handle_key(app, key(KeyCode::Char(':'))).is_none()); + assert!(app.is_command_mode()); + } + + fn type_keys(app: &mut App, text: &str) { + for c in text.chars() { + assert!(handle_key(app, key(KeyCode::Char(c))).is_none()); + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn has_alert(app: &App, needle: &str) -> bool { + app.blocks.iter().any(|block| match block { + crate::block::Block::Alert { message, .. } => message.contains(needle), + _ => false, + }) + } + + fn register_test_command( + app: &mut App, + name: &'static str, + usage: &'static str, + argument_parser: crate::command::ArgumentParser, + message: &'static str, + ) { + app.command_registry.register(crate::command::CommandSpec { + name, + aliases: &[], + usage, + description: "test command", + argument_parser, + can_execute: test_command_available, + executor: test_command_executor, + }); + TEST_COMMAND_MESSAGES.with(|messages| messages.borrow_mut().push((name, message))); + } + + thread_local! { + static TEST_COMMAND_MESSAGES: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; + } + + fn parse_no_args( + raw: &str, + ) -> Result { + Ok(crate::command::CommandArgs::parse_whitespace(raw)) + } + + fn parse_required_arg( + raw: &str, + ) -> Result { + let args = crate::command::CommandArgs::parse_whitespace(raw); + if args.argv().is_empty() { + return Err(crate::command::CommandDiagnostic::new( + "Invalid arguments. Usage: open ", + )); + } + Ok(args) + } + + fn test_command_available( + _environment: &crate::command::CommandEnvironment, + ) -> Result<(), crate::command::CommandDiagnostic> { + Ok(()) + } + + fn test_command_executor( + invocation: crate::command::CommandInvocation<'_>, + ) -> crate::command::CommandExecution { + let message = TEST_COMMAND_MESSAGES + .with(|messages| { + messages + .borrow() + .iter() + .find(|(name, _)| *name == invocation.command.name) + .map(|(_, message)| *message) + }) + .unwrap_or("test command executed"); + crate::command::CommandExecution::notice(message) + } + + fn input_text(app: &App) -> String { + protocol::Segment::flatten_to_text(&app.input.submit_segments()) + } +}