aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-08 23:04:16 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 04:26:19 +0300
commit615310e15c6a7b273947dafe0b3fe01290757c96 (patch)
tree0b203f16cacd3cb2eab8dd25d17c7b63ab8ac7dd /src
parentf18bee9f5c43e56ebdcb7e5fad17f3236de4d08e (diff)
fix: debounce idle to working agent transitions
Diffstat (limited to 'src')
-rw-r--r--src/pane.rs476
1 files changed, 468 insertions, 8 deletions
diff --git a/src/pane.rs b/src/pane.rs
index e0578b98..c53d99ef 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -124,6 +124,15 @@ const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration = std::time::Duration::fro
const AGENT_PENDING_IDLE_RECHECK: std::time::Duration = std::time::Duration::from_millis(100);
const AGENT_PENDING_IDLE_CONFIRMATIONS: u8 = 3;
const AGENT_PENDING_IDLE_CAP: std::time::Duration = std::time::Duration::from_millis(700);
+const AGENT_PENDING_WORKING_FAST_RECHECK: std::time::Duration =
+ std::time::Duration::from_millis(100);
+const AGENT_PENDING_WORKING_SLOW_RECHECK: std::time::Duration =
+ std::time::Duration::from_millis(250);
+const AGENT_PENDING_WORKING_FAST_WINDOW: std::time::Duration =
+ std::time::Duration::from_millis(500);
+const AGENT_PENDING_WORKING_CAP: std::time::Duration = std::time::Duration::from_secs(2);
+const AGENT_PENDING_WORKING_CONFIRM_DELAY: std::time::Duration =
+ std::time::Duration::from_millis(250);
#[derive(Debug, Clone, Copy)]
struct AgentDetectionPresence {
@@ -391,6 +400,103 @@ impl PendingIdleConfirmation {
}
}
+#[derive(Debug, Default)]
+struct PendingWorkingConfirmation {
+ started_at: Option<std::time::Instant>,
+ last_observed_render_seq: u64,
+}
+
+impl PendingWorkingConfirmation {
+ fn active(&self) -> bool {
+ self.started_at.is_some()
+ }
+
+ fn clear(&mut self) {
+ self.started_at = None;
+ self.last_observed_render_seq = 0;
+ }
+
+ fn recheck_delay(&self, now: std::time::Instant) -> std::time::Duration {
+ let Some(started_at) = self.started_at else {
+ return AGENT_PENDING_WORKING_FAST_RECHECK;
+ };
+ if now.duration_since(started_at) < AGENT_PENDING_WORKING_FAST_WINDOW {
+ AGENT_PENDING_WORKING_FAST_RECHECK
+ } else {
+ AGENT_PENDING_WORKING_SLOW_RECHECK
+ }
+ }
+
+ fn should_hold_idle_to_working(
+ &mut self,
+ previous: DetectionPublishState,
+ next: DetectionPublishState,
+ agent_changed: bool,
+ process_exited: bool,
+ pty_signal: Option<PtyActivitySignal>,
+ now: std::time::Instant,
+ ) -> bool {
+ let is_idle_to_working = previous.state == AgentState::Idle
+ && next.state == AgentState::Working
+ && !next.visible_blocker
+ && !agent_changed
+ && !process_exited;
+
+ if !is_idle_to_working {
+ self.clear();
+ return false;
+ }
+
+ let Some(pty_signal) = pty_signal else {
+ self.clear();
+ return false;
+ };
+ if !pty_signal.active {
+ self.clear();
+ return false;
+ }
+
+ let Some(started_at) = self.started_at else {
+ self.started_at = Some(now);
+ self.last_observed_render_seq = pty_signal.render_seq;
+ return true;
+ };
+
+ if pty_signal.fresh_render && pty_signal.render_seq != self.last_observed_render_seq {
+ self.last_observed_render_seq = pty_signal.render_seq;
+ if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CONFIRM_DELAY {
+ self.clear();
+ return false;
+ }
+ }
+
+ if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CAP {
+ self.clear();
+ return true;
+ }
+
+ true
+ }
+
+ fn should_publish_held_working_before_exit(
+ &mut self,
+ previous: DetectionPublishState,
+ next: DetectionPublishState,
+ process_exited: bool,
+ ) -> bool {
+ if self.started_at.is_none()
+ || !process_exited
+ || previous.state != AgentState::Idle
+ || next.state != AgentState::Idle
+ {
+ return false;
+ }
+
+ self.clear();
+ true
+ }
+}
+
fn pty_working_transition_is_vetoed(
agent: Option<Agent>,
previous: DetectionPublishState,
@@ -466,6 +572,8 @@ struct PtyCausalityTracker {
struct PtyActivitySignal {
active: bool,
tainted: bool,
+ fresh_render: bool,
+ render_seq: u64,
}
fn baseline_pty_causality(tracker: &mut PtyCausalityTracker, pty_render_seq: u64, input_seq: u64) {
@@ -489,10 +597,12 @@ fn agent_caused_pty_activity_active(
let tainted = tracker.input_tainted_until.is_some_and(|until| now < until);
+ let mut fresh_render = false;
if pty_render_seq != tracker.last_pty_render_seq {
tracker.last_pty_render_seq = pty_render_seq;
if !tainted {
tracker.last_agent_pty_at = Some(now);
+ fresh_render = true;
}
}
@@ -500,6 +610,8 @@ fn agent_caused_pty_activity_active(
return PtyActivitySignal {
active: false,
tainted: true,
+ fresh_render: false,
+ render_seq: tracker.last_pty_render_seq,
};
}
@@ -509,6 +621,8 @@ fn agent_caused_pty_activity_active(
PtyActivitySignal {
active,
tainted: false,
+ fresh_render,
+ render_seq: tracker.last_pty_render_seq,
}
}
@@ -548,9 +662,13 @@ fn spawn_basic_detection_task(
let mut pty_causality = PtyCausalityTracker::default();
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
+ let mut pending_working = PendingWorkingConfirmation::default();
loop {
- let sleep_duration = if pending_idle.active() {
+ let now_for_sleep = std::time::Instant::now();
+ let sleep_duration = if pending_working.active() {
+ pending_working.recheck_delay(now_for_sleep)
+ } else if pending_idle.active() {
AGENT_PENDING_IDLE_RECHECK
} else {
std::time::Duration::from_millis(300)
@@ -575,6 +693,7 @@ fn spawn_basic_detection_task(
pty_causality = PtyCausalityTracker::default();
agent_startup_grace_until = None;
pending_idle.clear();
+ pending_working.clear();
}
}
@@ -662,6 +781,7 @@ fn spawn_basic_detection_task(
agent_changed = previous_agent != agent;
if agent_changed {
pending_idle.clear();
+ pending_working.clear();
if agent.is_some() {
agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW);
baseline_pty_causality(
@@ -699,9 +819,11 @@ fn spawn_basic_detection_task(
if process_exited {
agent_startup_grace_until = None;
pending_idle.clear();
+ pending_working.clear();
} else {
if now < until {
pending_idle.clear();
+ pending_working.clear();
continue;
}
baseline_pty_causality(
@@ -711,6 +833,7 @@ fn spawn_basic_detection_task(
);
agent_startup_grace_until = None;
pending_idle.clear();
+ pending_working.clear();
continue;
}
}
@@ -720,6 +843,7 @@ fn spawn_basic_detection_task(
last_detection_text.clone_from(&content);
if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
pending_idle.clear();
+ pending_working.clear();
continue;
}
sync_content_change_acquisition(
@@ -735,15 +859,21 @@ fn spawn_basic_detection_task(
let Some(screen_detection) =
detection_update_for_publish(agent, &content, process_exited)
else {
+ pending_idle.clear();
+ pending_working.clear();
continue;
};
- let pty_signal = if agent.is_some() {
- let signal = agent_caused_pty_activity_active(
+ let pty_activity = if agent.is_some() {
+ Some(agent_caused_pty_activity_active(
pty_render_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
&mut pty_causality,
now,
- );
+ ))
+ } else {
+ None
+ };
+ let pty_signal = if let Some(signal) = pty_activity {
Some(crate::agent_detection_policy::PtySignal {
active: signal.active,
tainted: signal.tainted,
@@ -765,6 +895,7 @@ fn spawn_basic_detection_task(
}
crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
pending_idle.clear();
+ pending_working.clear();
continue;
}
};
@@ -791,6 +922,43 @@ fn spawn_basic_detection_task(
if pty_working_transition_is_vetoed(agent, previous_publish, next_publish, &content) {
pending_idle.clear();
+ pending_working.clear();
+ continue;
+ }
+
+ if pending_working.should_publish_held_working_before_exit(
+ previous_publish,
+ next_publish,
+ process_exited,
+ ) {
+ pending_idle.clear();
+ state = AgentState::Working;
+ last_visible_blocker = false;
+ last_visible_working = false;
+ last_visible_signal_refresh = None;
+ publish_state_changed_event(
+ state_events.clone(),
+ pane_id,
+ agent,
+ AgentState::Working,
+ false,
+ false,
+ false,
+ now,
+ )
+ .await;
+ continue;
+ }
+
+ if pending_working.should_hold_idle_to_working(
+ previous_publish,
+ next_publish,
+ agent_changed,
+ process_exited,
+ pty_activity,
+ now,
+ ) {
+ pending_idle.clear();
continue;
}
@@ -801,6 +969,7 @@ fn spawn_basic_detection_task(
process_exited,
now,
) {
+ pending_working.clear();
continue;
}
@@ -1905,15 +2074,19 @@ impl PaneRuntime {
let mut pty_causality = PtyCausalityTracker::default();
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
+ let mut pending_working = PendingWorkingConfirmation::default();
tokio::time::sleep(Duration::from_millis(50)).await;
loop {
- let tick = if active_pending_release(&pending_release_for_task, Instant::now())
+ let now_for_tick = Instant::now();
+ let tick = if active_pending_release(&pending_release_for_task, now_for_tick)
.is_some()
|| terminal.has_transient_default_color_override()
{
TICK_PENDING_RELEASE
+ } else if pending_working.active() {
+ pending_working.recheck_delay(now_for_tick)
} else if pending_idle.active() {
AGENT_PENDING_IDLE_RECHECK
} else if agent_presence.current_agent().is_none() {
@@ -1941,6 +2114,7 @@ impl PaneRuntime {
pty_causality = PtyCausalityTracker::default();
agent_startup_grace_until = None;
pending_idle.clear();
+ pending_working.clear();
}
}
@@ -2035,6 +2209,7 @@ impl PaneRuntime {
agent = agent_presence.current_agent();
if agent != previous_agent {
pending_idle.clear();
+ pending_working.clear();
if agent.is_some() {
agent_startup_grace_until =
Some(now + AGENT_STARTUP_GRACE_WINDOW);
@@ -2102,9 +2277,11 @@ impl PaneRuntime {
if process_exited {
agent_startup_grace_until = None;
pending_idle.clear();
+ pending_working.clear();
} else {
if now < until {
pending_idle.clear();
+ pending_working.clear();
continue;
}
baseline_pty_causality(
@@ -2114,6 +2291,7 @@ impl PaneRuntime {
);
agent_startup_grace_until = None;
pending_idle.clear();
+ pending_working.clear();
continue;
}
}
@@ -2123,6 +2301,7 @@ impl PaneRuntime {
last_detection_text.clone_from(&content);
if detect::should_skip_state_update(agent, &content) {
pending_idle.clear();
+ pending_working.clear();
continue;
}
sync_content_change_acquisition(
@@ -2138,15 +2317,21 @@ impl PaneRuntime {
let Some(screen_detection) =
detection_update_for_publish(agent, &content, process_exited)
else {
+ pending_idle.clear();
+ pending_working.clear();
continue;
};
- let pty_signal = if agent.is_some() {
- let signal = agent_caused_pty_activity_active(
+ let pty_activity = if agent.is_some() {
+ Some(agent_caused_pty_activity_active(
pty_render_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
&mut pty_causality,
now,
- );
+ ))
+ } else {
+ None
+ };
+ let pty_signal = if let Some(signal) = pty_activity {
Some(crate::agent_detection_policy::PtySignal {
active: signal.active,
tainted: signal.tainted,
@@ -2168,6 +2353,7 @@ impl PaneRuntime {
) => detection,
crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
pending_idle.clear();
+ pending_working.clear();
continue;
}
};
@@ -2201,6 +2387,43 @@ impl PaneRuntime {
&content,
) {
pending_idle.clear();
+ pending_working.clear();
+ continue;
+ }
+
+ if pending_working.should_publish_held_working_before_exit(
+ previous_publish,
+ next_publish,
+ process_exited,
+ ) {
+ pending_idle.clear();
+ state = AgentState::Working;
+ last_visible_blocker = false;
+ last_visible_working = false;
+ last_visible_signal_refresh = None;
+ publish_state_changed_event(
+ state_events.clone(),
+ pane_id,
+ agent,
+ AgentState::Working,
+ false,
+ false,
+ false,
+ now,
+ )
+ .await;
+ continue;
+ }
+
+ if pending_working.should_hold_idle_to_working(
+ previous_publish,
+ next_publish,
+ agent_changed,
+ process_exited,
+ pty_activity,
+ now,
+ ) {
+ pending_idle.clear();
continue;
}
@@ -2211,6 +2434,7 @@ impl PaneRuntime {
process_exited,
now,
) {
+ pending_working.clear();
continue;
}
@@ -3276,6 +3500,242 @@ mod tests {
assert!(!pending.active());
}
+ fn pty_activity(active: bool, fresh_render: bool, render_seq: u64) -> PtyActivitySignal {
+ PtyActivitySignal {
+ active,
+ tainted: false,
+ fresh_render,
+ render_seq,
+ }
+ }
+
+ #[test]
+ fn pending_working_holds_single_twitch_then_clears_when_quiet() {
+ let now = std::time::Instant::now();
+ let idle = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let working = DetectionPublishState {
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let mut pending = PendingWorkingConfirmation::default();
+
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 10)),
+ now
+ ));
+ assert!(pending.active());
+ assert!(!pending.should_hold_idle_to_working(
+ idle,
+ idle,
+ false,
+ false,
+ Some(pty_activity(false, false, 10)),
+ now + AGENT_PTY_ACTIVITY_WINDOW
+ ));
+ assert!(!pending.active());
+ }
+
+ #[test]
+ fn pending_working_confirms_after_delayed_render_observation() {
+ let now = std::time::Instant::now();
+ let idle = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let working = DetectionPublishState {
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let mut pending = PendingWorkingConfirmation::default();
+
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 10)),
+ now
+ ));
+ assert!(!pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 11)),
+ now + AGENT_PENDING_WORKING_CONFIRM_DELAY
+ ));
+ assert!(!pending.active());
+ }
+
+ #[test]
+ fn pending_working_counts_raw_sequence_jump_once() {
+ let now = std::time::Instant::now();
+ let idle = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let working = DetectionPublishState {
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let mut pending = PendingWorkingConfirmation::default();
+
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 10)),
+ now
+ ));
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 14)),
+ now + AGENT_PENDING_WORKING_FAST_RECHECK
+ ));
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, false, 14)),
+ now + AGENT_PENDING_WORKING_FAST_RECHECK * 2
+ ));
+ assert!(!pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 15)),
+ now + AGENT_PENDING_WORKING_CONFIRM_DELAY
+ ));
+ }
+
+ #[test]
+ fn pending_working_cap_suppresses_unconfirmed_activity() {
+ let now = std::time::Instant::now();
+ let idle = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let working = DetectionPublishState {
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let mut pending = PendingWorkingConfirmation::default();
+
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 10)),
+ now
+ ));
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 11)),
+ now + AGENT_PENDING_WORKING_FAST_RECHECK
+ ));
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, false, 11)),
+ now + AGENT_PENDING_WORKING_CAP
+ ));
+ assert!(!pending.active());
+
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 20)),
+ now
+ ));
+ assert!(!pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 21)),
+ now + AGENT_PENDING_WORKING_CONFIRM_DELAY
+ ));
+ assert!(!pending.active());
+ }
+
+ #[test]
+ fn pending_working_process_exit_publishes_held_working_first() {
+ let now = std::time::Instant::now();
+ let idle = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let working = DetectionPublishState {
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let mut pending = PendingWorkingConfirmation::default();
+
+ assert!(pending.should_hold_idle_to_working(
+ idle,
+ working,
+ false,
+ false,
+ Some(pty_activity(true, true, 10)),
+ now
+ ));
+ assert!(pending.should_publish_held_working_before_exit(idle, idle, true));
+ assert!(!pending.active());
+ }
+
+ #[test]
+ fn pty_activity_distinguishes_fresh_render_from_active_hold() {
+ let now = std::time::Instant::now();
+ let mut tracker = PtyCausalityTracker::default();
+ baseline_pty_causality(&mut tracker, 1, 1);
+
+ let fresh = agent_caused_pty_activity_active(2, 1, &mut tracker, now);
+ assert!(fresh.active);
+ assert!(fresh.fresh_render);
+ assert_eq!(fresh.render_seq, 2);
+
+ let held = agent_caused_pty_activity_active(
+ 2,
+ 1,
+ &mut tracker,
+ now + AGENT_PENDING_WORKING_FAST_RECHECK,
+ );
+ assert!(held.active);
+ assert!(!held.fresh_render);
+ assert_eq!(held.render_seq, 2);
+ }
+
#[test]
fn claude_recap_veto_only_applies_to_idle_to_working() {
let screen =