TUIからPauseする実装

This commit is contained in:
2026-04-19 14:27:53 +09:00
parent 605e78468c
commit 223d06c77e
10 changed files with 823 additions and 25 deletions
+19 -2
View File
@@ -1,9 +1,13 @@
use protocol::{Event, Greeting, Method, NotificationLevel, NotificationSource};
use protocol::{Event, Greeting, Method, NotificationLevel, NotificationSource, RunResult};
pub struct App {
pub pod_name: String,
pub connected: bool,
pub running: bool,
/// True while the Pod is in `PodStatus::Paused`. Set on
/// `RunEnd::Paused` and cleared when a new turn starts (either via
/// `Resume` or a fresh `Run`).
pub paused: bool,
pub run_requests: usize,
pub run_input_tokens: u64,
pub run_output_tokens: u64,
@@ -13,6 +17,10 @@ pub struct App {
pub cursor: usize,
pub quit: bool,
pub shutdown_confirm: Option<std::time::Instant>,
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
/// records the instant; a second press within the timeout exits the
/// TUI (the Pod itself stays alive).
pub quit_confirm: Option<std::time::Instant>,
/// Lines waiting to be flushed to terminal via insert_before.
pub output_queue: Vec<OutputItem>,
/// Partial streaming text not yet terminated by newline.
@@ -48,6 +56,7 @@ impl App {
pod_name,
connected: false,
running: false,
paused: false,
run_requests: 0,
run_input_tokens: 0,
run_output_tokens: 0,
@@ -57,6 +66,7 @@ impl App {
cursor: 0,
quit: false,
shutdown_confirm: None,
quit_confirm: None,
output_queue: Vec::new(),
pending_text: String::new(),
}
@@ -65,6 +75,11 @@ impl App {
pub fn submit_input(&mut self) -> Option<Method> {
let text = self.input.trim().to_owned();
if text.is_empty() {
// Empty Enter only does something meaningful when the Pod
// is paused: resume the interrupted turn. Otherwise no-op.
if self.paused {
return Some(Method::Resume);
}
return None;
}
self.turn_index += 1;
@@ -83,6 +98,7 @@ impl App {
match event {
Event::TurnStart { .. } => {
self.running = true;
self.paused = false;
self.run_requests += 1;
self.current_tool = None;
}
@@ -154,7 +170,7 @@ impl App {
format!("[{code:?}] {message}"),
));
}
Event::RunEnd { .. } => {
Event::RunEnd { result } => {
self.output_queue.push(OutputItem::PaddedRight(
MessageKind::TurnStats,
format!(
@@ -166,6 +182,7 @@ impl App {
));
self.output_queue.push(OutputItem::Blank);
self.running = false;
self.paused = matches!(result, RunResult::Paused);
self.run_requests = 0;
self.run_input_tokens = 0;
self.run_output_tokens = 0;
+40 -15
View File
@@ -141,14 +141,14 @@ async fn run_loop(
Ok(())
}
fn run_disconnected(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
loop {
if event::poll(std::time::Duration::from_millis(100))? {
if let TermEvent::Key(key) = event::read()? {
match key.code {
KeyCode::Esc => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
_ => {}
if let KeyCode::Char('c') = key.code {
if key.modifiers.contains(KeyModifiers::CONTROL) {
break;
}
}
}
}
@@ -158,16 +158,20 @@ fn run_disconnected(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
match key.code {
KeyCode::Esc => {
app.quit = true;
None
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.quit = true;
None
handle_pause_or_quit(app)
}
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if app.running {
Some(Method::Cancel)
} else {
app.output_queue.push(app::OutputItem::Padded(
app::MessageKind::Error,
"Nothing to cancel (Pod is not running).".into(),
));
None
}
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Method::Resume),
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Method::Cancel),
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return handle_shutdown(app);
}
@@ -204,14 +208,14 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
}
}
const SHUTDOWN_CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
fn handle_shutdown(app: &mut App) -> Option<Method> {
if !app.running {
return Some(Method::Shutdown);
}
if let Some(t) = app.shutdown_confirm {
if t.elapsed() < SHUTDOWN_CONFIRM_TIMEOUT {
if t.elapsed() < CONFIRM_TIMEOUT {
app.shutdown_confirm = None;
return Some(Method::Shutdown);
}
@@ -223,3 +227,24 @@ fn handle_shutdown(app: &mut App) -> Option<Method> {
));
None
}
/// 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<Method> {
if app.running {
return Some(Method::Pause);
}
if let Some(t) = app.quit_confirm {
if t.elapsed() < CONFIRM_TIMEOUT {
app.quit_confirm = None;
app.quit = true;
return None;
}
}
app.quit_confirm = Some(std::time::Instant::now());
app.output_queue.push(app::OutputItem::Padded(
app::MessageKind::Error,
"Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).".into(),
));
None
}
+12
View File
@@ -170,6 +170,18 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
};
spans.push(Span::raw(" | "));
spans.push(Span::styled(status, Style::default().fg(Color::Yellow)));
} else if app.paused {
spans.push(Span::raw(" | "));
spans.push(Span::styled(
"paused",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
" — Enter to resume, type to start new turn",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
}