aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-31 03:00:46 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-31 03:04:20 +0300
commit3acd499ffb1119e8032bbcba3f1f46d4ff5f3da9 (patch)
tree4e30e82ccbc821e8930b03addfd1542d91ee8705
parent4e1fbf410b844749dcddae775806e1cce01ef442 (diff)
fix: keep codex visible approval review working
-rw-r--r--src/detect/agents/codex.rs27
-rw-r--r--src/detect/mod.rs24
-rw-r--r--src/pane.rs181
-rw-r--r--src/terminal/metadata.rs1
-rw-r--r--src/terminal/state.rs44
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();