diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 5c2028d0..3b32f84a 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::time::Instant; use protocol::{ @@ -47,6 +48,23 @@ struct RollbackSubmitState { turn_before: usize, } +#[derive(Clone)] +pub struct QueuedInput { + segments: Vec, + preview: String, +} + +impl QueuedInput { + fn new(segments: Vec) -> Self { + let preview = Segment::flatten_to_text(&segments); + Self { segments, preview } + } + + pub fn preview(&self) -> &str { + &self.preview + } +} + pub struct App { pub pod_name: String, pub connected: bool, @@ -98,6 +116,9 @@ pub struct App { /// Top entry index of the task pane's visible window. Clamped on /// render so it never points past the end of the list. pub task_pane_scroll: usize, + /// TUI-local FIFO of user inputs submitted while the Pod is already running. + /// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally. + queued_inputs: VecDeque, /// Local submit state kept until the accepted run either completes /// normally or reports that the empty assistant turn was rolled back. pending_submit_rollback: Option, @@ -133,6 +154,7 @@ impl App { task_store: TaskStore::new(), task_pane_open: false, task_pane_scroll: 0, + queued_inputs: VecDeque::new(), pending_submit_rollback: None, last_rolled_back_input: None, } @@ -351,6 +373,17 @@ impl App { } return None; } + if self.running { + self.queued_inputs.push_back(QueuedInput::new(segments)); + self.input.clear(); + self.completion = None; + return None; + } + self.input.clear(); + Some(self.method_for_run(segments)) + } + + fn method_for_run(&mut self, segments: Vec) -> Method { // TurnHeader / UserMessage blocks are pushed in response to // `Event::UserMessage` (single source of truth, shared by every // client subscribed to the Pod). Locally we only clear the @@ -363,8 +396,42 @@ impl App { block_start: self.blocks.len(), turn_before: self.turn_index, }); - self.input.clear(); - Some(Method::Run { input: segments }) + Method::Run { input: segments } + } + + pub fn queued_input_count(&self) -> usize { + self.queued_inputs.len() + } + + pub fn next_queued_input_preview(&self) -> Option<&str> { + self.queued_inputs.front().map(QueuedInput::preview) + } + + pub fn clear_queued_inputs(&mut self) -> usize { + let cleared = self.queued_inputs.len(); + self.queued_inputs.clear(); + cleared + } + + pub fn restore_next_queued_input_to_composer(&mut self) -> bool { + if self.queued_inputs.is_empty() { + return false; + } + if !self.input.is_empty() { + self.push_error("Composer is not empty; clear it before editing queued input."); + return false; + } + let Some(queued) = self.queued_inputs.pop_front() else { + return false; + }; + self.input.replace_with_segments(&queued.segments); + self.completion = None; + true + } + + fn pop_next_queued_run(&mut self) -> Option { + let queued = self.queued_inputs.pop_front()?; + Some(self.method_for_run(queued.segments)) } pub fn push_error(&mut self, message: impl Into) { @@ -502,7 +569,7 @@ impl App { } } - pub fn handle_pod_event(&mut self, event: Event) { + pub fn handle_pod_event(&mut self, event: Event) -> Option { match event { Event::UserMessage { segments } => { self.turn_index += 1; @@ -708,6 +775,9 @@ impl App { PodStatus::Idle } }); + if matches!(result, RunResult::Finished | RunResult::LimitReached) { + return self.pop_next_queued_run(); + } } } Event::CompactStart => { @@ -791,6 +861,7 @@ impl App { self.quit = true; } } + None } fn reset_run_state(&mut self, status: PodStatus) { @@ -1610,6 +1681,108 @@ mod completion_flow_tests { } } + #[test] + fn running_submit_is_queued_locally_and_clears_composer() { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Running); + insert_text(&mut app, "queued turn"); + + assert!(app.submit_input().is_none()); + + assert_eq!(app.queued_input_count(), 1); + assert_eq!(app.next_queued_input_preview(), Some("queued turn")); + assert_eq!(input_text(&app), ""); + } + + #[test] + fn finished_run_auto_sends_next_queued_input() { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Running); + insert_text(&mut app, "next turn"); + assert!(app.submit_input().is_none()); + + let method = app.handle_pod_event(Event::RunEnd { + result: RunResult::Finished, + }); + + match method { + Some(Method::Run { input }) => { + assert_eq!(Segment::flatten_to_text(&input), "next turn"); + } + other => panic!("expected queued Run, got {other:?}"), + } + assert_eq!(app.queued_input_count(), 0); + } + + #[test] + fn limit_reached_run_auto_sends_next_queued_input() { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Running); + insert_text(&mut app, "next after limit"); + assert!(app.submit_input().is_none()); + + let method = app.handle_pod_event(Event::RunEnd { + result: RunResult::LimitReached, + }); + + match method { + Some(Method::Run { input }) => { + assert_eq!(Segment::flatten_to_text(&input), "next after limit"); + } + other => panic!("expected queued Run, got {other:?}"), + } + assert_eq!(app.queued_input_count(), 0); + } + + #[test] + fn paused_and_rolled_back_run_do_not_auto_send_queue() { + for result in [RunResult::Paused, RunResult::RolledBack] { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Running); + insert_text(&mut app, "held turn"); + assert!(app.submit_input().is_none()); + + let method = app.handle_pod_event(Event::RunEnd { result }); + + assert!(method.is_none()); + assert_eq!(app.queued_input_count(), 1); + assert_eq!(app.next_queued_input_preview(), Some("held turn")); + } + } + + #[test] + fn paused_empty_submit_still_resumes_immediately() { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Paused); + + assert!(matches!(app.submit_input(), Some(Method::Resume))); + assert_eq!(app.queued_input_count(), 0); + } + + #[test] + fn queued_input_can_be_restored_to_composer_or_cleared() { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Running); + insert_text(&mut app, "edit me"); + assert!(app.submit_input().is_none()); + + assert!(app.restore_next_queued_input_to_composer()); + assert_eq!(app.queued_input_count(), 0); + assert_eq!(input_text(&app), "edit me"); + + app.input.clear(); + insert_text(&mut app, "clear me"); + assert!(app.submit_input().is_none()); + assert_eq!(app.clear_queued_inputs(), 1); + assert_eq!(app.queued_input_count(), 0); + } + + fn insert_text(app: &mut App, text: &str) { + for c in text.chars() { + app.insert_char(c); + } + } + fn submit_text(app: &mut App, text: &str) -> Vec { for c in text.chars() { app.insert_char(c); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 7d989143..b394cf66 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -477,18 +477,23 @@ async fn drain_terminal_events( Ok(handled) } -fn drain_pod_events(app: &mut App, client: &mut PodClient) -> bool { +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; - app.handle_pod_event(ev); + if let Some(method) = app.handle_pod_event(ev) { + client.send(&method).await?; + } } None => break, } } - handled + Ok(handled) } async fn run_loop( @@ -509,7 +514,7 @@ async fn run_loop( if app.quit { break; } - let handled_pod_event = drain_pod_events(app, &mut client); + 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; @@ -520,7 +525,11 @@ async fn run_loop( handle_terminal_event(app, &mut client, term_event?).await?; } LoopInput::Pod(event) => match event { - Some(ev) => app.handle_pod_event(ev), + Some(ev) => { + if let Some(method) = app.handle_pod_event(ev) { + client.send(&method).await?; + } + } None => { app.connected = false; app.mark_orphan_compacts_incomplete(); @@ -635,9 +644,23 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_start(); Some(app.refresh_completion()) } + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') && alt && !ctrl => { + 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 => Some(Method::Cancel), + PodStatus::Running => { + app.clear_queued_inputs(); + Some(Method::Cancel) + } PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown), }), KeyCode::Char('d') if ctrl => { @@ -790,6 +813,7 @@ const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); /// 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 @@ -919,4 +943,120 @@ mod tests { "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); + } + + fn input_text(app: &App) -> String { + protocol::Segment::flatten_to_text(&app.input.submit_segments()) + } } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index c81c3543..9c80e1c9 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1141,6 +1141,11 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); } + if let Some(queue) = queue_status_text(app) { + spans.push(Span::raw(" | ")); + spans.push(Span::styled(queue, Style::default().fg(Color::Magenta))); + } + let right_text = context_usage_text(app); let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray))) .alignment(ratatui::layout::Alignment::Right); @@ -1150,6 +1155,14 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { } fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { + let mut left: Vec> = Vec::new(); + if app.queued_input_count() > 0 { + left.push(Span::styled( + "Alt-q edit queued Alt-c clear queued", + Style::default().fg(Color::DarkGray), + )); + } + let mut right: Vec> = Vec::new(); if !app.scroll.follow_tail { right.push(Span::styled( @@ -1161,10 +1174,28 @@ fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { format!("[{}]", app.mode.label()), Style::default().fg(Color::DarkGray), )); + let left_line = Line::from(left); let right_line = Line::from(right).alignment(ratatui::layout::Alignment::Right); + frame.render_widget(Paragraph::new(left_line), area); frame.render_widget(Paragraph::new(right_line), area); } +fn queue_status_text(app: &App) -> Option { + let count = app.queued_input_count(); + if count == 0 { + return None; + } + let mut text = format!("queued: {count}"); + if let Some(preview) = app.next_queued_input_preview() { + let preview = truncate_with_ellipsis(preview.trim(), 40); + if !preview.is_empty() { + text.push_str(" — "); + text.push_str(&preview); + } + } + Some(text) +} + fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) { // Prefix "> " on the first row, two-space gutter for continuation // rows so multi-line input aligns visually. @@ -1324,3 +1355,32 @@ fn format_pod_event(event: &PodEvent) -> String { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use protocol::PodStatus; + + #[test] + fn queue_status_text_includes_count_and_preview() { + let mut app = App::new("test".into()); + app.set_pod_status(PodStatus::Running); + for c in "queued preview".chars() { + app.insert_char(c); + } + assert!(app.submit_input().is_none()); + + assert_eq!( + queue_status_text(&app), + Some("queued: 1 — queued preview".to_string()) + ); + } + + #[test] + fn queue_status_text_is_absent_without_queue() { + let app = App::new("test".into()); + + assert_eq!(queue_status_text(&app), None); + } +}