diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 01:18:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 01:18:03 +0300 |
| commit | c0fe95235f639adbc935192ed14b43631f617ac2 (patch) | |
| tree | 9cc0f9fc17c462b331b0cb822bd4b063b730a64e /src | |
| parent | e719c6517d6cc2ef2b51a056725d2558c5fb94fa (diff) | |
fix: stabilize claude state detection
Diffstat (limited to 'src')
| -rw-r--r-- | src/detect.rs | 14 | ||||
| -rw-r--r-- | src/pane.rs | 102 |
2 files changed, 111 insertions, 5 deletions
diff --git a/src/detect.rs b/src/detect.rs index 07a9c682..0a38b952 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -453,8 +453,9 @@ fn has_interrupt_pattern(lower_content: &str) -> bool { || (lower_content.contains("esc") && lower_content.contains("interrupt")) } -/// Claude Code spinner characters + activity label -/// Pattern: one of ✱✲✳... followed by a word ending in "ing" and "…" +/// Claude Code spinner characters + activity label. +/// The verb changes frequently ("Processing…", "Pouncing…", etc.), so rely +/// on the spinner glyph + trailing ellipsis rather than specific wording. fn has_spinner_activity(content: &str) -> bool { const SPINNER_CHARS: &str = "✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆"; for line in content.lines() { @@ -462,9 +463,11 @@ fn has_spinner_activity(content: &str) -> bool { let mut chars = trimmed.chars(); if let Some(first) = chars.next() { if SPINNER_CHARS.contains(first) { - // Next should be a space, then a word ending in "ing", then "…" let rest: String = chars.collect(); - if rest.starts_with(' ') && rest.contains("ing") && rest.contains('\u{2026}') { + if rest.starts_with(' ') + && rest.contains('\u{2026}') + && rest.chars().any(|c| c.is_alphanumeric()) + { return true; } } @@ -1071,12 +1074,15 @@ mod tests { assert!(has_spinner_activity("✽ Tempering…")); assert!(has_spinner_activity("✳ Simplifying recompute_tangents…")); assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace + assert!(has_spinner_activity("✻ Pouncing…")); + assert!(has_spinner_activity("✽ Processing…")); } #[test] fn spinner_activity_not_false_positive() { assert!(!has_spinner_activity("normal text")); assert!(!has_spinner_activity("✽ no ellipsis here")); + assert!(!has_spinner_activity("✽ …")); assert!(!has_spinner_activity("some ✽ in the middle")); } diff --git a/src/pane.rs b/src/pane.rs index 5a7ae2bc..47ca79ab 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -15,6 +15,38 @@ use crate::events::AppEvent; use crate::layout::PaneId; use crate::pty_callbacks::PtyResponses; +const CLAUDE_BUSY_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); + +fn stabilize_agent_state( + agent: Option<Agent>, + previous: AgentState, + raw: AgentState, + now: std::time::Instant, + last_claude_busy_at: &mut Option<std::time::Instant>, +) -> AgentState { + if agent != Some(Agent::Claude) { + return raw; + } + + match raw { + AgentState::Busy => { + *last_claude_busy_at = Some(now); + AgentState::Busy + } + AgentState::Waiting => AgentState::Waiting, + AgentState::Idle if previous == AgentState::Busy => { + if last_claude_busy_at + .is_some_and(|last_busy| now.duration_since(last_busy) < CLAUDE_BUSY_HOLD) + { + AgentState::Busy + } else { + AgentState::Idle + } + } + _ => raw, + } +} + // --------------------------------------------------------------------------- // PaneState — pure data, constructable without PTYs, testable // --------------------------------------------------------------------------- @@ -210,6 +242,7 @@ impl PaneRuntime { let mut agent: Option<Agent> = None; let mut state = AgentState::Unknown; let mut last_process_check = Instant::now(); + let mut last_claude_busy_at = None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -257,16 +290,24 @@ impl PaneRuntime { } } - let new_state = if let Ok(content) = screen_content.read() { + let raw_state = if let Ok(content) = screen_content.read() { detect::detect_state(agent, &content) } else { continue; }; + let new_state = stabilize_agent_state( + agent, + state, + raw_state, + now, + &mut last_claude_busy_at, + ); if new_state != state || agent_changed { debug!( pane = pane_id.raw(), ?state, + ?raw_state, ?new_state, ?agent, "state changed" @@ -382,3 +423,62 @@ impl PaneRuntime { crate::platform::process_cwd(pid) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_busy_is_sticky_for_short_gap() { + let now = std::time::Instant::now(); + let mut last_busy = None; + + let busy = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Idle, + AgentState::Busy, + now, + &mut last_busy, + ); + assert_eq!(busy, AgentState::Busy); + + let still_busy = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Busy, + AgentState::Idle, + now + std::time::Duration::from_millis(400), + &mut last_busy, + ); + assert_eq!(still_busy, AgentState::Busy); + } + + #[test] + fn claude_transitions_to_idle_after_hold_expires() { + let now = std::time::Instant::now(); + let mut last_busy = Some(now); + + let state = stabilize_agent_state( + Some(Agent::Claude), + AgentState::Busy, + AgentState::Idle, + now + CLAUDE_BUSY_HOLD + std::time::Duration::from_millis(1), + &mut last_busy, + ); + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn non_claude_states_are_unchanged() { + let now = std::time::Instant::now(); + let mut last_busy = None; + + let state = stabilize_agent_state( + Some(Agent::Codex), + AgentState::Busy, + AgentState::Idle, + now, + &mut last_busy, + ); + assert_eq!(state, AgentState::Idle); + } +} |
