use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::models::Pane;
pub fn list_panes() -> HashMap<u32, Pane> {
list_panes_checked().unwrap_or_default()
}
pub fn list_panes_checked() -> Result<HashMap<u32, Pane>, String> {
let mut map = HashMap::new();
let out = Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{session_name}|#{window_index}.#{pane_index}|#{pane_pid}|#{pane_tty}|#{pane_current_command}|#{pane_current_path}|#{?pane_active,1,0}|#{pane_id}|#{window_name}",
])
.output()
.map_err(|e| format!("tmux list-panes failed: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let detail = stderr.trim();
if detail.is_empty() {
return Err(format!("tmux list-panes exited {}", out.status));
}
return Err(format!("tmux list-panes exited {}: {detail}", out.status));
}
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let parts: Vec<&str> = line.splitn(9, '|').collect();
if parts.len() < 9 {
continue;
}
let Ok(pid) = parts[2].parse::<u32>() else {
continue;
};
let active = parts[6] == "1";
map.insert(
pid,
Pane {
target: format!("{}:{}", parts[0], parts[1]),
tmux_session: parts[0].to_string(),
window_name: parts[8].to_string(),
pane_id: parts[7].to_string(),
pid,
tty: parts[3].to_string(),
current_command: parts[4].to_string(),
cwd: PathBuf::from(parts[5]),
active,
},
);
}
Ok(map)
}
pub fn build_ppid_map() -> HashMap<u32, u32> {
let mut map = HashMap::new();
let Ok(out) = Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return map;
};
if !out.status.success() {
return map;
}
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.split_whitespace();
let Some(pid) = parts.next().and_then(|s| s.parse::<u32>().ok()) else {
continue;
};
let Some(ppid) = parts.next().and_then(|s| s.parse::<u32>().ok()) else {
continue;
};
map.insert(pid, ppid);
}
map
}
pub fn find_owning_pane(
pid: u32,
pane_pids: &HashMap<u32, Pane>,
ppid_map: &HashMap<u32, u32>,
max_hops: usize,
) -> Option<Pane> {
let mut cur = pid;
for _ in 0..=max_hops {
if let Some(pane) = pane_pids.get(&cur) {
return Some(pane.clone());
}
let ppid = *ppid_map.get(&cur)?;
if ppid <= 1 {
return None;
}
cur = ppid;
}
None
}
pub fn attach_if_alive(zoom: bool) -> std::io::Result<bool> {
let panes = list_panes();
if let Some(LocatedTriage::Live(pane)) = locate_triage(&panes) {
focus_and_maybe_zoom(&pane, zoom)?;
return Ok(true);
}
Ok(false)
}
pub fn current_client_width() -> Option<u16> {
let out = Command::new("tmux")
.args(["display-message", "-p", "#{client_width}"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
pub fn jump_to_self(zoom: bool) -> std::io::Result<()> {
let panes = list_panes();
let cmd = if zoom {
"triage --zoom-on-jump"
} else {
"triage"
};
match locate_triage(&panes) {
Some(LocatedTriage::Live(pane)) => {
focus_and_maybe_zoom(&pane, zoom)?;
Ok(())
}
Some(LocatedTriage::PaneStale(pane)) => {
Command::new("tmux")
.args(["respawn-pane", "-k", "-t", &pane.target, cmd])
.status()?;
focus_and_maybe_zoom(&pane, zoom)?;
Ok(())
}
None => {
Command::new("tmux")
.args(["new-window", "-n", "triage", cmd])
.status()?;
Ok(())
}
}
}
fn focus_and_maybe_zoom(pane: &Pane, zoom: bool) -> std::io::Result<()> {
let window = pane
.target
.rsplit_once('.')
.map(|(w, _)| w)
.unwrap_or(&pane.target);
Command::new("tmux")
.args(["switch-client", "-t", &pane.tmux_session])
.status()?;
Command::new("tmux")
.args(["select-window", "-t", window])
.status()?;
Command::new("tmux")
.args(["select-pane", "-t", &pane.target])
.status()?;
if zoom {
let already_zoomed = Command::new("tmux")
.args([
"display-message",
"-p",
"-t",
&pane.target,
"#{window_zoomed_flag}",
])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "1")
.unwrap_or(false);
if !already_zoomed {
Command::new("tmux")
.args(["resize-pane", "-Z", "-t", &pane.target])
.status()?;
}
}
Ok(())
}
enum LocatedTriage {
Live(Pane),
PaneStale(Pane),
}
fn locate_triage(panes: &HashMap<u32, Pane>) -> Option<LocatedTriage> {
let record = crate::approval::read_alive_record()?;
let alive = crate::discovery::pid_alive(record.pid);
let ppid_map = if alive {
build_ppid_map()
} else {
HashMap::new()
};
locate_triage_from_record(&record, alive, panes, &ppid_map)
}
fn locate_triage_from_record(
record: &crate::approval::AliveRecord,
alive: bool,
panes: &HashMap<u32, Pane>,
ppid_map: &HashMap<u32, u32>,
) -> Option<LocatedTriage> {
if alive && let Some(pane) = find_owning_pane(record.pid, panes, ppid_map, 8) {
return Some(LocatedTriage::Live(pane));
}
if let Some(pane) = record
.pane_id
.as_deref()
.and_then(|pane_id| pane_by_id(panes, pane_id))
{
return Some(if alive {
LocatedTriage::Live(pane)
} else {
LocatedTriage::PaneStale(pane)
});
}
None
}
fn pane_by_id(panes: &HashMap<u32, Pane>, pane_id: &str) -> Option<Pane> {
panes.values().find(|pane| pane.pane_id == pane_id).cloned()
}
pub fn jump_to(target: &str, zoom: bool) -> std::io::Result<()> {
let session = target.split_once(':').map(|(s, _)| s).unwrap_or("");
let window = target.rsplit_once('.').map(|(w, _)| w).unwrap_or(target);
if !session.is_empty() {
Command::new("tmux")
.args(["switch-client", "-t", session])
.status()?;
}
Command::new("tmux")
.args(["select-window", "-t", window])
.status()?;
Command::new("tmux")
.args(["select-pane", "-t", target])
.status()?;
if zoom {
let already_zoomed = Command::new("tmux")
.args([
"display-message",
"-p",
"-t",
target,
"#{window_zoomed_flag}",
])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "1")
.unwrap_or(false);
if !already_zoomed {
Command::new("tmux")
.args(["resize-pane", "-Z", "-t", target])
.status()?;
}
}
Ok(())
}
pub fn send_keys(target: &str, keys: &[&str]) -> std::io::Result<()> {
let mut cmd = Command::new("tmux");
cmd.args(["send-keys", "-t", target]);
for k in keys {
cmd.arg(k);
}
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!(
"tmux send-keys exited {status}"
)))
}
}
pub fn new_window(
name: &str,
cwd: &Path,
command: &str,
detached: bool,
) -> std::io::Result<String> {
let command = command_in_cwd(cwd, command);
let mut tmux = Command::new("tmux");
tmux.args(["new-window", "-P", "-F", "#{pane_id}"]);
if detached {
tmux.arg("-d");
}
let output = tmux
.arg("-c")
.arg(cwd)
.args(["-n", name])
.arg(command)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(std::io::Error::other(if stderr.is_empty() {
format!("tmux new-window exited {}", output.status)
} else {
stderr
}));
}
let pane_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if pane_id.is_empty() {
return Err(std::io::Error::other("tmux new-window returned no pane id"));
}
Ok(pane_id)
}
fn command_in_cwd(cwd: &Path, command: &str) -> String {
format!(
"cd {} && {}",
shell_quote(&cwd.display().to_string()),
command
)
}
pub(crate) fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
pub fn send_after_boot(target: &str, text: &str, settle: Duration) -> std::io::Result<()> {
thread::sleep(settle);
send_keys(target, &["Enter"])?;
thread::sleep(Duration::from_millis(300));
paste_text_and_enter(target, text)
}
pub fn paste_text_and_enter(target: &str, text: &str) -> std::io::Result<()> {
let nonce = buffer_nonce();
let buffer_name = format!("triage-msg-{nonce}");
let temp_dir = triage_temp_dir();
fs::create_dir_all(&temp_dir)?;
#[cfg(unix)]
{
let _ = fs::set_permissions(&temp_dir, fs::Permissions::from_mode(0o700));
}
let temp_file = temp_dir.join(format!("{buffer_name}.txt"));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
options.mode(0o600);
}
{
let mut file = options.open(&temp_file)?;
file.write_all(text.as_bytes())?;
}
let load_result = Command::new("tmux")
.args(["load-buffer", "-b", &buffer_name])
.arg(&temp_file)
.status();
let _ = fs::remove_file(&temp_file);
let status = load_result?;
if !status.success() {
return Err(std::io::Error::other(format!(
"tmux load-buffer exited {status}"
)));
}
let paste_status = Command::new("tmux")
.args(["paste-buffer", "-d", "-p", "-b", &buffer_name, "-t", target])
.status()?;
if !paste_status.success() {
let _ = Command::new("tmux")
.args(["delete-buffer", "-b", &buffer_name])
.status();
return Err(std::io::Error::other(format!(
"tmux paste-buffer exited {paste_status}"
)));
}
send_keys(target, &["Enter"])
}
fn buffer_nonce() -> String {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid}-{nanos}")
}
fn triage_temp_dir() -> PathBuf {
std::env::var_os("TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var_os("HOME")
.map(|h| PathBuf::from(h).join(".config/triage/tmp"))
.unwrap_or_else(|| PathBuf::from("/tmp"))
})
.join("triage")
}
pub fn capture_pane(target: &str) -> Option<String> {
let out = Command::new("tmux")
.args(["capture-pane", "-p", "-S", "-200", "-t", target])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn capture_pane_tail(target: &str, lines: u32) -> Option<String> {
let start = format!("-{lines}");
let out = Command::new("tmux")
.args(["capture-pane", "-p", "-S", &start, "-t", target])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn capture_pane_tail_ansi(target: &str, lines: u32) -> Option<String> {
let start = format!("-{lines}");
let out = Command::new("tmux")
.args(["capture-pane", "-e", "-p", "-S", &start, "-t", target])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn capture_pane_visible_ansi(target: &str) -> Option<String> {
let out = Command::new("tmux")
.args(["capture-pane", "-e", "-p", "-t", target])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn has_pending_permission_prompt(pane: &str) -> bool {
let mut found_cursor = false;
let mut found_footer = false;
for line in pane.lines() {
let trimmed = line.trim();
if trimmed == "❯ 1. Yes" {
found_cursor = true;
}
if trimmed == "Esc to cancel · Tab to amend" {
found_footer = true;
}
if found_cursor && found_footer {
return true;
}
}
false
}
pub fn has_codex_permission_prompt(pane: &str) -> bool {
let mut found_question = false;
let mut found_yes = false;
let mut found_no = false;
for line in pane.lines() {
let trimmed = trim_codex_prompt_line(line);
if is_codex_prompt_question(trimmed) {
found_question = true;
}
if is_codex_yes_choice(trimmed) {
found_yes = true;
}
if is_codex_no_choice(trimmed) {
found_no = true;
}
if found_question && found_yes && found_no {
return true;
}
}
false
}
pub fn has_draft_input(pane: &str) -> bool {
for line in pane.lines().rev() {
if let Some(pos) = line.find('❯') {
return composer_has_real_text(&line[pos + '❯'.len_utf8()..]);
}
}
false
}
fn composer_has_real_text(s: &str) -> bool {
let mut faint = false;
let mut reverse = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
let mut params = String::new();
let mut final_byte = '\0';
for p in chars.by_ref() {
if p.is_ascii_alphabetic() {
final_byte = p;
break;
}
params.push(p);
}
if final_byte == 'm' {
for code in params.split(';') {
match code {
"2" => faint = true,
"7" => reverse = true,
"22" => faint = false,
"27" => reverse = false,
"" | "0" => {
faint = false;
reverse = false;
}
_ => {}
}
}
}
}
continue;
}
if !faint && !reverse && c != '\u{a0}' && !c.is_whitespace() {
return true;
}
}
false
}
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for p in chars.by_ref() {
if p.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
out.push(c);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexPromptChoice {
Yes,
No,
Other,
}
pub fn codex_selected_permission_choice(pane: &str) -> Option<CodexPromptChoice> {
let mut choice = None;
for line in pane.lines() {
let Some(trimmed) = selected_codex_prompt_line(line) else {
continue;
};
choice = Some(if is_codex_yes_choice(trimmed) {
CodexPromptChoice::Yes
} else if is_codex_no_choice(trimmed) {
CodexPromptChoice::No
} else {
CodexPromptChoice::Other
});
}
choice
}
pub fn parse_codex_pending_full(pane: &str) -> Option<String> {
let lines: Vec<&str> = pane.lines().collect();
let (question_idx, question) = lines.iter().enumerate().rev().find_map(|(idx, line)| {
let trimmed = trim_codex_prompt_line(line);
is_codex_prompt_question(trimmed).then_some((idx, trimmed))
})?;
let mut collected = Vec::new();
for line in &lines[question_idx + 1..] {
let trimmed = trim_codex_prompt_line(line);
if trimmed.is_empty() {
continue;
}
if is_codex_choice(trimmed) || trimmed.starts_with("Press enter to confirm") {
break;
}
collected.push(trimmed.to_string());
}
if collected.is_empty() && question.starts_with("Allow Codex to ") {
collected.push(question.to_string());
}
(!collected.is_empty()).then(|| collected.join("\n"))
}
fn trim_codex_prompt_line(line: &str) -> &str {
let trimmed = line
.trim()
.trim_matches(|c: char| c.is_whitespace() || matches!(c, '│' | '┃' | '║' | '┆' | '┊'))
.trim()
.trim_start_matches(|c: char| {
c.is_whitespace() || matches!(c, '›' | '❯' | '>' | '-' | '•' | '●' | '○')
})
.trim();
strip_numbered_choice_prefix(trimmed)
}
fn selected_codex_prompt_line(line: &str) -> Option<&str> {
let trimmed = line
.trim()
.trim_matches(|c: char| c.is_whitespace() || matches!(c, '│' | '┃' | '║' | '┆' | '┊'))
.trim();
let selected = trimmed
.strip_prefix('›')
.or_else(|| trimmed.strip_prefix('❯'))?;
Some(strip_numbered_choice_prefix(selected.trim()))
}
fn strip_numbered_choice_prefix(line: &str) -> &str {
let Some((prefix, rest)) = line.split_once(". ") else {
return line;
};
if prefix.chars().all(|c| c.is_ascii_digit()) {
rest.trim()
} else {
line
}
}
fn is_codex_prompt_question(line: &str) -> bool {
matches!(
line,
"Would you like to run the following command?"
| "Would you like to grant these permissions?"
| "Would you like to make the following edits?"
) || line.starts_with("Allow Codex to run `")
|| line.ends_with(" needs your approval.")
}
fn is_codex_yes_choice(line: &str) -> bool {
line.starts_with("Yes, proceed")
|| line == "Yes, just this once"
|| line.starts_with("Yes, and ")
|| line.starts_with("Allow this request")
|| line.starts_with("Run the tool")
}
fn is_codex_no_choice(line: &str) -> bool {
line.starts_with("No, ")
|| line == "Cancel this request"
|| line == "Decline this request and continue"
}
fn is_codex_choice(line: &str) -> bool {
is_codex_yes_choice(line) || is_codex_no_choice(line)
}
pub fn parse_pending_brief(pane: &str) -> Option<String> {
let lines: Vec<&str> = pane.lines().collect();
let opt_idx = lines.iter().rposition(|l| l.contains("1. Yes"))?;
let mut collected: Vec<&str> = Vec::new();
for line in lines[..opt_idx].iter().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("Do you want") {
continue;
}
if is_outer_separator(trimmed) {
break;
}
if is_inner_separator(trimmed) {
continue;
}
collected.push(trimmed);
if collected.len() >= 20 {
break;
}
}
if collected.is_empty() {
return None;
}
collected.reverse();
if collected.first().is_some_and(|l| is_chip_header(l)) {
collected.remove(0);
}
Some(collected.join(" "))
}
pub fn parse_pending_full(pane: &str) -> Option<String> {
let lines: Vec<&str> = pane.lines().collect();
let opt_idx = lines.iter().rposition(|l| l.contains("1. Yes"))?;
let mut collected: Vec<&str> = Vec::new();
for line in lines[..opt_idx].iter().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("Do you want") {
continue;
}
if is_outer_separator(trimmed) {
break;
}
if is_inner_separator(trimmed) {
continue;
}
collected.push(trimmed);
}
if collected.is_empty() {
return None;
}
collected.reverse();
if collected.first().is_some_and(|l| is_chip_header(l)) {
collected.remove(0);
}
Some(collected.join("\n"))
}
fn is_outer_separator(s: &str) -> bool {
!s.is_empty() && s.chars().count() >= 20 && s.chars().all(|c| c == '─')
}
fn is_inner_separator(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| matches!(c, '╌' | '╴' | '╶'))
}
fn is_chip_header(s: &str) -> bool {
let mut iter = s.split_whitespace();
let Some(first) = iter.next() else {
return false;
};
let Some(second) = iter.next() else {
return false;
};
if iter.next().is_some() {
return false;
}
first.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& matches!(
second,
"command" | "file" | "search" | "fetch" | "URL" | "request"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn new_window_command_explicitly_enters_cwd() {
assert_eq!(
command_in_cwd(Path::new("/tmp/my project"), "claude"),
"cd '/tmp/my project' && claude"
);
}
#[test]
fn shell_quote_handles_single_quotes() {
assert_eq!(
command_in_cwd(Path::new("/tmp/it's ok"), "codex"),
"cd '/tmp/it'\\''s ok' && codex"
);
}
fn pane(pid: u32, pane_id: &str, current_command: &str) -> Pane {
Pane {
target: format!("main:1.{pid}"),
tmux_session: "main".to_string(),
window_name: "triage".to_string(),
pane_id: pane_id.to_string(),
pid,
tty: "/dev/ttys001".to_string(),
current_command: current_command.to_string(),
cwd: PathBuf::from("/tmp/project"),
active: false,
}
}
#[test]
fn find_owning_pane_matches_direct_pane_pid() {
let pane = Pane {
target: "main:1.0".to_string(),
tmux_session: "main".to_string(),
window_name: "agent-ACDC-21".to_string(),
pane_id: "%311".to_string(),
pid: 98644,
tty: "/dev/ttys001".to_string(),
current_command: "2.1.158".to_string(),
cwd: PathBuf::from("/tmp/project"),
active: false,
};
let mut panes = HashMap::new();
panes.insert(pane.pid, pane);
let found = find_owning_pane(98644, &panes, &HashMap::new(), 8).unwrap();
assert_eq!(found.pane_id, "%311");
}
fn alive_record(pid: u32, pane_id: Option<&str>) -> crate::approval::AliveRecord {
crate::approval::AliveRecord {
pid,
pane_id: pane_id.map(str::to_string),
}
}
#[test]
fn locates_live_triage_by_pid_walk() {
let mut panes = HashMap::new();
panes.insert(10, pane(10, "%10", "fish"));
panes.insert(11, pane(11, "%11", "triage"));
let record = alive_record(11, Some("%11"));
let located = locate_triage_from_record(&record, true, &panes, &HashMap::new());
match located {
Some(LocatedTriage::Live(p)) => assert_eq!(p.pane_id, "%11"),
_ => panic!("expected live triage located by pid"),
}
}
#[test]
fn falls_back_to_recorded_pane_id_when_pid_walk_misses() {
let mut panes = HashMap::new();
panes.insert(11, pane(11, "%11", "fish")); let record = alive_record(999, Some("%11"));
let located = locate_triage_from_record(&record, true, &panes, &HashMap::new());
match located {
Some(LocatedTriage::Live(p)) => assert_eq!(p.pane_id, "%11"),
_ => panic!("expected fallback to recorded pane_id"),
}
}
#[test]
fn dead_pid_with_surviving_pane_is_stale() {
let mut panes = HashMap::new();
panes.insert(11, pane(11, "%11", "fish"));
let record = alive_record(11, Some("%11"));
let located = locate_triage_from_record(&record, false, &panes, &HashMap::new());
match located {
Some(LocatedTriage::PaneStale(p)) => assert_eq!(p.pane_id, "%11"),
_ => panic!("expected stale recorded pane for respawn"),
}
}
#[test]
fn dead_pid_with_no_pane_is_none() {
let panes = HashMap::new();
let record = alive_record(11, Some("%11"));
assert!(locate_triage_from_record(&record, false, &panes, &HashMap::new()).is_none());
}
#[test]
fn does_not_attach_to_launching_pane() {
let mut panes = HashMap::new();
panes.insert(366, pane(366, "%366", "triage"));
let record = alive_record(999, Some("%999"));
assert!(locate_triage_from_record(&record, false, &panes, &HashMap::new()).is_none());
assert!(locate_triage_from_record(&record, true, &panes, &HashMap::new()).is_none());
}
#[test]
fn draft_input_detected_when_composer_has_text() {
let pane =
"──────────────\n❯\u{a0}leave it to the coordinator\n──────────────\n [Opus] ux";
assert!(has_draft_input(pane));
}
#[test]
fn faint_placeholder_is_not_draft() {
let pane = "─────\n\u{1b}[39m❯\u{a0}\u{1b}[2mmonitor PR until CI green\u{1b}[0m\n─────";
assert!(!has_draft_input(pane));
}
#[test]
fn per_word_faint_placeholder_is_not_draft() {
let pane = "❯\u{a0}\u{1b}[2mmark\u{1b}[0m \u{1b}[2mit\u{1b}[0m \u{1b}[2mready\u{1b}[0m";
assert!(!has_draft_input(pane));
}
#[test]
fn cursor_on_faint_placeholder_is_not_draft() {
let pane = "❯\u{a0}\u{1b}[7ms\u{1b}[0;2mtand down until coordinator replies\u{1b}[0m";
assert!(!has_draft_input(pane));
}
#[test]
fn real_text_with_trailing_ghost_is_draft() {
let pane = "❯\u{a0}fix the \u{1b}[2mbug in the parser\u{1b}[0m";
assert!(has_draft_input(pane));
}
#[test]
fn empty_composer_is_not_draft() {
let pane = "──────────────\n❯\u{a0}\n──────────────\n [Opus] ux";
assert!(!has_draft_input(pane));
}
#[test]
fn draft_uses_bottom_most_composer_line() {
let pane = "❯\u{a0}an old prompt from history\nassistant replied\n──────\n❯\u{a0}\n──────";
assert!(!has_draft_input(pane));
}
#[test]
fn no_composer_marker_is_not_draft() {
assert!(!has_draft_input("just some output\nno prompt here"));
}
#[test]
fn detects_codex_command_approval_prompt() {
let pane = r#"
╭────────────────────────────────────────────╮
│ Would you like to run the following command? │
│ cargo install --path . │
│ › Yes, proceed │
│ No, and tell Codex what to do differently │
╰────────────────────────────────────────────╯
"#;
assert!(has_codex_permission_prompt(pane));
}
#[test]
fn detects_real_codex_numbered_command_prompt() {
let pane = r#"
Would you like to run the following command?
Reason: Allow Snowflake CLI to run a small context probe so I can identify why the tracker_v3 query is compiling as object-not-found?
$ snow sql --format CSV --silent -f ios-envelope-401/sql/current_snowflake_context_probe.sql > ios-envelope-401/data/current_snowflake_context_probe.csv 2> ios-envelope-401/data/current_snowflake_context_probe.err
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `snow sql --format CSV --silent -f ios-envelope-401/sql/current_snowflake_context_probe.sql > ios-envelope-401/data/current_snowflake_context_probe.csv 2> ios-envelope-401/data/current_snowflake_context_probe.err` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
"#;
assert!(has_codex_permission_prompt(pane));
assert_eq!(
codex_selected_permission_choice(pane),
Some(CodexPromptChoice::Yes)
);
assert_eq!(
parse_codex_pending_full(pane).as_deref(),
Some(
"Reason: Allow Snowflake CLI to run a small context probe so I can identify why the tracker_v3 query is compiling as object-not-found?\n$ snow sql --format CSV --silent -f ios-envelope-401/sql/current_snowflake_context_probe.sql > ios-envelope-401/data/current_snowflake_context_probe.csv 2> ios-envelope-401/data/current_snowflake_context_probe.err"
)
);
}
#[test]
fn ignores_codex_prompt_text_without_choices() {
let pane = r#"
let s = "Would you like to run the following command?";
println!("Yes, proceed");
"#;
assert!(!has_codex_permission_prompt(pane));
}
#[test]
fn detects_codex_selected_no_choice() {
let pane = r#"
Would you like to run the following command?
1. Yes, proceed (y)
› 2. No, and tell Codex what to do differently (esc)
"#;
assert_eq!(
codex_selected_permission_choice(pane),
Some(CodexPromptChoice::No)
);
}
#[test]
fn codex_selected_choice_prefers_latest_prompt_in_tail() {
let pane = r#"
Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Would you like to run the following command?
1. Yes, proceed (y)
› 2. No, and tell Codex what to do differently (esc)
"#;
assert_eq!(
codex_selected_permission_choice(pane),
Some(CodexPromptChoice::No)
);
}
#[test]
fn parse_codex_pending_full_prefers_latest_prompt() {
let pane = r#"
Would you like to run the following command?
$ old command
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Would you like to run the following command?
Reason: newer request
$ date
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
"#;
assert_eq!(
parse_codex_pending_full(pane).as_deref(),
Some("Reason: newer request\n$ date")
);
}
}