Skip to main content

vtcode_commons/
program_status.rs

1//! Bounded Program Status Protocol (OSC 7501) encoding. No terminal I/O.
2
3use anyhow::{Result, ensure};
4use base64::{Engine, engine::general_purpose::STANDARD};
5
6/// Terminal record states, independent of execution authority.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ProgramState {
9    Idle,
10    Working,
11    Blocked,
12    Done,
13    Error,
14    Clear,
15}
16
17impl ProgramState {
18    pub const fn as_str(self) -> &'static str {
19        match self {
20            Self::Idle => "idle",
21            Self::Working => "working",
22            Self::Blocked => "blocked",
23            Self::Done => "done",
24            Self::Error => "error",
25            Self::Clear => "clear",
26        }
27    }
28
29    pub const fn is_finished(self) -> bool {
30        matches!(self, Self::Done | Self::Error)
31    }
32}
33
34/// The actual user intervention required by an interaction.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum InteractionKind {
37    Permission,
38    Question,
39    Auth,
40}
41
42impl InteractionKind {
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::Permission => "permission",
46            Self::Question => "question",
47            Self::Auth => "auth",
48        }
49    }
50}
51
52/// Projection commands carry no input or execution authority.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ProgramStatusUpdate {
55    Configure { enabled: bool },
56    Wait { token: u64, kind: InteractionKind },
57    Resume { token: u64 },
58    Outcome(ProgramState),
59}
60
61/// Generate an opaque ASCII segment without copying source identity onto the wire.
62pub fn record_segment(kind: &str, source: &str) -> String {
63    use sha2::{Digest, Sha256};
64    let mut hash = Sha256::new();
65    hash.update(kind.as_bytes());
66    hash.update([0]);
67    hash.update(source.as_bytes());
68    let digest = hash.finalize();
69    let mut segment = String::with_capacity(31);
70    segment.push('t');
71    for byte in digest.iter().take(15) {
72        for nibble in [byte >> 4, byte & 0x0f] {
73            segment.push(char::from(if nibble < 10 { b'0' + nibble } else { b'a' + nibble - 10 }));
74        }
75    }
76    segment
77}
78
79fn valid_segment(segment: &str) -> bool {
80    (1..=32).contains(&segment.len())
81        && segment
82            .bytes()
83            .all(|byte| byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte))
84}
85
86fn encode_text(text: &str, limit: usize) -> Result<String> {
87    ensure!(text.len() <= limit, "program status text exceeds byte limit");
88    ensure!(!text.chars().any(char::is_control), "program status text contains controls");
89    // Disarm bidi overrides/isolates and invisible directional marks.
90    let text: String = text
91        .chars()
92        .filter(
93            |c| !matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'),
94        )
95        .collect();
96    Ok(STANDARD.encode(text))
97}
98
99/// Encode a complete replacement record. An explicit owned ID is mandatory;
100/// callers cannot accidentally clear the terminal's root or unrelated records.
101pub fn encode_report(
102    id: &str,
103    state: ProgramState,
104    kind: Option<InteractionKind>,
105    title: &str,
106    message: &str,
107) -> Result<String> {
108    ensure!(
109        id.len() <= 128 && id.split('/').count() <= 8 && id.split('/').all(valid_segment),
110        "invalid program status id"
111    );
112    let title = encode_text(title, 192)?;
113    let message = encode_text(message, 2048)?;
114    let mut report = format!("\x1b]7501;state={}:id={id}:app=vtcode", state.as_str());
115    if state == ProgramState::Blocked
116        && let Some(kind) = kind
117    {
118        report.push_str(":kind=");
119        report.push_str(kind.as_str());
120    }
121    report.push_str(":title=");
122    report.push_str(&title);
123    report.push_str(":msg=");
124    report.push_str(&message);
125    report.push_str("\x1b\\");
126    ensure!(report.len() <= 4096, "program status report exceeds byte limit");
127    Ok(report)
128}
129
130#[cfg(test)]
131mod tests;