use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use crate::sessions::{SessionRegistry, Tmux};
use crate::{frontmatter, sessions};
#[derive(Debug, Serialize)]
pub struct PromptInfo {
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub question: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<PromptOption>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct PromptOption {
pub index: usize,
pub label: String,
}
pub fn run(file: &Path) -> Result<()> {
run_with_tmux(file, &Tmux::default_server())
}
pub fn run_with_tmux(file: &Path, tmux: &Tmux) -> Result<()> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (_updated, session_id) = frontmatter::ensure_session(&content)?;
let pane = sessions::lookup(&session_id)?;
let pane_id = match pane {
Some(p) => p,
None => {
let info = PromptInfo {
active: false,
question: None,
options: None,
selected: None,
};
println!("{}", serde_json::to_string(&info)?);
return Ok(());
}
};
if !tmux.pane_alive(&pane_id) {
let info = PromptInfo {
active: false,
question: None,
options: None,
selected: None,
};
println!("{}", serde_json::to_string(&info)?);
return Ok(());
}
let pane_content = sessions::capture_pane(tmux,&pane_id)?;
let info = parse_prompt(&pane_content);
println!("{}", serde_json::to_string(&info)?);
Ok(())
}
#[derive(Debug, Serialize)]
pub struct PromptAllEntry {
pub session_id: String,
pub file: String,
#[serde(flatten)]
pub prompt: PromptInfo,
}
pub fn run_all() -> Result<()> {
run_all_with_tmux(&Tmux::default_server())
}
pub fn run_all_with_tmux(tmux: &Tmux) -> Result<()> {
let registry: SessionRegistry = sessions::load()?;
let mut entries: Vec<PromptAllEntry> = Vec::new();
let verbose = std::env::var("AGENT_DOC_PROMPT_DEBUG").is_ok();
for (session_id, entry) in ®istry {
if !tmux.pane_alive(&entry.pane) {
if verbose {
eprintln!("[prompt] pane {} dead for session {} ({})", entry.pane, session_id, entry.file);
}
continue;
}
let prompt = match sessions::capture_pane(tmux,&entry.pane) {
Ok(content) => {
if verbose {
let last_lines: Vec<&str> = content.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(5)
.collect();
eprintln!("[prompt] pane {} ({}) last lines:", entry.pane, entry.file);
for line in last_lines.iter().rev() {
eprintln!("[prompt] {}", strip_ansi(line));
}
}
parse_prompt(&content)
}
Err(e) => {
if verbose {
eprintln!("[prompt] capture failed for pane {}: {}", entry.pane, e);
}
inactive()
}
};
if verbose {
eprintln!("[prompt] session {} active={} question={:?}",
session_id, prompt.active, prompt.question);
}
entries.push(PromptAllEntry {
session_id: session_id.clone(),
file: entry.file.clone(),
prompt,
});
}
println!("{}", serde_json::to_string(&entries)?);
Ok(())
}
pub fn answer(file: &Path, option_index: usize) -> Result<()> {
answer_with_tmux(file, option_index, &Tmux::default_server())
}
pub fn answer_with_tmux(file: &Path, option_index: usize, tmux: &Tmux) -> Result<()> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (_updated, session_id) = frontmatter::ensure_session(&content)?;
let pane = sessions::lookup(&session_id)?;
let pane_id = pane.context("no pane registered for this session")?;
if !tmux.pane_alive(&pane_id) {
anyhow::bail!("pane {} is not alive", pane_id);
}
let pane_content = sessions::capture_pane(tmux,&pane_id)?;
let info = parse_prompt(&pane_content);
if !info.active {
anyhow::bail!("no active prompt detected");
}
let options = info.options.as_ref().unwrap();
if option_index == 0 || option_index > options.len() {
anyhow::bail!(
"option {} out of range (1-{})",
option_index,
options.len()
);
}
let current = info.selected.unwrap_or(0);
let target = option_index - 1;
if target < current {
for _ in 0..(current - target) {
sessions::send_key(tmux,&pane_id, "Up")?;
std::thread::sleep(std::time::Duration::from_millis(30));
}
} else if target > current {
for _ in 0..(target - current) {
sessions::send_key(tmux,&pane_id, "Down")?;
std::thread::sleep(std::time::Duration::from_millis(30));
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
sessions::send_key(tmux,&pane_id, "Enter")?;
eprintln!(
"Sent option {} to pane {}",
option_index, pane_id
);
Ok(())
}
pub fn parse_prompt(content: &str) -> PromptInfo {
let lines: Vec<&str> = content.lines().collect();
let stripped: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
let footer_idx = stripped.iter().rposition(|line| {
line.contains("to cancel")
});
let footer_idx = match footer_idx {
Some(idx) => idx,
None => return inactive(),
};
let mut options = Vec::new();
let mut selected: Option<usize> = None;
let mut question_line_idx: Option<usize> = None;
for i in (0..footer_idx).rev() {
let line = &stripped[i];
let trimmed = line.trim();
if trimmed.is_empty() {
if !options.is_empty() {
continue;
}
continue;
}
if let Some(opt) = parse_option_line(trimmed) {
let is_selected = trimmed.starts_with('❯') || trimmed.starts_with('>');
if is_selected {
selected = Some(opt.index - 1); }
options.push(opt);
} else if !options.is_empty() {
question_line_idx = Some(i);
break;
}
}
if options.is_empty() {
return inactive();
}
options.reverse();
let question = question_line_idx.map(|idx| stripped[idx].trim().to_string());
PromptInfo {
active: true,
question,
options: Some(options),
selected,
}
}
fn parse_option_line(line: &str) -> Option<PromptOption> {
let stripped = line
.trim_start_matches('❯')
.trim_start_matches('>')
.trim();
if stripped.starts_with('[') {
let bracket_close = stripped.find(']')?;
let num_str = &stripped[1..bracket_close];
let index: usize = num_str.parse().ok()?;
let label = stripped[bracket_close + 1..].trim().to_string();
if label.is_empty() {
return None;
}
return Some(PromptOption { index, label });
}
let dot_pos = stripped.find('.')?;
let num_str = &stripped[..dot_pos];
let index: usize = num_str.parse().ok()?;
let label = stripped[dot_pos + 1..].trim().to_string();
if label.is_empty() {
return None;
}
Some(PromptOption { index, label })
}
pub(crate) fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
if let Some(next) = chars.next()
&& next == '[' {
for c2 in chars.by_ref() {
if c2.is_ascii_alphabetic() {
break;
}
}
}
} else {
result.push(c);
}
}
result
}
fn inactive() -> PromptInfo {
PromptInfo {
active: false,
question: None,
options: None,
selected: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_permission_prompt() {
let content = r#"
⎿ Running…
────────────────────────────────────────────────────────
Bash command
tmux capture-pane -t %73 -p
Capture pane content
Do you want to proceed?
[1] Yes
❯ [2] Yes, and don't ask again for: tmux capture-pane:*
[3] No
Esc to cancel · ctrl+e to explain
"#;
let info = parse_prompt(content);
assert!(info.active);
assert_eq!(info.question.as_deref(), Some("Do you want to proceed?"));
let opts = info.options.as_ref().unwrap();
assert_eq!(opts.len(), 3);
assert_eq!(opts[0].index, 1);
assert_eq!(opts[0].label, "Yes");
assert_eq!(opts[1].index, 2);
assert!(opts[1].label.starts_with("Yes, and don't ask again"));
assert_eq!(opts[2].index, 3);
assert_eq!(opts[2].label, "No");
assert_eq!(info.selected, Some(1)); }
#[test]
fn parse_no_prompt() {
let content = "Hello world\nSome regular output\n";
let info = parse_prompt(content);
assert!(!info.active);
}
#[test]
fn parse_yes_no_prompt() {
let content = r#"
Read tool
/home/brian/file.txt
Allow this action?
[1] Yes
[2] No
Esc to cancel
"#;
let info = parse_prompt(content);
assert!(info.active);
assert_eq!(info.question.as_deref(), Some("Allow this action?"));
let opts = info.options.as_ref().unwrap();
assert_eq!(opts.len(), 2);
}
#[test]
fn strip_ansi_basic() {
let s = "\x1b[1mBold\x1b[0m Normal";
assert_eq!(strip_ansi(s), "Bold Normal");
}
#[test]
fn strip_ansi_colors() {
let s = "\x1b[32mGreen\x1b[0m \x1b[31mRed\x1b[0m";
assert_eq!(strip_ansi(s), "Green Red");
}
#[test]
fn parse_option_line_basic() {
let opt = parse_option_line("[1] Yes").unwrap();
assert_eq!(opt.index, 1);
assert_eq!(opt.label, "Yes");
}
#[test]
fn parse_option_line_with_cursor() {
let opt = parse_option_line("❯ [2] Yes, and don't ask again").unwrap();
assert_eq!(opt.index, 2);
assert_eq!(opt.label, "Yes, and don't ask again");
}
#[test]
fn parse_option_line_no_match() {
assert!(parse_option_line("Not an option").is_none());
assert!(parse_option_line("").is_none());
}
#[test]
fn parse_option_line_numbered_format() {
let opt = parse_option_line("1. Yes").unwrap();
assert_eq!(opt.index, 1);
assert_eq!(opt.label, "Yes");
let opt = parse_option_line("2. Yes, allow reading from agent-loop/ from this project").unwrap();
assert_eq!(opt.index, 2);
assert_eq!(opt.label, "Yes, allow reading from agent-loop/ from this project");
let opt = parse_option_line("❯ 3. No").unwrap();
assert_eq!(opt.index, 3);
assert_eq!(opt.label, "No");
}
#[test]
fn parse_numbered_format_prompt() {
let content = r#"
────────────────────────────────────────────────────────
Bash command
agent-doc preflight tasks/software/agent-doc.md
Do you want to proceed?
1. Yes
2. Yes, allow reading from agent-loop/ from this project
3. No
Esc to cancel · Tab to amend · ctrl+e to explain
"#;
let info = parse_prompt(content);
assert!(info.active, "numbered format prompt should be detected");
assert_eq!(info.question.as_deref(), Some("Do you want to proceed?"));
let opts = info.options.as_ref().unwrap();
assert_eq!(opts.len(), 3);
assert_eq!(opts[0].index, 1);
assert_eq!(opts[0].label, "Yes");
assert_eq!(opts[2].index, 3);
assert_eq!(opts[2].label, "No");
}
#[test]
fn parse_new_format_no_to_cancel() {
let content = r#"
────────────────────────────────────────────────────────
Bash command
cp tmp/file.txt /tmp/dest.txt
Do you want to proceed?
[1] Yes
[2] Yes, and always allow Claude to edit for this project
[3] No
No to cancel · ctrl+e to explain
"#;
let info = parse_prompt(content);
assert!(info.active, "new 'No to cancel' format should be detected");
assert_eq!(info.question.as_deref(), Some("Do you want to proceed?"));
let opts = info.options.as_ref().unwrap();
assert_eq!(opts.len(), 3);
}
#[test]
fn prompt_all_entry_serializes_flat() {
let entry = PromptAllEntry {
session_id: "abc-123".to_string(),
file: "tasks/plan.md".to_string(),
prompt: PromptInfo {
active: true,
question: Some("Allow?".to_string()),
options: Some(vec![
PromptOption { index: 1, label: "Yes".to_string() },
PromptOption { index: 2, label: "No".to_string() },
]),
selected: Some(0),
},
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"session_id\":\"abc-123\""));
assert!(json.contains("\"file\":\"tasks/plan.md\""));
assert!(json.contains("\"active\":true"));
assert!(json.contains("\"question\":\"Allow?\""));
}
#[test]
fn prompt_all_entry_inactive_omits_optional_fields() {
let entry = PromptAllEntry {
session_id: "def-456".to_string(),
file: "tasks/resume.md".to_string(),
prompt: inactive(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"active\":false"));
assert!(!json.contains("\"question\""));
assert!(!json.contains("\"options\""));
}
}