#[derive(Debug, Clone, Copy)]
pub struct ScreenView<'a> {
pub visible_text: &'a str,
pub cursor_row: usize,
pub cursor_col: usize,
pub osc_title: Option<&'a str>,
pub osc_progress: Option<&'a str>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ReadinessError {
#[error("no readiness signal available for provider '{provider}'; refusing to guess")]
UnknownReadinessSignal { provider: String },
}
pub trait ReadinessDetector: Send + Sync {
fn provider_name(&self) -> &str;
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError>;
}
pub struct NoSignalDetector {
pub provider: String,
}
impl ReadinessDetector for NoSignalDetector {
fn provider_name(&self) -> &str {
&self.provider
}
fn is_ready(&self, _screen: &ScreenView) -> Result<bool, ReadinessError> {
Err(ReadinessError::UnknownReadinessSignal {
provider: self.provider.clone(),
})
}
}
pub const PROMPT_GLYPHS: &[char] = &['\u{276f}', '\u{203a}', '\u{2595}'];
const BUSY_MARKERS: &[&str] = &[
"esc to interrupt",
"Esc to interrupt",
"Working",
"Thinking",
];
const WALL_MARKERS: &[&str] = &[
"Waiting for auth",
"waiting for auth",
"Do you trust",
"Login required",
];
const STATUS_REGION_LINES: usize = 3;
fn prompt_ready(screen: &ScreenView, glyphs: &[char]) -> bool {
let nonblank: Vec<&str> = screen
.visible_text
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let Some(last) = nonblank.last() else {
return false; };
let region_start = nonblank.len().saturating_sub(STATUS_REGION_LINES);
let region = nonblank[region_start..].join("\n");
if WALL_MARKERS.iter().any(|m| region.contains(m)) {
return false;
}
if BUSY_MARKERS.iter().any(|m| region.contains(m)) {
return false;
}
let tail = last.trim_end();
glyphs.iter().any(|g| tail.ends_with(*g))
}
pub fn screen_is_waiting(screen: &ScreenView) -> bool {
prompt_ready(screen, PROMPT_GLYPHS)
}
pub struct CodexReadinessDetector;
impl ReadinessDetector for CodexReadinessDetector {
fn provider_name(&self) -> &str {
"codex"
}
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
Ok(prompt_ready(screen, PROMPT_GLYPHS))
}
}
pub struct GeminiReadinessDetector;
impl ReadinessDetector for GeminiReadinessDetector {
fn provider_name(&self) -> &str {
"gemini"
}
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
Ok(prompt_ready(screen, PROMPT_GLYPHS))
}
}
pub struct AgyReadinessDetector;
impl ReadinessDetector for AgyReadinessDetector {
fn provider_name(&self) -> &str {
"agy"
}
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
Ok(prompt_ready(screen, PROMPT_GLYPHS))
}
}
pub struct ClaudeReadinessDetector;
impl ReadinessDetector for ClaudeReadinessDetector {
fn provider_name(&self) -> &str {
"claude"
}
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
Ok(prompt_ready(screen, PROMPT_GLYPHS))
}
}
pub struct OpencodeReadinessDetector;
const OPENCODE_STATUS_REGION_LINES: usize = 8;
impl ReadinessDetector for OpencodeReadinessDetector {
fn provider_name(&self) -> &str {
"opencode"
}
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
let nonblank: Vec<&str> = screen
.visible_text
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
if nonblank.is_empty() {
return Ok(false); }
let start = nonblank.len().saturating_sub(OPENCODE_STATUS_REGION_LINES);
let region = nonblank[start..].join("\n");
if WALL_MARKERS.iter().any(|m| region.contains(m)) {
return Ok(false);
}
const OPENCODE_NOT_READY: &[&str] = &[
"interrupt",
"Permission required",
"\u{25a0}\u{25a0}\u{25a0}\u{25a0}",
"\u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}",
];
if OPENCODE_NOT_READY.iter().any(|m| region.contains(m)) {
return Ok(false);
}
Ok(nonblank[start..]
.iter()
.any(|l| l.trim_start().starts_with('\u{2503}')))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct PromptGlyphDetector;
impl ReadinessDetector for PromptGlyphDetector {
fn provider_name(&self) -> &str {
"test-cli"
}
fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
if screen.visible_text.contains("Waiting for auth") {
return Ok(false); }
Ok(screen.visible_text.trim_end().ends_with('\u{276f}')) }
}
fn view(text: &str) -> ScreenView<'_> {
ScreenView {
visible_text: text,
cursor_row: 0,
cursor_col: 0,
osc_title: None,
osc_progress: None,
}
}
#[test]
fn ready_on_prompt_glyph() {
let d = PromptGlyphDetector;
assert_eq!(d.is_ready(&view("project \u{276f}")), Ok(true));
assert_eq!(d.is_ready(&view("loading...")), Ok(false));
}
#[test]
fn auth_wall_is_not_ready() {
let d = PromptGlyphDetector;
assert_eq!(d.is_ready(&view("Waiting for auth \u{276f}")), Ok(false));
}
fn opencode_screen(footer_hint: &str) -> String {
format!(
" \u{2503}\n \u{2503} Ask anything... \"Fix a TODO in the codebase\"\n \u{2503}\n \u{2503} Sisyphus - Ultraworker \u{b7} model \u{b7} medium\n \u{2579}\u{2580}\u{2580}\u{2580}\u{2580}\n{footer_hint}\n ~/proj:main \u{2299} 6 MCP /status 1.14.50"
)
}
#[test]
fn opencode_idle_composer_is_ready() {
let d = OpencodeReadinessDetector;
let screen = opencode_screen(" tab agents ctrl+p commands");
assert_eq!(d.is_ready(&view(&screen)), Ok(true));
assert_eq!(d.provider_name(), "opencode");
}
#[test]
fn opencode_progress_fill_or_interrupt_hint_is_busy() {
let d = OpencodeReadinessDetector;
let working = opencode_screen(
" \u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}\u{25a0}\u{25a0}\u{25a0}\u{25a0} esc interrupt tab agents ctrl+p commands",
);
assert_eq!(d.is_ready(&view(&working)), Ok(false));
let alt = opencode_screen(" esc to interrupt");
assert_eq!(d.is_ready(&view(&alt)), Ok(false));
}
#[test]
fn opencode_permission_dialog_and_auth_wall_are_not_ready() {
let d = OpencodeReadinessDetector;
let perm = opencode_screen(" \u{25b3} Permission required");
assert_eq!(d.is_ready(&view(&perm)), Ok(false));
let wall = opencode_screen(" Login required");
assert_eq!(d.is_ready(&view(&wall)), Ok(false));
}
#[test]
fn opencode_stale_blocker_above_the_8_line_window_does_not_block() {
let d = OpencodeReadinessDetector;
let screen = "Permission required somewhere in old reply\n\
\u{25a0}\u{25a0}\u{25a0}\u{25a0} progress marker from old scrollback\n\
filler line one\n\
filler line two\n\
\u{2503}\n\
\u{2503} Ask anything... \"Fix a TODO in the codebase\"\n\
\u{2503}\n\
\u{2503} Sisyphus - Ultraworker \u{b7} model \u{b7} medium\n\
\u{2579}\u{2580}\u{2580}\u{2580}\u{2580}\n\
tab agents ctrl+p commands\n\
~/proj:main \u{2299} 6 MCP /status 1.14.50";
assert_eq!(d.is_ready(&view(screen)), Ok(true));
}
#[test]
fn opencode_no_composer_edge_is_not_ready() {
let d = OpencodeReadinessDetector;
assert_eq!(d.is_ready(&view("loading opencode...")), Ok(false));
assert_eq!(d.is_ready(&view("")), Ok(false));
}
#[test]
fn no_signal_detector_errors_never_guesses() {
let d = NoSignalDetector {
provider: "aider".to_string(),
};
assert_eq!(d.provider_name(), "aider");
assert_eq!(
d.is_ready(&view("anything at all, 9999 bytes of banner")),
Err(ReadinessError::UnknownReadinessSignal {
provider: "aider".into()
})
);
}
#[test]
fn codex_detector_ready_on_idle_prompt() {
let d = CodexReadinessDetector;
assert_eq!(d.provider_name(), "codex");
assert_eq!(
d.is_ready(&view(
"codex 0.130\n\n build feature X\n\u{276f} ".trim_end()
)),
Ok(true)
);
}
#[test]
fn codex_detector_not_ready_while_working() {
let d = CodexReadinessDetector;
assert_eq!(
d.is_ready(&view("running tool...\nEsc to interrupt\n\u{276f}")),
Ok(false)
);
}
#[test]
fn codex_detector_not_ready_without_prompt_signal() {
let d = CodexReadinessDetector;
assert_eq!(
d.is_ready(&view("loading a 5000 byte banner of text")),
Ok(false)
);
}
#[test]
fn gemini_detector_rejects_waiting_for_auth_false_ready() {
let d = GeminiReadinessDetector;
assert_eq!(d.provider_name(), "gemini");
assert_eq!(
d.is_ready(&view("Waiting for auth...\n\u{276f}")),
Ok(false)
);
}
#[test]
fn gemini_detector_ready_on_idle_prompt() {
let d = GeminiReadinessDetector;
assert_eq!(
d.is_ready(&view("Gemini ready\n\u{203a} ".trim_end())),
Ok(true)
);
}
#[test]
fn busy_word_in_model_reply_above_status_region_does_not_block() {
let d = CodexReadinessDetector;
let screen = "I am Working on the Thinking task you asked about.\n\
Here is a long reply that mentions Working again.\n\
filler line\n\
another filler\n\
\u{276f} ";
assert_eq!(d.is_ready(&view(screen.trim_end())), Ok(true));
}
}