merge: tui-input-queue

This commit is contained in:
Keisuke Hirata 2026-05-23 13:57:32 +09:00
commit ff747da1a0
No known key found for this signature in database
3 changed files with 382 additions and 9 deletions

View File

@ -1,3 +1,4 @@
use std::collections::VecDeque;
use std::time::Instant; use std::time::Instant;
use protocol::{ use protocol::{
@ -47,6 +48,23 @@ struct RollbackSubmitState {
turn_before: usize, turn_before: usize,
} }
#[derive(Clone)]
pub struct QueuedInput {
segments: Vec<Segment>,
preview: String,
}
impl QueuedInput {
fn new(segments: Vec<Segment>) -> Self {
let preview = Segment::flatten_to_text(&segments);
Self { segments, preview }
}
pub fn preview(&self) -> &str {
&self.preview
}
}
pub struct App { pub struct App {
pub pod_name: String, pub pod_name: String,
pub connected: bool, pub connected: bool,
@ -98,6 +116,9 @@ pub struct App {
/// Top entry index of the task pane's visible window. Clamped on /// Top entry index of the task pane's visible window. Clamped on
/// render so it never points past the end of the list. /// render so it never points past the end of the list.
pub task_pane_scroll: usize, 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<QueuedInput>,
/// Local submit state kept until the accepted run either completes /// Local submit state kept until the accepted run either completes
/// normally or reports that the empty assistant turn was rolled back. /// normally or reports that the empty assistant turn was rolled back.
pending_submit_rollback: Option<RollbackSubmitState>, pending_submit_rollback: Option<RollbackSubmitState>,
@ -133,6 +154,7 @@ impl App {
task_store: TaskStore::new(), task_store: TaskStore::new(),
task_pane_open: false, task_pane_open: false,
task_pane_scroll: 0, task_pane_scroll: 0,
queued_inputs: VecDeque::new(),
pending_submit_rollback: None, pending_submit_rollback: None,
last_rolled_back_input: None, last_rolled_back_input: None,
} }
@ -351,6 +373,17 @@ impl App {
} }
return None; 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<Segment>) -> Method {
// TurnHeader / UserMessage blocks are pushed in response to // TurnHeader / UserMessage blocks are pushed in response to
// `Event::UserMessage` (single source of truth, shared by every // `Event::UserMessage` (single source of truth, shared by every
// client subscribed to the Pod). Locally we only clear the // client subscribed to the Pod). Locally we only clear the
@ -363,8 +396,42 @@ impl App {
block_start: self.blocks.len(), block_start: self.blocks.len(),
turn_before: self.turn_index, turn_before: self.turn_index,
}); });
self.input.clear(); Method::Run { input: segments }
Some(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<Method> {
let queued = self.queued_inputs.pop_front()?;
Some(self.method_for_run(queued.segments))
} }
pub fn push_error(&mut self, message: impl Into<String>) { pub fn push_error(&mut self, message: impl Into<String>) {
@ -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<Method> {
match event { match event {
Event::UserMessage { segments } => { Event::UserMessage { segments } => {
self.turn_index += 1; self.turn_index += 1;
@ -708,6 +775,9 @@ impl App {
PodStatus::Idle PodStatus::Idle
} }
}); });
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
return self.pop_next_queued_run();
}
} }
} }
Event::CompactStart => { Event::CompactStart => {
@ -791,6 +861,7 @@ impl App {
self.quit = true; self.quit = true;
} }
} }
None
} }
fn reset_run_state(&mut self, status: PodStatus) { 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<Segment> { fn submit_text(app: &mut App, text: &str) -> Vec<Segment> {
for c in text.chars() { for c in text.chars() {
app.insert_char(c); app.insert_char(c);

View File

@ -477,18 +477,23 @@ async fn drain_terminal_events(
Ok(handled) 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<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..POD_EVENT_DRAIN_LIMIT { for _ in 0..POD_EVENT_DRAIN_LIMIT {
match client.try_next_event() { match client.try_next_event() {
Some(ev) => { Some(ev) => {
handled = true; handled = true;
app.handle_pod_event(ev); if let Some(method) = app.handle_pod_event(ev) {
client.send(&method).await?;
}
} }
None => break, None => break,
} }
} }
handled Ok(handled)
} }
async fn run_loop( async fn run_loop(
@ -509,7 +514,7 @@ async fn run_loop(
if app.quit { if app.quit {
break; 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 { if handled_term_event || handled_pod_event {
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
continue; continue;
@ -520,7 +525,11 @@ async fn run_loop(
handle_terminal_event(app, &mut client, term_event?).await?; handle_terminal_event(app, &mut client, term_event?).await?;
} }
LoopInput::Pod(event) => match event { 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 => { None => {
app.connected = false; app.connected = false;
app.mark_orphan_compacts_incomplete(); app.mark_orphan_compacts_incomplete();
@ -635,9 +644,23 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.move_cursor_start(); app.move_cursor_start();
Some(app.refresh_completion()) 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('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(match app.pod_status { 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), PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
}), }),
KeyCode::Char('d') if ctrl => { 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). /// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> { fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.pod_status == PodStatus::Running { if app.pod_status == PodStatus::Running {
app.clear_queued_inputs();
return Some(Method::Pause); return Some(Method::Pause);
} }
if let Some(t) = app.quit_confirm if let Some(t) = app.quit_confirm
@ -919,4 +943,120 @@ mod tests {
"abc" "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())
}
} }

View File

@ -1141,6 +1141,11 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); 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_text = context_usage_text(app);
let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray))) let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
.alignment(ratatui::layout::Alignment::Right); .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) { fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
let mut left: Vec<Span<'static>> = 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<Span<'static>> = Vec::new(); let mut right: Vec<Span<'static>> = Vec::new();
if !app.scroll.follow_tail { if !app.scroll.follow_tail {
right.push(Span::styled( right.push(Span::styled(
@ -1161,10 +1174,28 @@ fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
format!("[{}]", app.mode.label()), format!("[{}]", app.mode.label()),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
let left_line = Line::from(left);
let right_line = Line::from(right).alignment(ratatui::layout::Alignment::Right); 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); frame.render_widget(Paragraph::new(right_line), area);
} }
fn queue_status_text(app: &App) -> Option<String> {
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) { fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) {
// Prefix "> " on the first row, two-space gutter for continuation // Prefix "> " on the first row, two-space gutter for continuation
// rows so multi-line input aligns visually. // 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);
}
}