diff --git a/crates/pa-tui/src/interactive/run.rs b/crates/pa-tui/src/interactive/run.rs index 345a59056..bde5f0d84 100644 --- a/crates/pa-tui/src/interactive/run.rs +++ b/crates/pa-tui/src/interactive/run.rs @@ -38,6 +38,21 @@ const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); /// regardless of the render rate. const SPINNER_INTERVAL_MS: u128 = 80; +/// The animating loader's next phase boundary, the wake the select needs +/// while a quiet turn waits out its stream: TS `Loader`'s `setInterval` +/// keeps painting the 80ms cadence through quiet turns, and this +/// boundary is that interval's timer. The wake always precedes the +/// phase change it observes — an off-by-one here parks the loop for a +/// whole boundary instead of firing at the phase edge. +fn next_spinner_deadline(started: Instant, now: Instant) -> Instant { + // The remainder form keeps the arithmetic bounded by one phase: a + // wide phase counter would truncate through `as usize` on 32-bit + // targets after ~10.9 years of continuous animation and arm an + // already-expired deadline, hot-spinning the select's wake. + let into_phase = now.duration_since(started).as_millis() % SPINNER_INTERVAL_MS; + now + Duration::from_millis((SPINNER_INTERVAL_MS - into_phase) as u64) +} + /// Run the interactive UI until the user exits (terminal) or the plan /// completes (headless). /// @@ -1172,6 +1187,18 @@ async fn run_interactive_surface( render_deadline = Some(expiry); } } + // The animating loader's next phase boundary: every paint clears + // the deadline it satisfied, and the boundary is the only wake a + // quiet turn has between stream events (a running tool, a + // provider gap, silent thinking) — without this arm the select + // parked until the 2s bash-activity poll, freezing the spinner + // and skipping whole seconds of the elapsed counter. + if let Some(started) = anim_started { + let next = next_spinner_deadline(started, Instant::now()); + if render_deadline.is_none_or(|armed| next < armed) { + render_deadline = Some(next); + } + } let was_active = session.turn_active; // The quiet tick's arming state, snapshotted before the select: // parked autocomplete requests and an armed selection auto-scroll @@ -1882,8 +1909,9 @@ async fn run_interactive_surface( || view.compaction.is_some() || view.share_loader.is_some(); if animating { - let started = *anim_started.get_or_insert_with(Instant::now); - let phase = (started.elapsed().as_millis() / SPINNER_INTERVAL_MS) as usize; + let now = Instant::now(); + let started = *anim_started.get_or_insert(now); + let phase = (now.duration_since(started).as_millis() / SPINNER_INTERVAL_MS) as usize; view.pulse_frame = phase; if phase != last_pulse_phase { session.dirty = true; @@ -1891,8 +1919,7 @@ async fn run_interactive_surface( // Arm the next phase boundary: without a deadline the select // would only wake on the 50ms tick, adding up to a full tick // of spinner latency to every phase change. - let next_phase = - started + Duration::from_millis(SPINNER_INTERVAL_MS as u64 * (phase as u64 + 1)); + let next_phase = next_spinner_deadline(started, now); if render_deadline.is_none_or(|deadline| deadline > next_phase) { render_deadline = Some(next_phase); } @@ -1964,9 +1991,14 @@ async fn run_interactive_surface( } else { // Headless capture keeps the per-change frame sequence // the verifiers assert on: no wall-clock interval applies. + // The paint bookkeeping matches the terminal's: a paint + // satisfied the armed deadline, and the pre-select + // inventory re-arms every wake still pending, so the + // harness runs the terminal's wake path. renderer.render_headless(&mut session, &mut view); session.dirty = false; last_pulse_phase = view.pulse_frame; + render_deadline = None; } } else if render_deadline.is_some_and(|deadline| deadline <= Instant::now()) { // A fired deadline with nothing dirty to paint must not @@ -2110,3 +2142,26 @@ async fn run_interactive_surface( } Ok(outcome) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The boundary the select arms is always the current phase's + /// 80ms edge — the wake must precede the phase change it observes + /// (an off-by-one parks the loop for a whole boundary). Samples sit + /// at least a millisecond inside their phase because `Instant` + /// round-trips can lose sub-millisecond ticks on the platform + /// clock, and the phase is floored from whole milliseconds. + #[test] + fn spinner_deadline_is_the_current_phase_edge() { + let started = Instant::now(); + let at = |ms: u64| started + Duration::from_millis(ms); + assert_eq!(next_spinner_deadline(started, started), at(80)); + assert_eq!(next_spinner_deadline(started, at(1)), at(80)); + assert_eq!(next_spinner_deadline(started, at(79)), at(80)); + assert_eq!(next_spinner_deadline(started, at(81)), at(160)); + assert_eq!(next_spinner_deadline(started, at(161)), at(240)); + assert_eq!(next_spinner_deadline(started, at(239)), at(240)); + } +} diff --git a/crates/pa-tui/tests/loader_quiet_turn_headless.rs b/crates/pa-tui/tests/loader_quiet_turn_headless.rs new file mode 100644 index 000000000..b983e0c0f --- /dev/null +++ b/crates/pa-tui/tests/loader_quiet_turn_headless.rs @@ -0,0 +1,283 @@ +//! Headless e2e for the loader during a quiet turn: while a turn runs +//! with no stream events (a foreground tool, a provider gap, silent +//! thinking), the spinner keeps its 80ms cadence and the elapsed +//! counter advances one second at a time. The regression: every paint +//! cleared the frame deadline and nothing re-armed the spinner's phase +//! boundary, so the quiet select parked — the loader froze and the +//! counter skipped whole seconds. +#![cfg(unix)] + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; + +use pa_tui::interactive::{ + run_interactive, HeadlessPlan, HeadlessStep, InteractiveOptions, ModelSelection, + SessionSelection, UiMode, +}; +use serde_json::{json, Value}; + +/// One mock daemon: serves one client connection, answering the attach +/// with a mid-turn snapshot and then staying silent until the turn ends. +struct MockSupervisor { + listener: UnixListener, +} + +impl MockSupervisor { + fn bind(socket: &std::path::Path) -> Self { + MockSupervisor { + listener: UnixListener::bind(socket).expect("bind mock socket"), + } + } + + /// Serve one client connection until it goes quiet (bounded, so the + /// plan teardown join always finishes). + fn serve(self) { + self.listener + .set_nonblocking(true) + .expect("nonblocking mock listener"); + let idle_window = std::time::Duration::from_millis(1500); + let idle_until = std::time::Instant::now() + idle_window; + let (stream, _) = loop { + match self.listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= idle_until { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(_) => return, + } + }; + let mut writer = stream.try_clone().expect("clone mock socket"); + let mut reader = BufReader::new(stream); + + let hello = json!({ + "type": "daemon_hello", + "protocol": { "name": "prime-agent.daemon", "version": 7 }, + "serverCapabilities": [], + "clientId": "mock", + }); + write_json(&mut writer, &hello); + + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let Ok(envelope) = serde_json::from_str::(line.trim()) else { + continue; + }; + let id = envelope.get("id").and_then(Value::as_str).unwrap_or(""); + let command = envelope.get("command").cloned().unwrap_or(Value::Null); + let command_type = command + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + match command_type.as_str() { + "create" => { + write_json( + &mut writer, + &json!({ + "type": "response", + "id": id, + "command": "create", + "success": true, + "data": { + "activeSessionId": "s1", + "id": "s1", + "sessionId": "sess-1", + "sessionFile": "/tmp/sess-1.jsonl", + }, + }), + ); + } + "attach" => { + write_json(&mut writer, &attach_data(id)); + // No stream event arrives until the turn ends from a + // side thread (the reader loop keeps answering the + // client's post-attach requests): the quiet window a + // running tool or a provider gap leaves. + let mut event_writer = writer.try_clone().expect("clone event socket"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(QUIET_TURN_MS)); + write_json( + &mut event_writer, + &json!({ + "type": "session_event", + "activeSessionId": "s1", + "event": { "type": "turn_end" }, + }), + ); + }); + } + _ => { + write_json( + &mut writer, + &json!({ + "type": "response", + "id": id, + "command": command_type, + "success": true, + "data": {}, + }), + ); + } + } + } + } +} + +fn write_json(writer: &mut UnixStream, value: &Value) { + let mut line = serde_json::to_string(value).expect("serialize mock frame"); + line.push('\n'); + writer.write_all(line.as_bytes()).expect("write mock frame"); + writer.flush().expect("flush mock frame"); +} + +/// The attach result: a streaming mid-turn snapshot whose prompt landed +/// just now, so the loader's elapsed counter starts at 0s. +fn attach_data(id: &str) -> Value { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after the epoch") + .as_millis(); + json!({ + "type": "response", + "id": id, + "command": "attach", + "success": true, + "data": { + "protocol": { "name": "prime-agent.daemon", "version": 7 }, + "activeSessionId": "s1", + "snapshot": { + "activeSessionId": "s1", + "summary": { "id": "s1", "cwd": "/tmp" }, + "state": { + "activeSessionId": "s1", + "cwd": "/tmp", + "sessionId": "sess-1", + "sessionName": "quiet turn session", + "model": null, + "isStreaming": true, + "isCompacting": false, + "sessionActions": { "queuedCount": 0, "steering": [], "followUps": [] }, + }, + "messages": [ + { + "role": "user", + "content": [{ "type": "text", "text": "run the sweep" }], + "timestamp": now_ms, + }, + ], + "lastEventSequence": 0, + "lastEventCursor": null, + }, + "client": { "id": "mock", "capabilities": [] }, + "lastEventSequence": 0, + "lastEventCursor": null, + }, + }) +} + +fn options(socket: PathBuf) -> InteractiveOptions { + InteractiveOptions { + models: None, + socket_path: socket, + cwd: PathBuf::from("/tmp"), + session_dir: None, + script_path: None, + model_selection: ModelSelection::default(), + model_catalog: Vec::new(), + model_configured_providers: std::collections::HashSet::default(), + model_recent_models: Vec::new(), + default_thinking_level: None, + no_session: false, + session: SessionSelection::New, + initial_message: None, + show_images: true, + fullscreen_mouse: false, + theme: "prime".to_string(), + code_block_indent: " ".to_string(), + tree_filter_mode: String::new(), + branch_summary_skip_prompt: false, + version: "0.0.0".to_string(), + onboarding: None, + telemetry_disabled: None, + client_auth: None, + traces: None, + provider_auth: None, + update_commands: None, + telemetry: None, + keybindings: pa_tui::keybindings::KeybindingsManager::new(), + session_rlm_depth: None, + prompt_stash: std::sync::Arc::default(), + session_has_children: false, + restore_dock_focus: false, + client_settings: None, + } +} + +/// How long the mock's turn stays silent before `turn_end`. +const QUIET_TURN_MS: u64 = 3500; + +#[test] +fn a_quiet_turn_keeps_the_loader_animating() { + // The ambient TMUX variable adds a startup notice to the + // transcript; scrub it so the run is the same inside tmux and out. + std::env::remove_var("TMUX"); + let dir = tempfile::TempDir::new().expect("temp dir"); + let socket = dir.path().join("tui.sock"); + let supervisor = MockSupervisor::bind(&socket); + let handle = std::thread::spawn(move || supervisor.serve()); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + // No input during the window: the loader's own phase boundary is + // the only thing that can wake the loop. + let plan = HeadlessPlan { + steps: vec![HeadlessStep::WaitMs(3000)], + width: 100, + height: 40, + }; + let outcome = runtime + .block_on(run_interactive(options(socket), UiMode::Headless(plan))) + .expect("interactive run"); + handle.join().expect("mock supervisor finished"); + + let loader_lines: Vec<&str> = outcome + .frames + .iter() + .filter_map(|frame| frame.lines().find(|line| line.contains("Waiting"))) + .collect(); + let elapsed: Vec = loader_lines + .iter() + .filter_map(|line| { + line.split('·') + .nth(1)? + .trim() + .split(|c: char| !c.is_ascii_digit()) + .next()? + .parse() + .ok() + }) + .collect(); + let transcript = loader_lines.join("\n"); + // The plan's 3s of quiet at the 80ms cadence paints ~37 loader + // frames before the counter reaches 3s; the parked loop painted the + // 0s frame and nothing else until the plan ended. + let quiet_frames = elapsed.iter().filter(|secs| **secs < 3).count(); + assert!( + quiet_frames >= 10, + "the spinner repaints through the quiet turn ({quiet_frames} frames under 3s):\n{transcript}" + ); + assert!( + elapsed.contains(&1) && elapsed.contains(&2), + "the elapsed counter paints every whole second:\n{transcript}" + ); +}