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",
] {
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());
}
}