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 {
56        enabled: bool,
57    },
58    Wait {
59        token: u64,
60        kind: InteractionKind,
61    },
62    Resume {
63        token: u64,
64    },
65    Outcome(ProgramState),
66    /// Determinate task progress (0-100). `None` means indeterminate and
67    /// omits `progress`, per OSC 7501. Only emitted with `working`/`blocked`.
68    Progress {
69        percent: Option<u8>,
70    },
71}
72
73/// Generate an opaque ASCII segment without copying source identity onto the wire.
74pub fn record_segment(kind: &str, source: &str) -> String {
75    use sha2::{Digest, Sha256};
76    let mut hash = Sha256::new();
77    hash.update(kind.as_bytes());
78    hash.update([0]);
79    hash.update(source.as_bytes());
80    let digest = hash.finalize();
81    let mut segment = String::with_capacity(31);
82    segment.push('t');
83    for byte in digest.iter().take(15) {
84        for nibble in [byte >> 4, byte & 0x0f] {
85            segment.push(char::from(if nibble < 10 { b'0' + nibble } else { b'a' + nibble - 10 }));
86        }
87    }
88    segment
89}
90
91fn valid_segment(segment: &str) -> bool {
92    (1..=32).contains(&segment.len())
93        && segment
94            .bytes()
95            .all(|byte| byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte))
96}
97
98fn encode_text(text: &str, limit: usize) -> Result<String> {
99    ensure!(text.len() <= limit, "program status text exceeds byte limit");
100    ensure!(!text.chars().any(char::is_control), "program status text contains controls");
101    // Disarm bidi overrides/isolates and invisible directional marks.
102    let text: String = text
103        .chars()
104        .filter(
105            |c| !matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'),
106        )
107        .collect();
108    Ok(STANDARD.encode(text))
109}
110
111/// Encode a complete replacement record. An explicit owned ID is mandatory;
112/// callers cannot accidentally clear the terminal's root or unrelated records.
113///
114/// `progress` follows OSC 7501: only emitted with `working`/`blocked` when
115/// `Some(0..=100)`. Any other state, `None`, or out-of-range value omits the
116/// key (absent means indeterminate).
117pub fn encode_report(
118    id: &str,
119    state: ProgramState,
120    kind: Option<InteractionKind>,
121    progress: Option<u8>,
122    title: &str,
123    message: &str,
124) -> Result<String> {
125    ensure!(
126        id.len() <= 128 && id.split('/').count() <= 8 && id.split('/').all(valid_segment),
127        "invalid program status id"
128    );
129    let title = encode_text(title, 192)?;
130    let message = encode_text(message, 2048)?;
131    let mut report = format!("\x1b]7501;state={}:id={id}:app=vtcode", state.as_str());
132    if state == ProgramState::Blocked
133        && let Some(kind) = kind
134    {
135        report.push_str(":kind=");
136        report.push_str(kind.as_str());
137    }
138    if matches!(state, ProgramState::Working | ProgramState::Blocked)
139        && let Some(percent) = progress
140        && percent <= 100
141    {
142        report.push_str(":progress=");
143        report.push_str(&percent.to_string());
144    }
145    report.push_str(":title=");
146    report.push_str(&title);
147    report.push_str(":msg=");
148    report.push_str(&message);
149    report.push_str("\x1b\\");
150    ensure!(report.len() <= 4096, "program status report exceeds byte limit");
151    Ok(report)
152}
153
154#[cfg(test)]
155mod tests;