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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user