aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-03 04:38:46 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-03 04:38:46 +0300
commit58af4902fc51b6be8fec99a3cec576c98e0c6075 (patch)
tree8dbb2501a61904b4c90d368150b5370b5f9e90e6 /src
parent816cb88cd9df3cba305a50842ebf78db19279dd5 (diff)
fix: preserve status in transcript viewers
Diffstat (limited to 'src')
-rw-r--r--src/detect/agents/claude_code.rs42
-rw-r--r--src/detect/agents/codex.rs47
-rw-r--r--src/detect/agents/mod.rs20
-rw-r--r--src/detect/mod.rs90
-rw-r--r--src/pane.rs70
-rw-r--r--src/terminal/state.rs2
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,