diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-31 03:00:46 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-31 03:04:20 +0300 |
| commit | 3acd499ffb1119e8032bbcba3f1f46d4ff5f3da9 (patch) | |
| tree | 4e30e82ccbc821e8930b03addfd1542d91ee8705 | |
| parent | 4e1fbf410b844749dcddae775806e1cce01ef442 (diff) | |
fix: keep codex visible approval review working
| -rw-r--r-- | src/detect/agents/codex.rs | 27 | ||||
| -rw-r--r-- | src/detect/mod.rs | 24 | ||||
| -rw-r--r-- | src/pane.rs | 181 | ||||
| -rw-r--r-- | src/terminal/metadata.rs | 1 | ||||
| -rw-r--r-- | src/terminal/state.rs | 44 |
5 files changed, 245 insertions, 32 deletions
diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs index 0d23d4cb..50430aa4 100644 --- a/src/detect/agents/codex.rs +++ b/src/detect/agents/codex.rs @@ -49,11 +49,20 @@ pub(super) fn has_visible_working(content: &str) -> bool { } fn has_codex_visible_working_without_prompt(content: &str) -> bool { - content - .lines() - .rev() - .find(|line| !line.trim().is_empty()) - .is_some_and(codex_live_working_line) + let mut recent_lines = content.lines().rev().filter(|line| !line.trim().is_empty()); + let Some(last_line) = recent_lines.next() else { + return false; + }; + + if codex_live_working_line(last_line) { + return true; + } + + codex_status_detail_line(last_line) + && recent_lines + .take(4) + .find(|line| codex_block_marker_line(line)) + .is_some_and(codex_live_working_line) } fn has_codex_strong_blocked_prompt(lower_content: &str) -> bool { @@ -113,10 +122,12 @@ fn codex_working_status_line(line: &str) -> bool { } let trimmed = line.trim_start(); + let lower = trimmed.to_lowercase(); trimmed.starts_with('•') && (trimmed.contains("Working (") || trimmed.contains("Waiting for background terminal (") - || trimmed.contains("Reviewing approval request (") + || lower.contains("reviewing approval request (") + || (lower.contains("reviewing ") && lower.contains(" approval requests (")) || trimmed.contains("Booting MCP server:")) } @@ -146,6 +157,10 @@ fn codex_block_marker_line(line: &str) -> bool { line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') } +fn codex_status_detail_line(line: &str) -> bool { + line.trim_start().starts_with('└') +} + fn codex_queued_input_header_line(line: &str) -> bool { let trimmed = line.trim_start(); if !trimmed.starts_with('•') { diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 6173415b..cf600f51 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -1212,6 +1212,30 @@ mod tests { } #[test] + fn codex_reviewing_approval_request_without_prompt_is_visible_working() { + let detection = detect_agent( + Some(Agent::Codex), + "• Running git push --force-with-lease origin codex/ci-flake-diagnostics\n\n• Reviewing approval request (2m 53s • esc to interrupt)\n └ /bin/zsh -lc 'git push --force-with-lease origin codex/ci-flake-diagnostics'", + ); + + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + assert!(!detection.visible_idle); + } + + #[test] + fn codex_reviewing_multiple_approval_requests_is_visible_working() { + let detection = detect_agent( + Some(Agent::Codex), + "• Reviewing 2 approval requests (0s • esc to interrupt)\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", + ); + + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + assert!(!detection.visible_idle); + } + + #[test] fn codex_booting_mcp_server_is_visible_working() { let detection = detect_agent( Some(Agent::Codex), diff --git a/src/pane.rs b/src/pane.rs index 97aeb9cb..ddf70b1b 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -110,6 +110,7 @@ const PROCESS_ACQUISITION_WINDOW: std::time::Duration = std::time::Duration::fro const PROCESS_ACQUISITION_FAST_WINDOW: std::time::Duration = std::time::Duration::from_millis(1500); const PROCESS_ACQUISITION_FAST_RECHECK: std::time::Duration = std::time::Duration::from_millis(500); const PROCESS_ACQUISITION_SLOW_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); +const STABLE_VISIBLE_SIGNAL_REFRESH: std::time::Duration = std::time::Duration::from_millis(800); #[derive(Debug, Clone, Copy)] struct AgentDetectionPresence { @@ -282,6 +283,7 @@ fn should_publish_detection_update( next: DetectionPublishState, agent_changed: bool, process_exited: bool, + stable_visible_signal_refresh_due: bool, ) -> bool { next.state != previous.state || next.visible_blocker != previous.visible_blocker @@ -289,7 +291,28 @@ fn should_publish_detection_update( || next.visible_working != previous.visible_working || agent_changed || process_exited + || (stable_visible_signal_refresh_due + && ((next.visible_blocker && previous.visible_blocker) + || (next.visible_idle && previous.visible_idle) + || (next.visible_working && previous.visible_working))) +} + +fn stable_visible_signal_refresh_due( + previous: DetectionPublishState, + next: DetectionPublishState, + last_refresh: Option<std::time::Instant>, + now: std::time::Instant, +) -> bool { + // TODO: Evaluate expanding agent-specific visible_* predicates so more + // detectors can use screen-authoritative refreshes safely. + let stable_visible_signal = (next.visible_blocker && previous.visible_blocker) || (next.visible_idle && previous.visible_idle) + || (next.visible_working && previous.visible_working); + + stable_visible_signal + && last_refresh.is_none_or(|last_refresh| { + now.duration_since(last_refresh) >= STABLE_VISIBLE_SIGNAL_REFRESH + }) } fn spawn_basic_detection_task( @@ -313,6 +336,7 @@ fn spawn_basic_detection_task( let mut last_visible_blocker = false; let mut last_visible_idle = false; let mut last_visible_working = false; + let mut last_visible_signal_refresh = None; let mut last_process_check = std::time::Instant::now(); let mut last_foreground_pgid = None; let mut has_process_probe = false; @@ -328,6 +352,7 @@ fn spawn_basic_detection_task( last_visible_blocker = false; last_visible_idle = false; last_visible_working = false; + last_visible_signal_refresh = None; last_process_check = std::time::Instant::now(); last_foreground_pgid = None; has_process_probe = false; @@ -402,26 +427,41 @@ fn spawn_basic_detection_task( let visible_idle = detection.visible_idle && new_state == AgentState::Idle; let visible_working = detection.visible_working && new_state == AgentState::Working; + let previous_publish = DetectionPublishState { + state, + visible_blocker: last_visible_blocker, + visible_idle: last_visible_idle, + visible_working: last_visible_working, + }; + let next_publish = DetectionPublishState { + state: new_state, + visible_blocker, + visible_idle, + visible_working, + }; + let stable_refresh_due = stable_visible_signal_refresh_due( + previous_publish, + next_publish, + last_visible_signal_refresh, + now, + ); + if should_publish_detection_update( - DetectionPublishState { - state, - visible_blocker: last_visible_blocker, - visible_idle: last_visible_idle, - visible_working: last_visible_working, - }, - DetectionPublishState { - state: new_state, - visible_blocker, - visible_idle, - visible_working, - }, + previous_publish, + next_publish, agent_changed, false, + stable_refresh_due, ) { state = new_state; last_visible_blocker = visible_blocker; last_visible_idle = visible_idle; last_visible_working = visible_working; + if visible_blocker || visible_idle || visible_working { + last_visible_signal_refresh = Some(now); + } else { + last_visible_signal_refresh = None; + } publish_state_changed_event( state_events.clone(), pane_id, @@ -1647,6 +1687,7 @@ impl PaneRuntime { let mut last_visible_blocker = false; let mut last_visible_idle = false; let mut last_visible_working = false; + let mut last_visible_signal_refresh = None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -1677,6 +1718,7 @@ impl PaneRuntime { last_visible_blocker = false; last_visible_idle = false; last_visible_working = false; + last_visible_signal_refresh = None; } } @@ -1828,21 +1870,31 @@ impl PaneRuntime { let visible_working = detection.visible_working && new_state == AgentState::Working; + let previous_publish = DetectionPublishState { + state, + visible_blocker: last_visible_blocker, + visible_idle: last_visible_idle, + visible_working: last_visible_working, + }; + let next_publish = DetectionPublishState { + state: new_state, + visible_blocker, + visible_idle, + visible_working, + }; + let stable_refresh_due = stable_visible_signal_refresh_due( + previous_publish, + next_publish, + last_visible_signal_refresh, + now, + ); + if should_publish_detection_update( - DetectionPublishState { - state, - visible_blocker: last_visible_blocker, - visible_idle: last_visible_idle, - visible_working: last_visible_working, - }, - DetectionPublishState { - state: new_state, - visible_blocker, - visible_idle, - visible_working, - }, + previous_publish, + next_publish, agent_changed, process_exited, + stable_refresh_due, ) { debug!( pane = pane_id.raw(), @@ -1856,6 +1908,11 @@ impl PaneRuntime { last_visible_blocker = visible_blocker; last_visible_idle = visible_idle; last_visible_working = visible_working; + if visible_blocker || visible_idle || visible_working { + last_visible_signal_refresh = Some(now); + } else { + last_visible_signal_refresh = None; + } publish_state_changed_event( state_events.clone(), pane_id, @@ -2798,29 +2855,101 @@ mod tests { #[test] fn stable_visible_idle_republishes_for_stale_hook_deadline() { + let now = std::time::Instant::now(); let previous = DetectionPublishState { state: AgentState::Idle, visible_blocker: false, visible_idle: true, visible_working: false, }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); assert!(should_publish_detection_update( - previous, previous, false, false + previous, + previous, + false, + false, + refresh_due )); } #[test] fn stable_plain_idle_does_not_republish() { + let now = std::time::Instant::now(); let previous = DetectionPublishState { state: AgentState::Idle, visible_blocker: false, visible_idle: false, visible_working: false, }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); assert!(!should_publish_detection_update( - previous, previous, false, false + previous, + previous, + false, + false, + refresh_due + )); + } + + #[test] + fn stable_visible_working_republishes_for_hook_override_refresh() { + let now = std::time::Instant::now(); + let previous = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_idle: false, + visible_working: true, + }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); + + assert!(should_publish_detection_update( + previous, + previous, + false, + false, + refresh_due + )); + } + + #[test] + fn stable_visible_blocker_republishes_for_hook_override_refresh() { + let now = std::time::Instant::now(); + let previous = DetectionPublishState { + state: AgentState::Blocked, + visible_blocker: true, + visible_idle: false, + visible_working: false, + }; + let refresh_due = stable_visible_signal_refresh_due( + previous, + previous, + Some(now - STABLE_VISIBLE_SIGNAL_REFRESH), + now, + ); + + assert!(should_publish_detection_update( + previous, + previous, + false, + false, + refresh_due )); } diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 5b9f0078..1dcb80c0 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -361,6 +361,7 @@ impl TerminalState { } if self.visible_blocker_overrides_hook() + || self.visible_working_overrides_hook() || self.visible_idle_masks_hook_custom_status(state, now) { return None; diff --git a/src/terminal/state.rs b/src/terminal/state.rs index bcf5737a..ac7c4380 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1263,6 +1263,50 @@ mod tests { } #[test] + fn refreshed_visible_working_overrides_newer_hook_blocked() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), + AgentState::Working, + false, + false, + true, + false, + now, + ); + terminal.set_hook_authority_with_custom_status_at( + "herdr:codex".into(), + "codex".into(), + AgentState::Blocked, + None, + Some("permission".into()), + None, + None, + now + CLAUDE_WORKING_HOLD + Duration::from_millis(1), + ); + + assert_eq!(terminal.state, AgentState::Blocked); + + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), + AgentState::Working, + false, + false, + true, + false, + now + CLAUDE_WORKING_HOLD + Duration::from_millis(800), + ); + + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.effective_custom_status(), None); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Blocked + ); + } + + #[test] fn visible_idle_waits_before_overriding_claude_hook_blocked() { let now = Instant::now(); let mut terminal = test_terminal(); |
