use crate::utils::terminal_colors;
use base64::Engine as _;
use serde_json::Value;
use std::time::Duration;
use strum_macros::IntoStaticStr;
pub(crate) const PROGRAM_STATUS_QUERY: &str = "\x1b]7501;?\x1b\\";
const QUERY_TIMEOUT_MS: u64 = 100;
const ENV_OVERRIDE: &str = "PRUX_PROGRAM_STATUS";
const MAX_APP_LEN: usize = 32;
const MAX_MESSAGE_BYTES: usize = 2048;
pub const PROGRAM_STATUS_CLEAR: &str = "\x1b]7501;state=clear\x1b\\";
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
pub enum ProgramState {
Idle,
Working,
Blocked,
Done,
Error,
}
impl ProgramState {
fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
pub enum BlockedKind {
Permission,
Question,
Auth,
}
impl BlockedKind {
fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramStatus {
pub state: ProgramState,
pub app: Option<String>,
pub kind: Option<BlockedKind>,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockedStatus {
pub kind: BlockedKind,
pub message: String,
}
impl BlockedStatus {
pub fn new(kind: BlockedKind, message: impl Into<String>) -> Self {
BlockedStatus {
kind,
message: message.into(),
}
}
}
fn is_valid_app(app: &str) -> bool {
!app.is_empty()
&& app.len() <= MAX_APP_LEN
&& app
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '+' | '-'))
}
fn replace_control_characters(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_run = false;
for ch in text.chars() {
if ch.is_control() {
if !in_run {
out.push(' ');
}
in_run = true;
} else {
out.push(ch);
in_run = false;
}
}
out
}
fn truncate_utf8(text: &str, max_bytes: usize) -> &str {
if text.len() <= max_bytes {
return text;
}
let mut end = 0;
for (idx, ch) in text.char_indices() {
if idx + ch.len_utf8() > max_bytes {
break;
}
end = idx + ch.len_utf8();
}
&text[..end]
}
pub fn format_program_status(status: &ProgramStatus) -> String {
let mut pairs = vec![format!("state={}", status.state.as_str())];
if let Some(app) = status.app.as_deref()
&& is_valid_app(app)
{
pairs.push(format!("app={app}"));
}
if status.state == ProgramState::Blocked
&& let Some(kind) = status.kind
{
pairs.push(format!("kind={}", kind.as_str()));
}
let sanitized = replace_control_characters(status.message.as_deref().unwrap_or(""));
let message = truncate_utf8(sanitized.trim(), MAX_MESSAGE_BYTES);
if !message.is_empty() {
pairs.push(format!(
"msg={}",
base64::engine::general_purpose::STANDARD.encode(message)
));
}
format!("\x1b]7501;{}\x1b\\", pairs.join(":"))
}
pub fn is_program_status_reply_body(body: &str) -> bool {
body.starts_with("7501;?")
}
pub fn detect_program_status_support() -> bool {
match std::env::var(ENV_OVERRIDE).ok().as_deref() {
Some("1") => return true,
Some("0") => return false,
_ => {}
}
let query = format!("{PROGRAM_STATUS_QUERY}\x1b[c");
let bytes =
terminal_colors::query_terminal_replies(&query, Duration::from_millis(QUERY_TIMEOUT_MS));
let mut buf = bytes;
while let Some(reply) = terminal_colors::take_reply(&mut buf) {
if let terminal_colors::Reply::Osc(body) = reply
&& is_program_status_reply_body(&body)
{
return true;
}
}
false
}
pub struct ProgramStatusReporter {
supported: bool,
run_active: bool,
compacting: bool,
run_result: ProgramStatus,
resting_status: ProgramStatus,
blocked: Option<BlockedStatus>,
session_name: Option<String>,
last_report: Option<String>,
pending: Option<String>,
}
impl ProgramStatusReporter {
pub fn new(supported: bool) -> Self {
ProgramStatusReporter {
supported,
run_active: false,
compacting: false,
run_result: ProgramStatus {
state: ProgramState::Done,
app: None,
kind: None,
message: None,
},
resting_status: ProgramStatus {
state: ProgramState::Idle,
app: None,
kind: None,
message: None,
},
blocked: None,
session_name: None,
last_report: None,
pending: None,
}
}
pub fn supported(&self) -> bool {
self.supported
}
pub fn set_session_name(&mut self, name: Option<String>) {
self.session_name = name.filter(|n| !n.is_empty());
}
pub fn set_blocked(&mut self, status: Option<BlockedStatus>) {
self.blocked = status;
}
pub fn handle_event(&mut self, event: &Value) {
match event.get("type").and_then(|v| v.as_str()).unwrap_or("") {
"agent_start" => {
self.run_active = true;
self.run_result = plain_status(ProgramState::Done);
}
"message_end" => {
let Some(message) = event.get("message") else {
return;
};
if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
return;
}
self.run_result =
if message.get("stopReason").and_then(|v| v.as_str()) == Some("error") {
ProgramStatus {
state: ProgramState::Error,
app: None,
kind: None,
message: Some(first_line(
message.get("errorMessage").and_then(|v| v.as_str()),
)),
}
} else {
plain_status(ProgramState::Done)
};
}
"compaction_start" => self.compacting = true,
"compaction_end" => {
self.compacting = false;
let aborted = event
.get("aborted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let error = event.get("errorMessage").and_then(|v| v.as_str());
let manual = event.get("reason").and_then(|v| v.as_str()) == Some("manual");
if self.run_active {
if aborted {
self.run_result = plain_status(ProgramState::Idle);
} else if let Some(error) = error {
self.run_result = error_status(error);
}
} else if aborted {
self.resting_status = plain_status(ProgramState::Idle);
} else if manual {
self.resting_status = match error {
Some(error) => error_status(error),
None => plain_status(ProgramState::Done),
};
}
}
"agent_settled" => {
self.run_active = false;
let aborted = event
.get("aborted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
self.resting_status = if aborted {
plain_status(ProgramState::Idle)
} else {
self.run_result.clone()
};
}
"session_info_changed" => {
self.set_session_name(
event
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string),
);
}
_ => return,
}
self.report();
}
pub fn reset(&mut self) {
self.run_active = false;
self.compacting = false;
self.run_result = plain_status(ProgramState::Done);
self.resting_status = plain_status(ProgramState::Idle);
self.blocked = None;
self.report();
}
pub fn report(&mut self) {
let status = self.current_status();
let key = format!("{status:?}");
if self.last_report.as_deref() == Some(key.as_str()) {
return;
}
self.last_report = Some(key);
if self.supported {
self.pending = Some(format_program_status(&status));
}
}
pub fn take_pending(&mut self) -> Option<String> {
self.pending.take()
}
pub fn exit_sequence(&self) -> Option<&'static str> {
(self.supported && self.last_report.is_some()).then_some(PROGRAM_STATUS_CLEAR)
}
fn current_status(&self) -> ProgramStatus {
if let Some(blocked) = &self.blocked {
return ProgramStatus {
state: ProgramState::Blocked,
app: None,
kind: Some(blocked.kind),
message: Some(blocked.message.clone()),
};
}
if self.compacting {
return ProgramStatus {
state: ProgramState::Working,
app: None,
kind: None,
message: Some("Compacting context".to_string()),
};
}
let status = if self.run_active {
plain_status(ProgramState::Working)
} else {
self.resting_status.clone()
};
if matches!(status.state, ProgramState::Working | ProgramState::Done) {
return ProgramStatus {
message: self.session_name.clone(),
..status
};
}
status
}
}
fn plain_status(state: ProgramState) -> ProgramStatus {
ProgramStatus {
state,
app: None,
kind: None,
message: None,
}
}
fn error_status(message: &str) -> ProgramStatus {
ProgramStatus {
state: ProgramState::Error,
app: None,
kind: None,
message: Some(first_line(Some(message))),
}
}
fn first_line(text: Option<&str>) -> String {
let line = text.unwrap_or("").lines().next().unwrap_or("").trim();
if line.is_empty() {
"Error".to_string()
} else {
line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn decoded_message(sequence: &str) -> Option<String> {
let body = sequence
.strip_prefix("\x1b]7501;")?
.strip_suffix("\x1b\\")?;
for pair in body.split(':') {
if let Some(encoded) = pair.strip_prefix("msg=") {
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
return String::from_utf8(bytes).ok();
}
}
None
}
#[test]
fn formats_state_and_message_fields() {
let status = ProgramStatus {
state: ProgramState::Working,
app: Some("prux".to_string()),
kind: None,
message: Some("my session".to_string()),
};
let wire = format_program_status(&status);
assert!(wire.starts_with("\x1b]7501;state=working:app=prux:msg="));
assert!(wire.ends_with("\x1b\\"));
assert_eq!(decoded_message(&wire).as_deref(), Some("my session"));
}
#[test]
fn blocked_carries_kind_and_omits_it_elsewhere() {
let blocked = ProgramStatus {
state: ProgramState::Blocked,
app: Some("prux".to_string()),
kind: Some(BlockedKind::Auth),
message: Some("Log in to Anthropic".to_string()),
};
assert!(format_program_status(&blocked).contains("state=blocked:app=prux:kind=auth:msg="));
let done = ProgramStatus {
state: ProgramState::Done,
app: None,
kind: Some(BlockedKind::Auth),
message: None,
};
assert_eq!(format_program_status(&done), "\x1b]7501;state=done\x1b\\");
}
#[test]
fn invalid_app_names_are_omitted() {
for app in ["", "has space", "way-too-long-name-for-the-protocol-field"] {
let status = ProgramStatus {
state: ProgramState::Idle,
app: Some(app.to_string()),
kind: None,
message: None,
};
assert_eq!(
format_program_status(&status),
"\x1b]7501;state=idle\x1b\\",
"app={app:?} should be dropped"
);
}
let ok = ProgramStatus {
state: ProgramState::Idle,
app: Some("prux-cli.dev+1".to_string()),
kind: None,
message: None,
};
assert!(format_program_status(&ok).contains(":app=prux-cli.dev+1"));
}
#[test]
fn control_characters_become_one_space_and_text_is_trimmed() {
let status = ProgramStatus {
state: ProgramState::Error,
app: None,
kind: None,
message: Some(" bad\u{7}\u{1b}[0m\nthing ".to_string()),
};
assert_eq!(
decoded_message(&format_program_status(&status)).as_deref(),
Some("bad [0m thing")
);
}
#[test]
fn long_messages_are_truncated_on_a_character_boundary() {
let status = ProgramStatus {
state: ProgramState::Error,
app: None,
kind: None,
message: Some("汉".repeat(1000)),
};
let decoded = decoded_message(&format_program_status(&status)).unwrap();
assert!(decoded.len() <= MAX_MESSAGE_BYTES);
assert_eq!(decoded.chars().count(), 682);
}
#[test]
fn reply_detection_accepts_spec_revisions() {
assert!(is_program_status_reply_body("7501;?"));
assert!(is_program_status_reply_body("7501;?version=2"));
assert!(!is_program_status_reply_body("7501;state=idle"));
assert!(!is_program_status_reply_body("10;#ffffff"));
}
#[test]
fn reporter_tracks_run_outcome_and_dedups() {
let mut reporter = ProgramStatusReporter::new(true);
reporter.set_session_name(Some("demo".to_string()));
reporter.handle_event(&json!({ "type": "agent_start" }));
let write = reporter.take_pending().unwrap();
assert_eq!(decoded_message(&write).as_deref(), Some("demo"));
assert!(write.contains("state=working"));
reporter.report();
assert!(reporter.take_pending().is_none());
reporter.handle_event(&json!({
"type": "message_end",
"message": { "role": "assistant", "stopReason": "error", "errorMessage": "boom\nsecond" }
}));
reporter.handle_event(&json!({ "type": "agent_settled", "aborted": false }));
let write = reporter.take_pending().unwrap();
assert!(write.contains("state=error"));
assert_eq!(decoded_message(&write).as_deref(), Some("boom"));
}
#[test]
fn aborted_run_rests_at_idle() {
let mut reporter = ProgramStatusReporter::new(true);
reporter.handle_event(&json!({ "type": "agent_start" }));
_ = reporter.take_pending();
reporter.handle_event(&json!({ "type": "agent_settled", "aborted": true }));
let write = reporter.take_pending().unwrap();
assert!(write.contains("state=idle"), "{write}");
}
#[test]
fn blocked_dialog_wins_over_working() {
let mut reporter = ProgramStatusReporter::new(true);
reporter.handle_event(&json!({ "type": "agent_start" }));
_ = reporter.take_pending();
reporter.set_blocked(Some(BlockedStatus::new(BlockedKind::Question, "Pick one")));
reporter.report();
let write = reporter.take_pending().unwrap();
assert!(write.contains("state=blocked:kind=question"), "{write}");
assert_eq!(decoded_message(&write).as_deref(), Some("Pick one"));
reporter.set_blocked(None);
reporter.report();
let write = reporter.take_pending().unwrap();
assert!(write.contains("state=working"), "{write}");
}
#[test]
fn exit_sequence_only_after_a_report() {
let mut reporter = ProgramStatusReporter::new(true);
assert!(reporter.exit_sequence().is_none());
reporter.handle_event(&json!({ "type": "agent_start" }));
assert_eq!(reporter.exit_sequence(), Some(PROGRAM_STATUS_CLEAR));
let mut unsupported = ProgramStatusReporter::new(false);
unsupported.handle_event(&json!({ "type": "agent_start" }));
assert!(unsupported.exit_sequence().is_none());
}
#[test]
fn unsupported_terminal_never_writes() {
let mut reporter = ProgramStatusReporter::new(false);
reporter.handle_event(&json!({ "type": "agent_start" }));
assert!(reporter.take_pending().is_none());
assert!(!reporter.supported());
}
#[test]
fn compaction_reports_working_and_manual_result() {
let mut reporter = ProgramStatusReporter::new(true);
reporter.set_session_name(Some("demo".to_string()));
reporter.handle_event(&json!({ "type": "compaction_start" }));
let write = reporter.take_pending().unwrap();
assert!(write.contains("state=working"));
assert_eq!(
decoded_message(&write).as_deref(),
Some("Compacting context")
);
reporter.handle_event(&json!({
"type": "compaction_end",
"reason": "manual",
"errorMessage": null
}));
let write = reporter.take_pending().unwrap();
assert!(write.contains("state=done"), "{write}");
assert_eq!(decoded_message(&write).as_deref(), Some("demo"));
}
}