diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-03 04:38:46 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-03 04:38:46 +0300 |
| commit | 58af4902fc51b6be8fec99a3cec576c98e0c6075 (patch) | |
| tree | 8dbb2501a61904b4c90d368150b5370b5f9e90e6 /src | |
| parent | 816cb88cd9df3cba305a50842ebf78db19279dd5 (diff) | |
fix: preserve status in transcript viewers
Diffstat (limited to 'src')
| -rw-r--r-- | src/detect/agents/claude_code.rs | 42 | ||||
| -rw-r--r-- | src/detect/agents/codex.rs | 47 | ||||
| -rw-r--r-- | src/detect/agents/mod.rs | 20 | ||||
| -rw-r--r-- | src/detect/mod.rs | 90 | ||||
| -rw-r--r-- | src/pane.rs | 70 | ||||
| -rw-r--r-- | src/terminal/state.rs | 2 |
6 files changed, 261 insertions, 10 deletions
diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs index ace946da..77db856c 100644 --- a/src/detect/agents/claude_code.rs +++ b/src/detect/agents/claude_code.rs @@ -63,6 +63,20 @@ pub(super) fn has_working_chrome(content: &str) -> bool { || has_spinner_activity(above) } +pub(super) fn is_transcript_viewer(content: &str) -> bool { + let bottom_lines = bottom_non_empty_lines(content, 3); + let Some(last_line) = bottom_lines.last() else { + return false; + }; + let bottom_text = normalize_lines(&bottom_lines); + + bottom_text.contains("showing detailed transcript") + && bottom_text.contains("ctrl+o to toggle") + && (bottom_text.contains("ctrl+e to show all") + || bottom_text.contains("ctrl+e to collapse")) + && transcript_control_tail(last_line) +} + pub(super) fn has_prompt_box(content: &str) -> bool { let lines: Vec<&str> = content.lines().collect(); let Some(top_border_index) = claude_prompt_box_top_border_index(&lines) else { @@ -232,3 +246,31 @@ fn is_horizontal_rule(line: &str) -> bool { let trimmed = line.trim(); !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') } + +fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> { + let mut lines: Vec<&str> = content + .lines() + .rev() + .filter(|line| !line.trim().is_empty()) + .take(max_lines) + .collect(); + lines.reverse(); + lines +} + +fn normalize_lines(lines: &[&str]) -> String { + lines + .iter() + .flat_map(|line| line.split_whitespace()) + .collect::<Vec<_>>() + .join(" ") + .to_lowercase() +} + +fn transcript_control_tail(line: &str) -> bool { + let lower = line.to_lowercase(); + lower.contains("ctrl+e") + || lower.contains("show all") + || lower.contains("collapse") + || lower.contains("verbose") +} diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs index 50430aa4..e00d0a28 100644 --- a/src/detect/agents/codex.rs +++ b/src/detect/agents/codex.rs @@ -48,6 +48,25 @@ pub(super) fn has_visible_working(content: &str) -> bool { || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content)) } +pub(super) fn is_transcript_viewer(content: &str) -> bool { + let bottom_lines = bottom_non_empty_lines(content, 3); + let Some(last_line) = bottom_lines.last() else { + return false; + }; + let bottom_text = normalize_lines(&bottom_lines); + + bottom_text.contains("↑/↓ to scroll") + && bottom_text.contains("pgup/pgdn to page") + && bottom_text.contains("home/end to jump") + && bottom_text.contains("q to quit") + && has_codex_edit_prev_controls(&bottom_text) + && transcript_control_tail(last_line) +} + +fn has_codex_edit_prev_controls(bottom_text: &str) -> bool { + bottom_text.contains("esc to edit prev") || bottom_text.contains("esc/← to edit prev") +} + fn has_codex_visible_working_without_prompt(content: &str) -> bool { let mut recent_lines = content.lines().rev().filter(|line| !line.trim().is_empty()); let Some(last_line) = recent_lines.next() else { @@ -171,3 +190,31 @@ fn codex_queued_input_header_line(line: &str) -> bool { lower.starts_with("• queued follow-up inputs") || lower.starts_with("• messages to be submitted after next tool call") } + +fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> { + let mut lines: Vec<&str> = content + .lines() + .rev() + .filter(|line| !line.trim().is_empty()) + .take(max_lines) + .collect(); + lines.reverse(); + lines +} + +fn normalize_lines(lines: &[&str]) -> String { + lines + .iter() + .flat_map(|line| line.split_whitespace()) + .collect::<Vec<_>>() + .join(" ") + .to_lowercase() +} + +fn transcript_control_tail(line: &str) -> bool { + let lower = line.to_lowercase(); + lower.contains("q to quit") + || lower.contains("esc to edit") + || lower.contains("esc/← to edit") + || lower.contains("edit message") +} diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs index 4597c122..a89b2f37 100644 --- a/src/detect/agents/mod.rs +++ b/src/detect/agents/mod.rs @@ -39,14 +39,34 @@ pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection { Agent::Qodercli => qodercli::detect(screen_content), }; + let skip_state_update = should_skip_state_update(agent, screen_content); + if skip_state_update { + return AgentDetection { + state, + skip_state_update: true, + visible_blocker: false, + visible_idle: false, + visible_working: false, + }; + } + AgentDetection { state, + skip_state_update: false, visible_blocker: has_visible_blocker(agent, screen_content, state), visible_idle: has_visible_idle(agent, screen_content, state), visible_working: has_visible_working(agent, screen_content, state), } } +pub(super) fn should_skip_state_update(agent: Agent, content: &str) -> bool { + match agent { + Agent::Claude => claude_code::is_transcript_viewer(content), + Agent::Codex => codex::is_transcript_viewer(content), + _ => false, + } +} + fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool { if state != AgentState::Blocked { return false; diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 58a73d7d..e5e9b93b 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -22,6 +22,9 @@ pub enum AgentState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AgentDetection { pub state: AgentState, + /// True when the current screen is an agent-owned viewer that shows + /// transcript/history instead of the live prompt state. + pub skip_state_update: bool, /// True when the current screen visibly shows live UI chrome that needs /// human input. This is stronger than arbitrary prompt-like text in the /// scrollback and may override a non-blocked integration state. @@ -173,6 +176,7 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio let Some(agent) = agent else { return AgentDetection { state: AgentState::Unknown, + skip_state_update: false, visible_blocker: false, visible_idle: false, visible_working: false, @@ -181,6 +185,10 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio agents::detect(agent, screen_content) } +pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool { + agent.is_some_and(|agent| agents::should_skip_state_update(agent, screen_content)) +} + // --------------------------------------------------------------------------- // Per-agent detectors // --------------------------------------------------------------------------- @@ -940,6 +948,42 @@ mod tests { } #[test] + fn claude_detailed_transcript_skips_state_update() { + let screen = "● I read the root and README.md.\n\n✻ Cogitated for 14s\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all verbose"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert!(detection.skip_state_update); + assert!(!detection.visible_idle); + assert!(!detection.visible_working); + assert!(!detection.visible_blocker); + } + + #[test] + fn claude_detailed_transcript_collapse_skips_state_update() { + let screen = "● Running tool\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to collapse"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert!(detection.skip_state_update); + } + + #[test] + fn claude_wrapped_detailed_transcript_controls_skip_state_update() { + let screen = "● Running tool\n\n────────────────────────\n Showing detailed transcript · ctrl+o\n to toggle · ctrl+e to\n collapse"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert!(detection.skip_state_update); + } + + #[test] + fn claude_transcript_text_above_prompt_does_not_skip_state_update() { + let screen = "Docs mention: Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n❯ \n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert!(!detection.skip_state_update); + assert!(detection.visible_idle); + } + + #[test] fn claude_waiting_do_you_want() { let screen = "Do you want to run this command?\n\nYes No"; assert_eq!(detect_claude(screen), AgentState::Blocked); @@ -1474,6 +1518,52 @@ mod tests { assert_eq!(detect_codex("❯ "), AgentState::Idle); } + #[test] + fn codex_transcript_viewer_skips_state_update() { + let screen = "/ T R A N S C R I P T / / / / / / / / / / / / / / / /\n\n› i did thats why our latest commit is also a claude fix D\n\n• Yes, then I would release.\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert!(detection.skip_state_update); + assert!(!detection.visible_idle); + assert!(!detection.visible_working); + assert!(!detection.visible_blocker); + } + + #[test] + fn codex_wrapped_transcript_controls_skip_state_update() { + let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to\n page home/end to jump\n q to quit esc to edit prev"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert!(detection.skip_state_update); + assert!(!detection.visible_idle); + } + + #[test] + fn codex_esc_transcript_controls_skip_state_update() { + let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc/← to edit prev → to edit next enter to edit message"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert!(detection.skip_state_update); + assert!(!detection.visible_idle); + } + + #[test] + fn codex_transcript_controls_above_prompt_do_not_skip_state_update() { + let screen = "Old output:\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev\n\n› "; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert!(!detection.skip_state_update); + assert!(detection.visible_idle); + } + + #[test] + fn codex_exit_control_without_scroll_control_does_not_skip_state_update() { + let screen = "Some output\n\n q to quit esc to edit prev"; + let detection = detect_agent(Some(Agent::Codex), screen); + + assert!(!detection.skip_state_update); + } + // ---- Gemini ---- #[test] diff --git a/src/pane.rs b/src/pane.rs index 4611b315..55b08f60 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -323,6 +323,29 @@ fn stable_visible_signal_refresh_due( }) } +fn detection_update_for_publish( + agent: Option<Agent>, + content: &str, + process_exited: bool, +) -> Option<crate::detect::AgentDetection> { + if crate::detect::should_skip_state_update(agent, content) { + return None; + } + + if process_exited { + return Some(crate::detect::AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_idle: false, + visible_working: false, + }); + } + + let detection = crate::detect::detect_agent(agent, content); + (!detection.skip_state_update).then_some(detection) +} + fn spawn_basic_detection_task( pane_id: PaneId, child_pid: Arc<AtomicU32>, @@ -380,6 +403,9 @@ fn spawn_basic_detection_task( let mut agent_changed = false; let mut agent = agent_presence.current_agent(); let content = terminal.detection_text(); + if crate::detect::should_skip_state_update(agent, &content) { + continue; + } let foreground_pgid = (pid > 0) .then(|| crate::detect::foreground_process_group_id(pid)) @@ -429,7 +455,9 @@ fn spawn_basic_detection_task( } } - let detection = crate::detect::detect_agent(agent, &content); + let Some(detection) = detection_update_for_publish(agent, &content, false) else { + continue; + }; let new_state = detection.state; let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; let visible_idle = detection.visible_idle && new_state == AgentState::Idle; @@ -1467,6 +1495,9 @@ impl PaneRuntime { release_was_active = suppressed_agent.is_some(); let pid = child_pid.load(Ordering::Acquire); let content = terminal.detection_text(); + if detect::should_skip_state_update(agent_presence.current_agent(), &content) { + continue; + } let foreground_pgid = (pid > 0) .then(|| detect::foreground_process_group_id(pid)) .flatten(); @@ -1581,15 +1612,10 @@ impl PaneRuntime { let process_exited = pending_foreground_shell_clear && agent.is_some() && !foreground_shell_exit_reported; - let detection = if process_exited { - detect::AgentDetection { - state: AgentState::Idle, - visible_blocker: false, - visible_idle: false, - visible_working: false, - } - } else { - detect::detect_agent(agent, &content) + let Some(detection) = + detection_update_for_publish(agent, &content, process_exited) + else { + continue; }; let raw_state = detection.state; let new_state = crate::terminal::state::stabilize_agent_detection( @@ -2425,6 +2451,30 @@ mod tests { } #[test] + fn codex_transcript_viewer_suppresses_prompt_idle_publish() { + let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; + + assert!(detection_update_for_publish(Some(Agent::Codex), content, false).is_none()); + } + + #[test] + fn codex_transcript_viewer_suppresses_process_exit_idle_publish() { + let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; + + assert!(detection_update_for_publish(Some(Agent::Codex), content, true).is_none()); + } + + #[test] + fn process_exit_without_transcript_still_reports_idle() { + let detection = + detection_update_for_publish(Some(Agent::Codex), "Codex finished\n› ", true) + .expect("process exit should publish idle outside transcript viewer"); + + assert_eq!(detection.state, AgentState::Idle); + assert!(!detection.skip_state_update); + } + + #[test] fn stable_visible_idle_republishes_for_stale_hook_deadline() { let now = std::time::Instant::now(); let previous = DetectionPublishState { diff --git a/src/terminal/state.rs b/src/terminal/state.rs index e6ac3c00..0b96250f 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -887,6 +887,7 @@ mod tests { AgentState::Working, AgentDetection { state: AgentState::Idle, + skip_state_update: false, visible_blocker: false, visible_idle: false, visible_working: false, @@ -909,6 +910,7 @@ mod tests { AgentState::Working, AgentDetection { state: AgentState::Idle, + skip_state_update: false, visible_blocker: false, visible_idle: true, visible_working: false, |
