fix(tui): base the typing backoff on keystrokes, not provider output

time_since_activity tracks provider stream events, so typing into an idle
session left it unchanged and the composing backoff never engaged. Track the
last key/mouse/paste event separately and key the backoff off that.

Measured live with scripts/repro_input_lag.py against a real server: burst
keystroke p50 32ms -> 2.2ms, and repaint-settle p50 ~2000ms -> 6-9ms.
This commit is contained in:
jeremy
2026-07-28 02:45:00 -07:00
parent e0b8f07f27
commit c9ccb4f01a
8 changed files with 59 additions and 9 deletions
+16 -2
View File
@@ -884,6 +884,14 @@ pub struct App {
context_revision: u64,
// Track last streaming activity for "stale" detection
last_stream_activity: Option<Instant>,
// When the user last pressed a key, mouse-scrolled, or pasted.
//
// Distinct from `last_stream_activity`, which tracks *provider* output: a
// user typing into an idle session produces no stream events at all, so that
// field cannot tell "actively composing" from "sitting untouched". The redraw
// scheduler needs the difference so it can keep the decorative animation out
// of the way of keystrokes.
last_user_interaction: Option<Instant>,
// Provider has emitted MessageEnd, but the turn is still finalizing bookkeeping.
stream_message_ended: bool,
// A remote Done received while paced text is still buffered. The redraw
@@ -1306,6 +1314,14 @@ pub struct App {
/// Hash of the todo payload rendered into the inline chat todo card, used
/// to keep the card live-updating while it stays in the transcript.
todo_card_rendered_hash: u64,
/// JSON payload for the pinned todo band (display.pin_todos). `None` when
/// the feature is off or the session has no todos. Refreshed on tick.
pinned_todos_payload: Option<String>,
/// Hash of the todo payload behind `pinned_todos_payload`, used to skip
/// re-serializing when nothing changed between ticks.
pinned_todos_rendered_hash: u64,
/// Last time the pinned todo band re-read todos from disk (1s throttle).
pinned_todos_checked_at: Option<Instant>,
last_side_panel_refresh: Option<Instant>,
// Most recently persisted focus target for dictation routing.
last_client_focus_recorded_at: Option<Instant>,
@@ -1448,8 +1464,6 @@ pub struct App {
active_experimental_feature_notice: Option<String>,
// Message to interleave during processing (set via Ctrl+Enter in queue mode)
interleave_message: Option<String>,
// Image attachments associated with the staged interleave message.
interleave_images: Vec<(String, String)>,
// Message sent as soft interrupt but not yet injected (shown in queue preview until injected)
pending_soft_interrupts: Vec<String>,
// Soft interrupts written to the socket but not yet acknowledged by the server.
+3
View File
@@ -109,6 +109,9 @@ impl App {
}
pub(super) fn note_client_interaction(&mut self) {
// Every key/mouse/paste event routes through here, which makes this the
// one place that reliably knows the user is actively driving the UI.
self.last_user_interaction = Some(std::time::Instant::now());
// A terminal only delivers key/mouse/paste events to the focused window,
// so receiving one is proof this window is focused *right now*. Adopt that
// focus state directly instead of relying solely on FocusGained reports:
@@ -28,7 +28,6 @@ impl App {
self.push_display_message(DisplayMessage::system(message).with_title(title));
}
self.interleave_message = None;
self.interleave_images.clear();
self.rate_limit_pending_message = restored.rate_limit_pending_message;
self.rate_limit_reset = restored.rate_limit_reset;
self.observe_page_markdown = restored.observe_page_markdown;
@@ -399,6 +398,7 @@ impl App {
context_info: crate::prompt::ContextInfo::default(),
context_revision: 0,
last_stream_activity: None,
last_user_interaction: None,
stream_message_ended: false,
deferred_stream_done_id: None,
remote_resume_activity: None,
@@ -578,6 +578,9 @@ impl App {
todos_view_updated_at_ms: 0,
todos_view_rendered_hash: 0,
todo_card_rendered_hash: 0,
pinned_todos_payload: None,
pinned_todos_rendered_hash: 0,
pinned_todos_checked_at: None,
last_side_panel_refresh: None,
last_client_focus_recorded_at: None,
last_client_focus_session_id: None,
@@ -639,7 +642,6 @@ impl App {
experimental_feature_warnings_seen: HashSet::new(),
active_experimental_feature_notice: None,
interleave_message: None,
interleave_images: Vec::new(),
pending_soft_interrupts: Vec::new(),
pending_soft_interrupt_requests: Vec::new(),
autoreview_after_current_turn: false,
@@ -831,6 +833,7 @@ impl App {
context_info,
context_revision: 0,
last_stream_activity: None,
last_user_interaction: None,
stream_message_ended: false,
deferred_stream_done_id: None,
remote_resume_activity: None,
@@ -1010,6 +1013,9 @@ impl App {
todos_view_updated_at_ms: 0,
todos_view_rendered_hash: 0,
todo_card_rendered_hash: 0,
pinned_todos_payload: None,
pinned_todos_rendered_hash: 0,
pinned_todos_checked_at: None,
last_side_panel_refresh: None,
last_client_focus_recorded_at: None,
last_client_focus_session_id: None,
@@ -1071,7 +1077,6 @@ impl App {
experimental_feature_warnings_seen: HashSet::new(),
active_experimental_feature_notice: None,
interleave_message: None,
interleave_images: Vec::new(),
pending_soft_interrupts: Vec::new(),
pending_soft_interrupt_requests: Vec::new(),
autoreview_after_current_turn: false,
@@ -585,6 +585,10 @@ impl crate::tui::TuiState for App {
&self.streaming.streaming_text
}
fn pinned_todos_payload(&self) -> Option<&str> {
self.pinned_todos_payload_ref()
}
fn input(&self) -> &str {
&self.input
}
@@ -796,6 +800,10 @@ impl crate::tui::TuiState for App {
App::client_focused(self)
}
fn time_since_user_interaction(&self) -> Option<std::time::Duration> {
self.last_user_interaction.map(|at| at.elapsed())
}
fn stream_message_ended(&self) -> bool {
self.stream_message_ended
}
+17 -1
View File
@@ -36,7 +36,8 @@ mod redraw_schedule;
pub(crate) use redraw_schedule::{
REDRAW_DEEP_IDLE, REDRAW_DEEP_IDLE_AFTER, REDRAW_IDLE, REDRAW_PASSIVE_LIVENESS,
REDRAW_REMOTE_STARTUP, REDRAW_SWARM_SPINNER, idle_donut_active, last_full_frame_redraw_reason,
periodic_redraw_required, periodic_redraw_required_excluding_idle_animation, redraw_interval,
current_full_frame_redraw_reason, periodic_redraw_required,
periodic_redraw_required_excluding_idle_animation, redraw_interval,
redraw_interval_with_policy,
};
mod remote_diff;
@@ -199,6 +200,12 @@ pub trait TuiState {
/// Version counter for display_messages (monotonic, increments on mutation)
fn display_messages_version(&self) -> u64;
fn streaming_text(&self) -> &str;
/// JSON payload for the pinned todo band rendered at the top of the chat
/// viewport when `display.pin_todos` is enabled. `None` when the feature
/// is off or the session has no todos.
fn pinned_todos_payload(&self) -> Option<&str> {
None
}
// ---- Input ----
fn input(&self) -> &str;
@@ -350,6 +357,15 @@ pub trait TuiState {
fn connected_clients(&self) -> Option<usize>;
/// Short-lived notice shown in the status line (e.g., model switch, toggle diff)
fn status_notice(&self) -> Option<String>;
/// How long since the user last pressed a key, scrolled, or pasted, or
/// `None` when they have not interacted yet.
///
/// Distinct from [`time_since_activity`], which tracks provider output:
/// typing into an idle session produces no stream events, so only this can
/// tell "actively composing" from "sitting untouched".
fn time_since_user_interaction(&self) -> Option<Duration> {
None
}
/// Distinct learned-keybinding nudge shown in its own pop-out color, e.g.
/// "you usually do X the slow way, press <key>". Separate from
/// [`status_notice`] so the UI can style it differently.
+1 -1
View File
@@ -280,7 +280,7 @@ const COMPOSING_ANIMATION_BACKOFF: Duration = Duration::from_millis(600);
fn actively_composing(state: &dyn TuiState) -> bool {
!state.input().is_empty()
&& state
.time_since_activity()
.time_since_user_interaction()
.is_some_and(|since| since < COMPOSING_ANIMATION_BACKOFF)
}
@@ -129,7 +129,7 @@ fn active_typing_backs_the_decorative_animation_off() {
let typing = TestState {
input: "hel".to_string(),
cursor_pos: 3,
time_since_activity: Some(Duration::from_millis(30)),
time_since_user_interaction: Some(Duration::from_millis(30)),
..Default::default()
};
let interval = crate::tui::redraw_interval_with_policy(&typing, &policy);
@@ -148,7 +148,7 @@ fn a_paused_draft_lets_the_animation_recover() {
let paused = TestState {
input: "a draft i walked away from".to_string(),
cursor_pos: 5,
time_since_activity: Some(Duration::from_secs(3)),
time_since_user_interaction: Some(Duration::from_secs(3)),
..Default::default()
};
assert_eq!(
+4
View File
@@ -145,6 +145,7 @@ struct TestState {
chat_overscroll_active: bool,
cache_ttl_status: Option<crate::tui::CacheTtlInfo>,
status_notice: Option<String>,
time_since_user_interaction: Option<Duration>,
swarm_members: Vec<crate::protocol::SwarmMemberStatus>,
transcript_swarm_members: Option<Vec<crate::protocol::SwarmMemberStatus>>,
swarm_panel_selected: usize,
@@ -309,6 +310,9 @@ impl crate::tui::TuiState for TestState {
fn status_notice(&self) -> Option<String> {
self.status_notice.clone()
}
fn time_since_user_interaction(&self) -> Option<Duration> {
self.time_since_user_interaction
}
fn inline_swarm_gallery_active(&self) -> bool {
!self.swarm_members.is_empty()
}