use super::{DetectContext, Status, StatusDetector};
const BOTTOM_REGION_LINES: usize = 12;
pub struct CodexDetector;
impl StatusDetector for CodexDetector {
fn name(&self) -> &'static str {
"codex"
}
fn priority(&self) -> u8 {
90
}
fn detect(&self, ctx: &DetectContext<'_>) -> Status {
let bottom = bottom_region(ctx.plain);
if !looks_like_codex(ctx.plain, &bottom) {
return Status::Unknown;
}
if has_prompt_marker(&bottom) {
return Status::Waiting;
}
if has_activity_marker(&bottom) || has_spinner_title(ctx.ansi) {
return Status::Running;
}
if ctx.activity_age < std::time::Duration::from_secs(3) {
return Status::Running;
}
Status::Idle
}
}
fn bottom_region(plain: &str) -> String {
let mut lines: Vec<&str> = plain
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(BOTTOM_REGION_LINES)
.collect();
lines.reverse();
lines.join("\n")
}
fn looks_like_codex(plain: &str, bottom: &str) -> bool {
if plain.contains("OpenAI Codex") || plain.contains("codex-cli") || plain.contains("Codex") {
return true;
}
bottom.contains("codex")
}
fn has_prompt_marker(region: &str) -> bool {
const PROMPTS: &[&str] = &[
"Do you want to",
"Would you like to",
"(y/n)",
"(Y/n)",
"(y/N)",
"approve",
"Approve",
"deny",
"Deny",
];
PROMPTS.iter().any(|p| region.contains(p))
}
fn has_activity_marker(region: &str) -> bool {
const MARKERS: &[&str] = &[
"Thinking",
"Working",
"Running",
"Executing",
"Generating",
"Applying",
"Searching",
"Reading",
"Writing",
"Reasoning",
];
MARKERS
.iter()
.any(|v| region.contains(&format!("{v}…")) || region.contains(&format!("{v}...")))
}
fn has_spinner_title(ansi: &[u8]) -> bool {
let s = String::from_utf8_lossy(ansi);
let mut in_title = false;
let mut title = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.next() == Some(']') {
for _ in 0..2 {
chars.next();
}
in_title = true;
title.clear();
}
} else if in_title {
if c == '\x07' || c == '\x1b' {
if title
.chars()
.any(|c| ('\u{2800}'..='\u{28ff}').contains(&c))
{
return true;
}
in_title = false;
} else {
title.push(c);
}
}
}
false
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::*;
use crate::tmux::detector::DetectContext;
fn ctx_plain(s: &str) -> DetectContext<'_> {
let now = SystemTime::now();
DetectContext::from_parts(s.as_bytes(), s, Some(now), now, None, "test")
}
#[test]
fn non_codex_returns_unknown() {
let ctx = ctx_plain("$ ls -la\ntotal 42\n");
assert_eq!(CodexDetector.detect(&ctx), Status::Unknown);
}
#[test]
fn codex_prompt_yields_waiting() {
let ctx = ctx_plain("OpenAI Codex\n\nDo you want to proceed? (y/n)\n");
assert_eq!(CodexDetector.detect(&ctx), Status::Waiting);
}
#[test]
fn codex_thinking_yields_running() {
let ctx = ctx_plain("codex session\n· Thinking…\n");
assert_eq!(CodexDetector.detect(&ctx), Status::Running);
}
#[test]
fn codex_idle_when_settled() {
let ctx = {
let now = SystemTime::now();
let ago = now - std::time::Duration::from_secs(60);
DetectContext::from_parts(
b"codex session done",
"codex session done",
Some(ago),
now,
None,
"test",
)
};
assert_eq!(CodexDetector.detect(&ctx), Status::Idle);
}
}