perf(tui): an idle session relaxes its poll during prompts and background jobs (#6728)

The event loop only reached its 250 ms quiet poll when nothing at all was
live, so the states an idle user actually sits in kept the 24-48 ms poll:
- an approval, elevation or question card on top (the turn is still
  loading, so the active 24 ms poll applied);
- a running background job (durable task) or a once-a-second label, which
  counted as "animating" or "busy" even though the frame requester already
  wakes the loop at the next frame deadline.

Now a waiting decision prompt takes the idle path and counts as quiescent;
only motion faster than the quiet poll counts as animation (slower motion
is woken by its frame deadline); a live durable task no longer blocks the
quiet poll (the task panel refreshes on its own 2.5 s cadence). The
terminal input thread polls every 200 ms instead of 50 ms after 5 s
without input, which keeps the child-terminal pause ack well inside its
500 ms timeout. `decision_prompt_waiting` replaces the duplicated
ModalKind match in status_animation_interval_ms.

Known limits: the loop is still polled, not woken, so an engine event, a
remote decision or a prompt timeout lands up to 250 ms late once the UI
has been quiet for 5 s. The input pump still wakes 5x/s at rest.

Measured on macOS arm64, release builds, codewhale process only, 60 s
windows (30 s where noted), timer wakeups from proc_pid_rusage
interrupt_wkups, through a loopback OpenAI-compatible provider (no
provider spend). Before: installed 0.10.2 (3400ebb5c9; this poll logic is
unchanged between it and main). After: release build of this tree.
| state                     | before wakeups/s, CPU | after wakeups/s, CPU |
| launch screen             | ~25 (composer), 0.20% | 10.4, 0.22% (30 s)   |
| after a finished turn     | 25.0, 0.23% (30 s)    | 10.8, 0.17% (30 s)   |
| approval card waiting     | 29.4, 0.35% (30 s)    | 12.6, 0.17%          |
| background `sleep 900`    | 32.4, 0.45%           | 15.9, 0.21%          |
The approval card still decided normally (`y` ran the command and wrote
its file). Not measured: FreeBSD, where the reporter's ktrace showed the
per-wake clock_gettime cost dominating.

Tests: scripts/dev-test.sh tui idle_poll_tests terminal_input
session_manager persistence_actor thread_history runtime_store_convergence
tui::approval snapshot checkpoint resume motion: 784 tests run: 784
passed, 13351 skipped (idle_poll_tests 5, tui::approval 128). The
idle-poll test row for durable tasks was removed with the field.
Release build: Finished release in 8m 43s, 0 warnings.

Refs #6728, CW-6993

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 14:19:02 -07:00
1 parent a4bbb21564
commit ccda707286
6 files changed
+77 -45

No files matched your search

+11
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
- **`codewhale-dependencies`, `codewhale-snapshot`, `codewhale-lsp`, `codewhale-repl` crates:** external-tool resolution, workspace snapshots, the language-server client and the Python REPL runtime moved out of `codewhale-tui` into their own crates; `write_context_file` now lives in `codewhale-repl`. No behavior change. (CW-6129)
- **`codewhale-skill-install` crate:** the community-skill installer, package content digest and `SKILL.md` frontmatter reader move out of `codewhale-tui` into a new crate; the install SSRF guard and timeouts are unchanged in production builds. (CW-6129)
@@ -171,6 +173,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
seconds without input or engine events, as it already did in an idle
session, instead of starting about a dozen git processes every 2 seconds for
as long as the job lives (#6728).
- An idle session wakes less often. While an approval, elevation or question
prompt waits on you, while a background job runs, or while only a
once-a-second label ticks, the event loop now relaxes to its 250 ms quiet
poll instead of polling every 24 to 48 ms; animations and the prompt's
timeout still wake it on their own deadlines. The terminal input thread
polls every 200 ms instead of 50 ms after 5 seconds without input. Measured
on macOS with release builds: an approval wait went from 29 to 12 timer
wakeups per second, a background job from 32 to 16 and an idle composer
from 25 to 11 (#6728).
- `write` over an existing file now says when it kept that file's CRLF or LF
line endings, so the agent no longer reports a line-ending conversion that
did not happen (#7070).
+11
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
- **`codewhale-dependencies`, `codewhale-snapshot`, `codewhale-lsp`, `codewhale-repl` crates:** external-tool resolution, workspace snapshots, the language-server client and the Python REPL runtime moved out of `codewhale-tui` into their own crates; `write_context_file` now lives in `codewhale-repl`. No behavior change. (CW-6129)
- **`codewhale-skill-install` crate:** the community-skill installer, package content digest and `SKILL.md` frontmatter reader move out of `codewhale-tui` into a new crate; the install SSRF guard and timeouts are unchanged in production builds. (CW-6129)
@@ -171,6 +173,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
seconds without input or engine events, as it already did in an idle
session, instead of starting about a dozen git processes every 2 seconds for
as long as the job lives (#6728).
- An idle session wakes less often. While an approval, elevation or question
prompt waits on you, while a background job runs, or while only a
once-a-second label ticks, the event loop now relaxes to its 250 ms quiet
poll instead of polling every 24 to 48 ms; animations and the prompt's
timeout still wake it on their own deadlines. The terminal input thread
polls every 200 ms instead of 50 ms after 5 seconds without input. Measured
on macOS with release builds: an approval wait went from 29 to 12 timer
wakeups per second, a background job from 32 to 16 and an idle composer
from 25 to 11 (#6728).
- `write` over an existing file now says when it kept that file's CRLF or LF
line endings, so the agent no longer reports a line-ending conversion that
did not happen (#7070).
+25 -22
View File
@@ -5081,13 +5081,19 @@ pub(crate) async fn run_event_loop(
let underwater_motion =
underwater_ambient_motion || underwater_completion_motion || launch_motion;
let animation_active = status_motion || underwater_motion;
let animation_interval = Duration::from_millis(animation_interval_ms(
app,
status_motion,
underwater_motion,
cadence_tier,
));
// #6728: what the loop knows about its own quiet. Anything that
// wants a prompt reaction moves `last_ui_activity`; the idle poll
// and the automation scan back off from it.
let idle_facts = IdleFacts {
has_running_agents,
animation_active,
durable_tasks_active,
animation_active: animation_active
&& animation_interval < Duration::from_millis(UI_QUIESCENT_POLL_MS),
input_pending: !pending_terminal_events.is_empty(),
pending_engine_op: pending_subagent_list_refresh,
};
@@ -5113,12 +5119,6 @@ pub(crate) async fn run_event_loop(
),
));
}
let animation_interval = Duration::from_millis(animation_interval_ms(
app,
status_motion,
underwater_motion,
cadence_tier,
));
let motion_policy = app.motion_policy();
if animation_active && last_status_frame.elapsed() >= animation_interval {
let translation_animated = streaming_thinking::animate_pending_translation(
@@ -5325,20 +5325,23 @@ pub(crate) async fn run_event_loop(
}
}
let mut poll_timeout =
if app.is_loading || has_running_agents || app.is_compacting || app.is_purging {
Duration::from_millis(active_poll_ms(app))
} else {
// Relaxes only once the UI has been quiescent and quiet for
// `UI_QUIESCENT_AFTER` (#6728); every deadline below still
// shortens it, and input returns the loop at once.
idle_poll_duration(
app,
&idle_facts,
now,
now.saturating_duration_since(last_ui_activity),
)
};
let mut poll_timeout = if (app.is_loading && !decision_prompt_waiting(app))
|| has_running_agents
|| app.is_compacting
|| app.is_purging
{
Duration::from_millis(active_poll_ms(app))
} else {
// Relaxes only once the UI has been quiescent and quiet for
// `UI_QUIESCENT_AFTER` (#6728); every deadline below still
// shortens it, and input returns the loop at once.
idle_poll_duration(
app,
&idle_facts,
now,
now.saturating_duration_since(last_ui_activity),
)
};
if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
poll_timeout = poll_timeout.min(until_flush);
}
+5 -7
View File
@@ -158,13 +158,11 @@ pub(crate) fn rail_min_chat_width(idle_empty: bool) -> u16 {
pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
if app.effective_low_motion_for_status() {
crate::tui::display_refresh::adaptive_animation_interval_ms(true)
} else if matches!(
app.view_stack.top_kind(),
Some(ModalKind::Approval | ModalKind::Elevation | ModalKind::UserInput)
) || !(app.is_loading
|| app.is_compacting
|| app.is_purging
|| crate::tui::subagent_routing::running_agent_count(app) > 0)
} else if decision_prompt_waiting(app)
|| !(app.is_loading
|| app.is_compacting
|| app.is_purging
|| crate::tui::subagent_routing::running_agent_count(app) > 0)
{
UI_QUIET_STATUS_ANIMATION_MS
} else {
+15 -15
View File
@@ -821,10 +821,10 @@ pub(crate) const UI_QUIESCENT_POLL_MS: u64 = 250;
pub(crate) struct IdleFacts {
/// Live sub-agents (`running_agent_count(app) > 0`).
pub(crate) has_running_agents: bool,
/// A spinner, the underwater scene, or a launch animation wants frames.
/// A spinner, the underwater scene, or a launch animation wants frames
/// faster than [`UI_QUIESCENT_POLL_MS`]; slower motion is woken by its
/// own frame deadline.
pub(crate) animation_active: bool,
/// Durable tasks queued, running or waiting.
pub(crate) durable_tasks_active: bool,
/// A terminal event is already buffered for this iteration.
pub(crate) input_pending: bool,
/// A sub-agent list refresh is waiting for room in the engine mailbox.
@@ -837,18 +837,18 @@ pub(crate) struct IdleFacts {
/// the list errs towards "busy".
pub(crate) fn ui_state_is_quiescent(app: &App, facts: &IdleFacts, now: Instant) -> bool {
let toast_live = |toast: &StatusToast| toast.ttl_ms.is_some() && !toast.is_expired(now);
!(app.is_loading
let deciding = decision_prompt_waiting(app);
!((app.is_loading && !deciding)
|| app.is_compacting
|| app.is_purging
|| app.turn_started_at.is_some()
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|| (app.turn_started_at.is_some() && !deciding)
|| (matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) && !deciding)
|| facts.has_running_agents
|| facts.animation_active
|| facts.durable_tasks_active
|| facts.input_pending
|| facts.pending_engine_op
|| app.needs_redraw
|| !app.view_stack.is_empty()
|| (!app.view_stack.is_empty() && !deciding)
|| app.onboarding != OnboardingState::None
|| app.redaction_gate
|| !app.queued_messages.is_empty()
@@ -862,6 +862,13 @@ pub(crate) fn ui_state_is_quiescent(app: &App, facts: &IdleFacts, now: Instant)
|| app.sticky_status.as_ref().is_some_and(toast_live))
}
pub(crate) fn decision_prompt_waiting(app: &App) -> bool {
matches!(
app.view_stack.top_kind(),
Some(ModalKind::Approval | ModalKind::Elevation | ModalKind::UserInput)
)
}
/// The idle poll for this iteration: [`idle_poll_ms`] normally, relaxed to
/// [`UI_QUIESCENT_POLL_MS`] once the UI has been quiescent (see
/// [`ui_state_is_quiescent`]) and quiet for [`UI_QUIESCENT_AFTER`]. `quiet_for`
@@ -991,13 +998,6 @@ mod idle_poll_tests {
},
"something is animating",
),
(
IdleFacts {
durable_tasks_active: true,
..IdleFacts::default()
},
"a durable task is live",
),
(
IdleFacts {
input_pending: true,
+10 -1
View File
@@ -12,6 +12,8 @@ use std::time::{Duration, Instant};
use crossterm::event::{self, Event};
const TERMINAL_INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
const TERMINAL_INPUT_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(200);
const TERMINAL_INPUT_IDLE_AFTER: Duration = Duration::from_secs(5);
const TERMINAL_INPUT_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(500);
pub(super) const TERMINAL_INPUT_STALL_TIMEOUT: Duration = Duration::from_secs(5);
pub(super) const TERMINAL_INPUT_RECOVERY_COOLDOWN: Duration = Duration::from_secs(10);
@@ -186,6 +188,7 @@ impl TerminalInputPump {
.name("codewhale-terminal-input".to_string())
.spawn(move || {
let mut last_heartbeat = Instant::now();
let mut last_event = last_heartbeat;
while !thread_stop.load(Ordering::Acquire) {
if thread_paused.load(Ordering::Acquire) {
thread_paused_ack.store(true, Ordering::Release);
@@ -193,11 +196,17 @@ impl TerminalInputPump {
continue;
}
thread_paused_ack.store(false, Ordering::Release);
match event::poll(TERMINAL_INPUT_POLL_INTERVAL) {
let poll_interval = if last_event.elapsed() < TERMINAL_INPUT_IDLE_AFTER {
TERMINAL_INPUT_POLL_INTERVAL
} else {
TERMINAL_INPUT_IDLE_POLL_INTERVAL
};
match event::poll(poll_interval) {
Ok(true) if thread_stop.load(Ordering::Acquire) => break,
Ok(true) => match event::read() {
Ok(event) => {
last_heartbeat = Instant::now();
last_event = last_heartbeat;
let observed = ObservedTerminalEvent::new(event, last_heartbeat);
if tx.send(TerminalInputMessage::Event(observed)).is_err() {
break;