use std::{
fs::OpenOptions,
io::Write as _,
path::{Path, PathBuf},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Result, bail};
use super::{
Herdr,
native::{squash, trust_workspace_matches},
};
use crate::{Paths, cli::PaneRunArgs};
const REDRAW: Duration = Duration::from_millis(1500);
pub(super) const SCREEN_DEADLINE: Duration = Duration::from_mins(3);
fn screen_deadline() -> Duration {
#[cfg(debug_assertions)]
if let Some(millis) = std::env::var("BRGR_TEST_SCREEN_DEADLINE_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
{
return Duration::from_millis(millis);
}
SCREEN_DEADLINE
}
#[derive(Debug, Eq, PartialEq)]
pub(super) enum Screen {
Clear,
Resolve {
rule: &'static str,
keys: Vec<&'static str>,
},
Unknown(String),
}
pub(super) fn classify(screen: &str, workspace: &Path) -> Screen {
if trust_workspace_matches(screen, workspace) {
let options = menu_options(screen);
let yes = options
.iter()
.position(|option| option.text.starts_with("Yes, I trust this folder"));
let keys = if let (Some(selected), Some(yes)) =
(options.iter().position(|option| option.selected), yes)
{
let mut keys = keys_between(selected, yes);
keys.push("enter");
keys
} else {
let flat = squash(screen);
let selected_yes = ["❯", "›", ">"]
.iter()
.any(|cursor| flat.contains(&format!("{cursor}Yes,Itrustthisfolder")));
if selected_yes {
vec!["enter"]
} else {
vec!["down", "enter"]
}
};
return Screen::Resolve {
rule: "workspace-trust",
keys,
};
}
if let Some(resolution) = codex_trust(screen, workspace, || {
crate::workspace::primary_checkout(workspace)
}) {
return resolution;
}
if let Some(resolution) = copilot_trust(screen, workspace) {
return resolution;
}
if let Some(resolution) = cursor_trust(screen, workspace) {
return resolution;
}
let lower = screen.to_lowercase();
if squash(&lower).contains("trustthisfolder") {
return Screen::Unknown(head(screen));
}
if lower.contains("update complete")
&& lower.contains("restart")
&& screen
.lines()
.any(|line| matches!(line.trim().to_lowercase().as_str(), "ok" | "esc"))
{
return Screen::Resolve {
rule: "update-applied",
keys: vec!["esc"],
};
}
let options = menu_options(screen);
let selected = options.iter().position(|option| option.selected);
if let (Some(selected), Some((rule, target))) = (selected, accepting_option(&lower, &options)) {
let mut keys = keys_between(selected, target);
keys.push("enter");
return Screen::Resolve { rule, keys };
}
if let Some(selected) = selected
&& is_update_offer(&lower)
&& let Some(skip) = options.iter().position(|option| is_skip(&option.text))
{
let mut keys = keys_between(selected, skip);
keys.push("enter");
return Screen::Resolve {
rule: "update-offer-skip",
keys,
};
}
if let Some(selected) = selected
&& lower.contains("try the new fullscreen renderer?")
&& let Some(skip) = options.iter().position(|option| is_skip(&option.text))
{
let mut keys = keys_between(selected, skip);
keys.push("enter");
return Screen::Resolve {
rule: "renderer-offer-skip",
keys,
};
}
if selected.is_none() {
return if has_continue_notice(screen) {
Screen::Resolve {
rule: "continue-notice",
keys: vec!["enter"],
}
} else {
Screen::Clear
};
}
Screen::Unknown(head(screen))
}
fn is_update_offer(lower: &str) -> bool {
(lower.contains("update") || lower.contains("upgrade"))
&& (lower.contains("available")
|| lower.contains("new version")
|| lower.contains("newer version")
|| has_version_number(lower))
}
fn has_version_number(text: &str) -> bool {
text.split(|character: char| !(character.is_ascii_digit() || character == '.'))
.any(|token| {
let parts: Vec<&str> = token.trim_matches('.').split('.').collect();
parts.len() >= 3 && parts.iter().all(|part| !part.is_empty())
})
}
fn keys_between(from: usize, to: usize) -> Vec<&'static str> {
let key = if to > from { "down" } else { "up" };
std::iter::repeat_n(key, to.abs_diff(from)).collect()
}
fn accepting_option(lower: &str, options: &[MenuOption]) -> Option<(&'static str, usize)> {
let position = |prefix: &str| {
options
.iter()
.position(|option| option.text.to_lowercase().starts_with(prefix))
};
if lower.contains("bypass permissions mode") {
return position("yes, i accept").map(|at| ("bypass-warning", at));
}
if lower.contains("hooks need review") {
return position("trust all").map(|at| ("codex-hooks-trust", at));
}
if lower.contains("mcp server") {
return position("use this mcp server")
.or_else(|| position("use this"))
.map(|at| ("mcp-trust", at));
}
None
}
fn has_continue_notice(screen: &str) -> bool {
screen.lines().any(|line| {
let line = line
.trim()
.trim_matches(['│', '┃', '║'])
.trim()
.trim_start_matches(['•', '·', '*', '-', '›', '❯'])
.trim_start()
.to_lowercase();
let notices = [
"press enter to continue",
"press return to continue",
"press any key to continue",
];
notices.iter().any(|notice| {
line.match_indices(notice).any(|(start, _)| {
let before = line[..start].trim_end();
let after = line[start + notice.len()..].trim();
(before.is_empty() || before.ends_with(['.', '!', '?']))
&& after.chars().all(is_closing_punctuation)
})
})
})
}
fn is_closing_punctuation(character: char) -> bool {
character.is_ascii_punctuation()
|| matches!(
character,
'…' | '⋯' | '”' | '’' | '»' | '。' | '!' | '?' | '、'
)
}
fn codex_trust(
screen: &str,
workspace: &Path,
primary: impl FnOnce() -> Option<PathBuf>,
) -> Option<Screen> {
let flat = squash(screen);
if !flat.contains("Trustthisfolder?") || !flat.contains("Trustandcontinue") {
return None;
}
let workspace = squash(&workspace.to_string_lossy());
let primary = flat
.contains("repositoryroot:")
.then(primary)
.flatten()
.map(|primary| squash(&primary.to_string_lossy()));
let shown = flat.split_once("Folderaccess").is_some_and(|(_, after)| {
after
.strip_prefix(workspace.trim_end_matches('/'))
.map(|rest| rest.trim_start_matches('/'))
.is_some_and(|rest| {
rest.starts_with("Trustthisfolder")
|| rest.starts_with("Note:")
&& rest
.split_once("repositoryroot:")
.and_then(|(_, root)| root.split_once("Trustthisfolder"))
.is_some_and(|(root, _)| {
primary.as_deref().is_some_and(|primary| {
root.trim_end_matches('/') == primary.trim_end_matches('/')
})
})
})
});
Some(trust_choice(screen, shown, "codex-trust", |text| {
text.starts_with("Trust and continue")
}))
}
fn copilot_trust(screen: &str, workspace: &Path) -> Option<Screen> {
let flat: String = screen
.chars()
.filter(|character| {
!character.is_whitespace() && !matches!(character, '│' | '╭' | '╮' | '╰' | '╯' | '─')
})
.collect();
if !flat.contains("Confirmfoldertrust") || !flat.contains("Doyoutrustthefilesinthisfolder?") {
return None;
}
let workspace = squash(&workspace.to_string_lossy());
let shown = flat
.split_once("Confirmfoldertrust")
.and_then(|(_, after)| after.split_once("Copilotcanread"))
.is_some_and(|(path, _)| path.trim_end_matches('/') == workspace.trim_end_matches('/'));
Some(trust_choice(screen, shown, "copilot-trust", |text| {
text == "Yes"
}))
}
fn cursor_trust(screen: &str, workspace: &Path) -> Option<Screen> {
let flat: String = screen
.chars()
.filter(|character| {
!character.is_whitespace() && !matches!(character, '│' | '╭' | '╮' | '╰' | '╯' | '─')
})
.collect();
if !flat.contains("WorkspaceTrustRequired") || !flat.contains("[a]Trustthisworkspace") {
return None;
}
if flat.contains("Trustingworkspace") {
return None;
}
let workspace = squash(&workspace.to_string_lossy());
let shown = flat
.split_once("Doyoutrustthecontentsofthisdirectory?")
.and_then(|(_, after)| after.split_once("[a]Trustthisworkspace"))
.is_some_and(|(path, _)| {
let path = path.trim_end_matches(['▶', '▸', '❯', '›', '>']);
path.trim_end_matches('/') == workspace.trim_end_matches('/')
});
Some(if shown {
Screen::Resolve {
rule: "cursor-trust",
keys: vec!["a"],
}
} else {
Screen::Unknown(head(screen))
})
}
fn trust_choice(
screen: &str,
shown: bool,
rule: &'static str,
accept: impl Fn(&str) -> bool,
) -> Screen {
let options = menu_options(screen);
let target = options.iter().position(|option| accept(&option.text));
let (true, Some(target)) = (shown, target) else {
return Screen::Unknown(head(screen));
};
let selected = options
.iter()
.position(|option| option.selected)
.unwrap_or(target);
let mut keys = keys_between(selected, target);
keys.push("enter");
Screen::Resolve { rule, keys }
}
pub(super) fn resolve(
herdr: &Herdr,
pane: &str,
run: &PaneRunArgs,
paths: &Paths,
status: Option<&str>,
) -> Result<Screen> {
let workspace = run.workspace.as_path();
if status == Some("working") {
return Ok(Screen::Clear);
}
let Some(screen) = herdr.screen(pane) else {
return Ok(Screen::Clear);
};
let outcome = classify(&screen, workspace);
if let Screen::Resolve { rule, keys } = &outcome {
let mut call = vec!["pane", "send-keys", pane];
call.extend(keys.iter().copied());
herdr.call(&call).map_err(|failure| {
anyhow::anyhow!("brgr could not answer the {rule} screen: {failure}")
})?;
eprintln!("brgr pane mode · {rule}: pressed {}", keys.join(" "));
record(&log_path(paths, run), pane, rule, keys);
std::thread::sleep(REDRAW);
}
Ok(outcome)
}
const SHELL_DRAW: Duration = Duration::from_secs(5);
const SHELL_DRAW_POLL: Duration = Duration::from_millis(100);
fn is_shell_update_offer(screen: &str) -> bool {
let lower = screen.to_lowercase();
lower.contains("[oh-my-zsh]") && lower.contains("would you like to update?")
}
pub(super) fn settle_shell(herdr: &Herdr, pane: &str, run: &PaneRunArgs, paths: &Paths) {
let started = Instant::now();
let mut answered = false;
let mut last = String::new();
let mut unchanged = 0;
while started.elapsed() < SHELL_DRAW {
let Some(screen) = herdr.screen(pane) else {
return;
};
if !answered && is_shell_update_offer(&screen) {
answered = true;
let keys = ["n"];
match herdr.call(&["pane", "send-keys", pane, keys[0]]) {
Ok(_) => record(&log_path(paths, run), pane, "shell-update-offer", &keys),
Err(failure) => {
eprintln!("brgr pane mode · could not skip a shell update offer: {failure}");
}
}
} else if screen == last && !screen.trim().is_empty() {
unchanged += 1;
if unchanged >= 2 {
return;
}
} else {
unchanged = 0;
}
last = screen;
std::thread::sleep(SHELL_DRAW_POLL);
}
}
pub(super) fn log_path(paths: &Paths, run: &PaneRunArgs) -> PathBuf {
paths
.runs
.join(format!("{}-r{}.screens.log", run.task, run.revision))
}
fn record(path: &Path, pane: &str, rule: &str, keys: &[&str]) {
let at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs());
let written = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut file| writeln!(file, "{at} {pane} {rule} {}", keys.join(" ")));
if let Err(error) = written {
eprintln!("brgr pane mode · could not log the {rule} key press: {error}");
}
}
pub(super) struct ScreenWatch {
kind: String,
limit: Duration,
since: Option<Instant>,
presses: Vec<(&'static str, u32)>,
}
const PRESS_BUDGET: u32 = 5;
impl ScreenWatch {
pub(super) fn new(kind: &str) -> Self {
Self::with_limit(kind, screen_deadline())
}
pub(super) fn with_limit(kind: &str, limit: Duration) -> Self {
Self {
kind: kind.to_owned(),
limit,
since: None,
presses: Vec::new(),
}
}
pub(super) fn pressed(&mut self, rule: &'static str) -> Result<()> {
let count =
if let Some((_, count)) = self.presses.iter_mut().find(|(name, _)| *name == rule) {
*count += 1;
*count
} else {
self.presses.push((rule, 1));
1
};
if count > PRESS_BUDGET {
bail!(
"the {} agent kept showing a screen brgr answers ({rule}); {PRESS_BUDGET} presses did not clear it",
self.kind
);
}
Ok(())
}
pub(super) fn observe(&mut self, screen: &Screen) -> Result<()> {
match screen {
Screen::Unknown(text) => self.stuck(text),
Screen::Clear => {
self.since = None;
self.presses.clear();
Ok(())
}
Screen::Resolve { .. } => {
self.since = None;
Ok(())
}
}
}
pub(super) fn observe_blocked(&mut self, screen: Option<&str>) -> Result<()> {
self.stuck(&screen.map(head).unwrap_or_default())
}
pub(super) fn reset(&mut self) {
self.since = None;
self.presses.clear();
}
pub(super) fn restart_clock(&mut self) {
self.since = None;
}
fn stuck(&mut self, text: &str) -> Result<()> {
let since = *self.since.get_or_insert_with(Instant::now);
if since.elapsed() >= self.limit {
bail!(
"the {} agent stayed on a screen no brgr rule answers for {}s: {text}",
self.kind,
self.limit.as_secs()
);
}
Ok(())
}
}
struct MenuOption {
selected: bool,
text: String,
}
fn menu_options(screen: &str) -> Vec<MenuOption> {
screen
.lines()
.filter_map(|line| {
let line = line.trim().trim_matches(['│', '┃', '║']).trim();
let (selected, rest) = ["›", "❯", ">", "▸"]
.iter()
.find_map(|cursor| line.strip_prefix(cursor))
.map_or((false, line), |rest| (true, rest.trim_start()));
let digits = rest.chars().take_while(char::is_ascii_digit).count();
let after = rest.get(digits..)?;
(digits > 0 && (after.starts_with('.') || after.starts_with(')'))).then(|| MenuOption {
selected,
text: after[1..].trim().to_owned(),
})
})
.collect()
}
fn is_skip(option: &str) -> bool {
let option = option.to_lowercase();
!option.contains("update now")
&& !option.contains("install")
&& [
"skip",
"not now",
"later",
"no thanks",
"dismiss",
"remind me",
]
.iter()
.any(|word| option.contains(word))
}
pub(crate) const LAST_SCREEN: &str = "last screen:";
pub(super) fn tail(screen: &str) -> String {
head(screen)
}
fn head(screen: &str) -> String {
let rows: Vec<&str> = screen
.lines()
.map(str::trim)
.filter(|row| !row.is_empty())
.collect();
rows[rows.len().saturating_sub(8)..].join(" | ")
}
#[cfg(test)]
mod tests {
use super::*;
const OPENCODE_UPDATED: &str = "\
┃ ┃
┃ MCP Authentication Required ┃
┃ ┃
┃ Updating to v2.0.15… ┃
┃ ┃
Update Complete esc
Successfully updated to OpenCode v2.0.15. Please
restart the application.
ok
┃
┃ Ask anything… \"Fix broken tests\"
┃
┃ Build auto · Big Pickle OpenCode Zen
╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
tab agents ctrl+p commands
";
fn work() -> &'static Path {
Path::new("/repo/task")
}
#[test]
fn an_applied_self_update_is_dismissed_with_esc_not_enter() {
assert_eq!(
classify(OPENCODE_UPDATED, work()),
Screen::Resolve {
rule: "update-applied",
keys: vec!["esc"],
}
);
}
#[test]
fn an_mcp_auth_toast_alone_does_not_block_the_run() {
let toast = OPENCODE_UPDATED
.lines()
.filter(|line| !line.contains("Update Complete") && !line.contains("restart"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(classify(&toast, work()), Screen::Clear);
}
#[test]
fn the_claude_trust_prompt_for_the_task_workspace_is_accepted() {
let screen =
"Accessing workspace:\n\n/repo/task\n\n❯ No, exit\n Yes, I trust this folder\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "workspace-trust",
keys: vec!["down", "enter"],
}
);
let selected =
"Accessing workspace:\n\n/repo/task\n\n No, exit\n❯ Yes, I trust this folder\n";
assert_eq!(
classify(selected, work()),
Screen::Resolve {
rule: "workspace-trust",
keys: vec!["enter"],
}
);
}
const CODEX_TRUST: &str = "\
Folder access
/private/tmp/scratchpad/cod
ex owner repo
Trust this folder? Codex can read, edit, and run files here, subject to your permission settings.
Folder settings can run code automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Back to Agent Command Center
enter continue · esc back
";
#[test]
fn the_codex_trust_prompt_for_the_task_workspace_is_accepted() {
assert_eq!(
classify(
CODEX_TRUST,
Path::new("/private/tmp/scratchpad/codex owner repo")
),
Screen::Resolve {
rule: "codex-trust",
keys: vec!["enter"],
}
);
let moved = CODEX_TRUST
.replace("› 1. Trust and continue", " 1. Trust and continue")
.replace(" 2. Back", "› 2. Back");
assert_eq!(
classify(
&moved,
Path::new("/private/tmp/scratchpad/codex owner repo")
),
Screen::Resolve {
rule: "codex-trust",
keys: vec!["up", "enter"],
}
);
}
#[test]
fn the_codex_trust_prompt_for_another_folder_is_not_accepted() {
assert!(matches!(
classify(CODEX_TRUST, Path::new("/elsewhere")),
Screen::Unknown(_)
));
}
#[test]
fn a_trust_prompt_for_another_folder_is_not_accepted() {
let screen =
"Accessing workspace:\n\n/elsewhere\n\n❯ No, exit\n Yes, I trust this folder\n";
assert!(matches!(classify(screen, work()), Screen::Unknown(_)));
}
#[test]
fn an_update_offer_is_skipped_by_moving_to_the_skip_option() {
let screen = "\
Update available! 0.1.0 -> 0.2.0
› 1. Update now
2. Skip
3. Skip until next version
";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "update-offer-skip",
keys: vec!["down", "enter"],
}
);
}
#[test]
fn an_update_offer_with_the_cursor_on_skip_only_presses_enter() {
let screen = "Update available\n 1. Update now\n❯ 2. Not now\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "update-offer-skip",
keys: vec!["enter"],
}
);
}
const CLAUDE_RENDERER_OFFER: &str =
include_str!("../../../../testdata/fixtures/screens/claude-2.1.318-renderer-offer.txt");
#[test]
fn claudes_renderer_offer_is_declined_by_moving_to_not_now() {
assert_eq!(
classify(CLAUDE_RENDERER_OFFER, Path::new("/somewhere")),
Screen::Resolve {
rule: "renderer-offer-skip",
keys: vec!["down", "enter"],
}
);
}
#[test]
fn a_question_about_a_fullscreen_renderer_without_that_offer_is_not_answered() {
let screen = "Should the fullscreen renderer be the default?\n❯ 1. Yes\n 2. Not now\n";
assert!(matches!(
classify(screen, Path::new("/somewhere")),
Screen::Unknown(_)
));
}
const CODEX_HOOKS_REVIEW: &str =
include_str!("../../../../testdata/fixtures/screens/codex-0.166-hooks-review.txt");
#[test]
fn codexs_hook_review_is_answered_by_trusting_all() {
assert_eq!(
classify(CODEX_HOOKS_REVIEW, Path::new("/somewhere")),
Screen::Resolve {
rule: "codex-hooks-trust",
keys: vec!["down", "enter"],
}
);
}
const COPILOT_TRUST: &str =
include_str!("../../../../testdata/fixtures/screens/copilot-1.0.86-trust.txt");
const COPILOT_WORKSPACE: &str = "/private/tmp/claude-501/-Users-justn-dev-brgr/b54b7525-6101-4186-a2b9-0e32355d043a/scratchpad/cap-copilot-1791247661";
const CODEX_WORKTREE_TRUST: &str =
include_str!("../../../../testdata/fixtures/screens/codex-0.160-worktree-trust.txt");
const CODEX_WORKTREE: &str = "/private/tmp/claude-501/-Users-justn-dev-brgr/b54b7525-6101-4186-a2b9-0e32355d043a/scratchpad/cap-codex-wt";
const CODEX_REPOSITORY: &str = "/private/tmp/claude-501/-Users-justn-dev-brgr/b54b7525-6101-4186-a2b9-0e32355d043a/scratchpad/cap-codex-repo";
#[test]
fn codex_trust_in_a_task_worktree_names_and_accepts_its_repository() {
assert_eq!(
codex_trust(CODEX_WORKTREE_TRUST, Path::new(CODEX_WORKTREE), || {
Some(PathBuf::from(CODEX_REPOSITORY))
}),
Some(Screen::Resolve {
rule: "codex-trust",
keys: vec!["enter"],
})
);
}
const CODEX_0166_WORKTREE_TRUST: &str =
include_str!("../../../../testdata/fixtures/screens/codex-0.166-worktree-trust.txt");
const CODEX_0166_WORKTREE: &str =
"/Users/justn/Library/Application Support/brgr/worktrees/ws/6ca4cbb7";
const CODEX_0166_REPOSITORY: &str = "/private/tmp/claude-501/-Users-justn-dev-brgr/2c6e674e-b811-4b53-8577-a4d60a8cc2ab/scratchpad/m226/ws";
#[test]
fn codex_0166_trust_in_a_task_worktree_names_and_accepts_its_repository() {
assert_eq!(
codex_trust(
CODEX_0166_WORKTREE_TRUST,
Path::new(CODEX_0166_WORKTREE),
|| Some(PathBuf::from(CODEX_0166_REPOSITORY))
),
Some(Screen::Resolve {
rule: "codex-trust",
keys: vec!["enter"],
})
);
}
#[test]
fn codex_trust_for_a_root_that_is_not_the_worktrees_repository_is_refused() {
for primary in [None, Some("/private/tmp/other-repo"), Some(CODEX_WORKTREE)] {
assert!(
matches!(
codex_trust(CODEX_WORKTREE_TRUST, Path::new(CODEX_WORKTREE), || {
primary.map(PathBuf::from)
}),
Some(Screen::Unknown(_))
),
"{primary:?}"
);
}
}
const CURSOR_TRUST: &str =
include_str!("../../../../testdata/fixtures/screens/cursor-2026.10.22-trust-wide.txt");
const CURSOR_WORKSPACE: &str = "/private/tmp/claude-501/-Users-justn-dev-brgr/2c6e674e-b811-4b53-8577-a4d60a8cc2ab/scratchpad/cur3-work";
const CURSOR_TRUST_ANSWERED: &str =
include_str!("../../../../testdata/fixtures/screens/cursor-2026.10.22-trust.txt");
const CURSOR_ANSWERED_WORKSPACE: &str = "/private/tmp/claude-501/-Users-justn-dev-brgr/2c6e674e-b811-4b53-8577-a4d60a8cc2ab/scratchpad/cursor-work";
#[test]
fn cursors_trust_prompt_for_the_task_workspace_is_accepted_with_its_key() {
assert_eq!(
classify(CURSOR_TRUST, Path::new(CURSOR_WORKSPACE)),
Screen::Resolve {
rule: "cursor-trust",
keys: vec!["a"],
}
);
}
#[test]
fn cursors_trust_prompt_for_another_folder_is_not_accepted() {
for other in [
"/private/tmp/claude-501/-Users-justn-dev-brgr/2c6e674e-b811-4b53-8577-a4d60a8cc2ab/scratchpad/cur3-wor",
"/private/tmp/claude-501",
"/somewhere/else",
] {
assert!(
matches!(classify(CURSOR_TRUST, Path::new(other)), Screen::Unknown(_)),
"{other}"
);
}
}
#[test]
fn cursors_answered_trust_box_still_on_screen_is_not_answered_again() {
assert_eq!(
classify(CURSOR_TRUST_ANSWERED, Path::new(CURSOR_ANSWERED_WORKSPACE)),
Screen::Clear
);
}
#[test]
fn copilots_trust_prompt_for_the_task_workspace_is_accepted_once() {
assert_eq!(
classify(COPILOT_TRUST, Path::new(COPILOT_WORKSPACE)),
Screen::Resolve {
rule: "copilot-trust",
keys: vec!["enter"],
}
);
}
#[test]
fn copilots_trust_prompt_for_another_folder_is_not_accepted() {
for other in [
"/private/tmp/claude-501/-Users-justn-dev-brgr/b54b7525-6101-4186-a2b9-0e32355d043a/scratchpad/cap-copilot-179124766",
"/private/tmp/claude-501",
"/somewhere/else",
] {
assert!(
matches!(
classify(COPILOT_TRUST, Path::new(other)),
Screen::Unknown(_)
),
"{other}"
);
}
}
#[test]
fn copilots_idle_screen_needs_no_answer() {
let idle = include_str!("../../../../testdata/fixtures/screens/copilot-1.0.86-idle.txt");
assert_eq!(classify(idle, Path::new(COPILOT_WORKSPACE)), Screen::Clear);
}
#[test]
fn an_agent_question_about_updating_something_is_not_an_update_offer() {
for screen in [
"Update the schema now?\n› 1. Yes\n 2. Later\n",
"Upgrade the dependencies too?\n› 1. Yes\n 2. Skip\n",
"> 1. update the config, then skip later steps\n 2. next\n",
] {
assert!(
matches!(classify(screen, work()), Screen::Unknown(_)),
"{screen}"
);
}
}
#[test]
fn an_update_offer_is_known_by_its_wording_or_version() {
assert!(is_update_offer("a new version of codex is ready, update?"));
assert!(is_update_offer("upgrade to v2.0.15"));
assert!(!is_update_offer("update the 2.0 schema"));
assert!(!is_update_offer("update readme.md and 1.2 notes"));
}
#[test]
fn an_update_menu_without_a_skip_option_is_not_guessed() {
let screen = "Update available\n› 1. Update now\n 2. Show release notes\n";
assert!(matches!(classify(screen, work()), Screen::Unknown(_)));
}
#[test]
fn the_bypass_permissions_warning_is_accepted() {
let screen = "WARNING: Claude Code running in Bypass Permissions mode\n\nIn Bypass Permissions mode, Claude Code will not ask for your approval.\n\n❯ 1. No, exit\n 2. Yes, I accept\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "bypass-warning",
keys: vec!["down", "enter"],
}
);
}
#[test]
fn a_new_mcp_server_prompt_is_trusted() {
let screen = "New MCP server found in .mcp.json: docs\n\n❯ 1. Use this and all future MCP servers in this project\n 2. Use this MCP server\n 3. Continue without using this MCP server\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "mcp-trust",
keys: vec!["down", "enter"],
}
);
}
#[test]
fn a_numbered_claude_trust_menu_is_answered_by_moving_to_yes() {
let screen = "Accessing workspace:\n\n/repo/task\n\nQuick safety check\n\n❯ 1. Yes, I trust this folder\n 2. No, exit\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "workspace-trust",
keys: vec!["enter"],
}
);
let other = screen
.replace("❯ 1. Yes", " 1. Yes")
.replace(" 2. No, exit", "❯ 2. No, exit");
assert_eq!(
classify(&other, work()),
Screen::Resolve {
rule: "workspace-trust",
keys: vec!["up", "enter"],
}
);
}
#[test]
fn the_codex_trust_prompt_for_a_longer_path_is_not_accepted() {
for longer in [
"/private/tmp/scratchpad/codex owner repo-evil",
"/private/tmp/scratchpad/codex owner repo/sub",
] {
let screen =
CODEX_TRUST.replace("/private/tmp/scratchpad/cod\n ex owner repo", longer);
assert!(
matches!(
classify(
&screen,
Path::new("/private/tmp/scratchpad/codex owner repo")
),
Screen::Unknown(_)
),
"{longer}"
);
}
}
#[test]
fn agent_prose_about_an_update_is_not_the_applied_update_dialog() {
let prose = "The build finished. Update complete, please restart the dev server.\n";
assert_eq!(classify(prose, work()), Screen::Clear);
}
#[test]
fn sentence_followed_by_a_continue_notice_is_dismissed_with_enter() {
assert_eq!(
classify("│ • Startup complete. Press Enter to continue… │\n", work()),
Screen::Resolve {
rule: "continue-notice",
keys: vec!["enter"],
}
);
}
#[test]
fn prose_quoting_or_discussing_a_continue_notice_is_left_alone() {
for prose in [
"The tool prints \"press enter to continue\" when it stops.\n",
"I saw press enter to continue in the log.\n",
"Then it says press enter to continue.\n",
"Press Enter to continue or Esc to cancel\n",
] {
assert_eq!(classify(prose, work()), Screen::Clear, "{prose:?}");
}
}
#[test]
fn a_screen_that_a_key_does_not_clear_fails_the_run_after_the_budget() {
let mut watch = ScreenWatch::new("claude");
for _ in 0..PRESS_BUDGET {
watch.pressed("continue-notice").unwrap();
}
let error = watch.pressed("continue-notice").unwrap_err().to_string();
assert!(
error.contains("continue-notice") && error.contains("did not clear"),
"{error}"
);
watch.observe(&Screen::Clear).unwrap();
watch.pressed("continue-notice").unwrap();
}
#[test]
fn a_new_mcp_server_prompt_prefers_the_single_server_choice() {
let screen = "New MCP server found in .mcp.json: docs\n\n❯ 1. Use this and all future MCP servers in this project\n 2. Use this MCP server\n 3. Continue without using this MCP server\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "mcp-trust",
keys: vec!["down", "enter"],
}
);
}
#[test]
fn an_update_menu_with_a_continue_line_is_skipped_not_confirmed() {
let screen = "Update available\n› 1. Update now\n 2. Skip\nPress Enter to continue\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "update-offer-skip",
keys: vec!["down", "enter"],
}
);
}
#[test]
fn a_continue_notice_is_dismissed_with_enter() {
assert_eq!(
classify(
"Welcome to the new release\n\n Press Enter to continue\n",
work()
),
Screen::Resolve {
rule: "continue-notice",
keys: vec!["enter"],
}
);
}
#[test]
fn a_menu_no_rule_covers_is_unknown_and_never_pressed() {
let screen = "Choose a theme\n› 1. Dark\n 2. Light\n";
assert_eq!(
classify(screen, work()),
Screen::Unknown("Choose a theme | › 1. Dark | 2. Light".to_owned())
);
}
#[test]
fn an_ordinary_agent_screen_is_clear() {
assert_eq!(classify("some output\n❯ \n", work()), Screen::Clear);
}
fn fake_herdr(screen: &str) -> (tempfile::TempDir, Herdr) {
let temp = tempfile::tempdir().unwrap();
let binary = temp.path().join("herdr");
std::fs::write(temp.path().join("screen"), screen).unwrap();
crate::fast_script::write_fast_script(
&binary,
r#"#!/bin/sh
d=$(/usr/bin/dirname "$0")
case "$1 $2" in
'pane read') /bin/cat "$d/screen";;
'pane send-keys') echo "$@" >> "$d/keys";;
*) exit 8;;
esac
"#,
);
let herdr = Herdr {
binary: binary.into_os_string(),
session: None,
};
(temp, herdr)
}
fn run_args(temp: &tempfile::TempDir) -> (Paths, PaneRunArgs) {
let paths = Paths::new(Some(temp.path().join("home"))).unwrap();
let run = PaneRunArgs {
caller: None,
native_executable: None,
opencode_model: None,
prompt_file: PathBuf::new(),
workspace: PathBuf::from("/repo/task"),
task: brgr_protocol::TaskId::new(),
revision: 1,
kind: "opencode".to_owned(),
agent_args: Vec::new(),
keep_pane: false,
};
(paths, run)
}
#[test]
fn a_shell_update_offer_is_declined_before_anything_is_typed() {
let (temp, herdr) = fake_herdr("[oh-my-zsh] Would you like to update? [Y/n] ");
let (paths, run) = run_args(&temp);
settle_shell(&herdr, "w1:p2", &run, &paths);
let keys = std::fs::read_to_string(temp.path().join("keys")).unwrap();
assert_eq!(keys.trim(), "pane send-keys w1:p2 n");
let log = std::fs::read_to_string(log_path(&paths, &run)).unwrap();
assert!(
log.trim_end().ends_with("w1:p2 shell-update-offer n"),
"{log}"
);
}
#[test]
fn a_plain_shell_prompt_presses_nothing() {
let (temp, herdr) = fake_herdr("repo on main\n❯ update the docs? [Y/n]");
let (paths, run) = run_args(&temp);
settle_shell(&herdr, "w1:p2", &run, &paths);
assert!(!temp.path().join("keys").exists());
}
#[test]
fn resolve_presses_the_rule_keys_on_the_pane() {
let (temp, herdr) = fake_herdr(OPENCODE_UPDATED);
let (paths, run) = run_args(&temp);
let outcome = resolve(&herdr, "w1:p2", &run, &paths, Some("idle")).unwrap();
assert!(matches!(outcome, Screen::Resolve { .. }));
let keys = std::fs::read_to_string(temp.path().join("keys")).unwrap();
assert_eq!(keys.trim(), "pane send-keys w1:p2 esc");
let log = std::fs::read_to_string(log_path(&paths, &run)).unwrap();
assert!(
log.trim_end().ends_with("w1:p2 update-applied esc"),
"{log}"
);
}
#[test]
fn resolve_presses_nothing_while_the_agent_is_working() {
let screen = "agent output\n› 1. Update now\n 2. Skip\n";
let (temp, herdr) = fake_herdr(screen);
let (paths, run) = run_args(&temp);
assert_eq!(
resolve(&herdr, "w1:p2", &run, &paths, Some("working")).unwrap(),
Screen::Clear
);
assert!(!temp.path().join("keys").exists());
assert!(!log_path(&paths, &run).exists());
}
#[test]
fn an_unknown_screen_fails_the_run_with_its_text_after_the_deadline() {
let mut watch = ScreenWatch::with_limit("opencode", Duration::ZERO);
let error = watch
.observe(&Screen::Unknown("Choose a theme".to_owned()))
.unwrap_err()
.to_string();
assert!(
error.contains("opencode") && error.contains("Choose a theme"),
"{error}"
);
}
#[test]
fn a_resolved_screen_resets_the_deadline() {
let mut watch = ScreenWatch::with_limit("claude", Duration::from_hours(1));
watch.observe(&Screen::Unknown("x".to_owned())).unwrap();
watch.observe(&Screen::Clear).unwrap();
assert!(watch.since.is_none());
}
#[test]
fn an_answered_screen_resets_the_deadline() {
let mut watch = ScreenWatch::with_limit("claude", Duration::from_hours(1));
watch.observe(&Screen::Unknown("x".to_owned())).unwrap();
watch
.observe(&Screen::Resolve {
rule: "continue-notice",
keys: vec!["enter"],
})
.unwrap();
assert!(watch.since.is_none());
}
#[test]
fn the_unknown_screen_deadline_is_three_minutes() {
assert_eq!(SCREEN_DEADLINE, Duration::from_mins(3));
}
#[test]
fn an_update_offer_is_skipped_by_its_later_option() {
let screen = "Update available\n› 1. Update now\n 2. Later\n";
assert_eq!(
classify(screen, work()),
Screen::Resolve {
rule: "update-offer-skip",
keys: vec!["down", "enter"],
}
);
}
}