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 valid_id(id: &str) -> bool {
99    id.len() <= 128 && id.split('/').count() <= 8 && id.split('/').all(valid_segment)
100}
101
102fn encode_text(text: &str, limit: usize) -> Result<String> {
103    ensure!(text.len() <= limit, "program status text exceeds byte limit");
104    ensure!(!text.chars().any(char::is_control), "program status text contains controls");
105    // Disarm bidi overrides/isolates and invisible directional marks.
106    let text: String = text
107        .chars()
108        .filter(
109            |c| !matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'),
110        )
111        .collect();
112    Ok(STANDARD.encode(text))
113}
114
115/// Minimum `VTE_VERSION` whose parser safely ignores unknown OSC sequences.
116///
117/// Older VTE-based terminals may mishandle unrecognized OSC bytes, so
118/// default-on emission skips them. Gate adapted from Codex
119/// (`codex-rs/tui/src/terminal_program_status.rs`, openai/codex#52725,
120/// Apache-2.0): absent or unparsable versions are treated as safe.
121pub const MIN_VTE_VERSION_WITH_SAFE_OSC: u32 = 5400;
122
123/// Pure gate: whether a `VTE_VERSION` value permits unknown-OSC emission.
124pub fn vte_version_allows_unknown_osc(raw: Option<&str>) -> bool {
125    let Some(raw) = raw else {
126        return true;
127    };
128    match raw.trim().parse::<u32>() {
129        Ok(version) => version >= MIN_VTE_VERSION_WITH_SAFE_OSC,
130        Err(_) => true,
131    }
132}
133
134/// Live gate combining [`vte_version_allows_unknown_osc`] with the process environment.
135pub fn vte_allows_unknown_osc() -> bool {
136    vte_version_allows_unknown_osc(std::env::var("VTE_VERSION").ok().as_deref())
137}
138
139/// Maximum `detail` characters in an iTerm2 session-status report.
140const MAX_ITERM21337_DETAIL_CHARS: usize = 200;
141
142/// iTerm2 session-status (OSC 21337) label and indicator for a terminal state.
143///
144/// Finished states read as idle, as does `Clear` (defensive: callers clear via
145/// the empty report instead); only the label/color pair travels. Mapping
146/// adapted from Codex (`codex-rs/tui/src/terminal_program_status.rs`,
147/// openai/codex#52725, Apache-2.0).
148pub fn iterm21337_status(state: ProgramState) -> (&'static str, &'static str) {
149    match state {
150        ProgramState::Idle | ProgramState::Done | ProgramState::Error | ProgramState::Clear => ("Idle", "#00d75f"),
151        ProgramState::Working => ("Working", "#ff9500"),
152        ProgramState::Blocked => ("Waiting", "#5f87ff"),
153    }
154}
155
156/// Encode an iTerm2 session-status report for a direct iTerm2 session.
157///
158/// `detail` is sanitized: controls are stripped, output is bounded, and `;`
159/// / `\` are backslash-escaped so the payload cannot reshape the sequence.
160pub fn encode_iterm21337(state: ProgramState, detail: &str) -> String {
161    let (label, indicator) = iterm21337_status(state);
162    format!(
163        "\x1b]21337;status={label};indicator={indicator};status-color=;detail={}\x07",
164        sanitize_iterm21337_detail(detail)
165    )
166}
167
168/// Encode the empty iTerm2 session-status report that clears a previous one.
169pub fn clear_iterm21337() -> String {
170    "\x1b]21337;status=;indicator=;status-color=;detail=\x07".to_string()
171}
172
173fn sanitize_iterm21337_detail(detail: &str) -> String {
174    let mut clean = String::with_capacity(detail.len());
175    for ch in detail.chars().filter(|ch| !ch.is_control()).take(MAX_ITERM21337_DETAIL_CHARS) {
176        if matches!(ch, ';' | '\\') {
177            clean.push('\\');
178        }
179        clean.push(ch);
180    }
181    clean
182}
183
184/// Pure gate: 21337 is iTerm2-proprietary, so only direct iTerm2 sessions
185/// (no multiplexer or GNU screen between the program and the emulator).
186pub fn should_emit_iterm21337(iterm_session: bool, tmux_session: bool, screen_session: bool) -> bool {
187    iterm_session && !tmux_session && !screen_session
188}
189
190/// Live gate combining [`should_emit_iterm21337`] with `ITERM_SESSION_ID` /
191/// `TMUX` / `STY` detection.
192pub fn should_emit_iterm21337_now() -> bool {
193    should_emit_iterm21337(
194        std::env::var("ITERM_SESSION_ID").is_ok(),
195        std::env::var("TMUX").is_ok(),
196        std::env::var("STY").is_ok(),
197    )
198}
199
200/// ConEmu `OSC 9;4` tab-progress state, also rendered natively by Windows
201/// Terminal and Ghostty 1.2+. Numeric mapping follows ConEmu and the
202/// [Windows Terminal docs](https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences).
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ConEmuProgress {
205    /// `0`: hide the progress indicator (also the completion state).
206    Remove,
207    /// `1`: determinate progress, clamped to `0..=100`.
208    Normal(u8),
209    /// `2`: error state.
210    Error,
211    /// `3`: indeterminate work (no percentage available).
212    Indeterminate,
213    /// `4`: paused/warning state (a wait, not a failure).
214    Paused,
215}
216
217/// Encode a ConEmu `OSC 9;4` tab-progress report (`BEL`-terminated, the
218/// canonical form from the Windows Terminal docs).
219pub fn encode_conemu94(progress: ConEmuProgress) -> String {
220    match progress {
221        ConEmuProgress::Remove => "\x1b]9;4;0\x07".to_string(),
222        ConEmuProgress::Normal(percent) => format!("\x1b]9;4;1;{}\x07", percent.min(100)),
223        ConEmuProgress::Error => "\x1b]9;4;2\x07".to_string(),
224        ConEmuProgress::Indeterminate => "\x1b]9;4;3\x07".to_string(),
225        ConEmuProgress::Paused => "\x1b]9;4;4\x07".to_string(),
226    }
227}
228
229/// Minimum Ghostty `(major, minor)` rendering `OSC 9;4` as tab progress.
230///
231/// Older Ghostty parses `OSC 9` as a notification, so unversioned or
232/// unparsable versions fail closed (no emission).
233pub const MIN_GHOSTTY_VERSION_WITH_CONEMU94: (u32, u32) = (1, 2);
234
235/// Pure gate: whether a `TERM_PROGRAM_VERSION` value permits `OSC 9;4`.
236/// Compares only the leading `major.minor` pair; anything else fails closed.
237pub fn ghostty_version_allows_conemu94(raw: Option<&str>) -> bool {
238    let Some(raw) = raw else {
239        return false;
240    };
241    let mut parts = raw.trim().split('.');
242    let (Some(major), Some(minor)) = (parts.next(), parts.next()) else {
243        return false;
244    };
245    match (major.parse::<u32>(), minor.parse::<u32>()) {
246        (Ok(major), Ok(minor)) => (major, minor) >= MIN_GHOSTTY_VERSION_WITH_CONEMU94,
247        _ => false,
248    }
249}
250
251/// Pure gate: `OSC 9;4` renders only on terminals documented to honor it.
252///
253/// Everywhere else `OSC 9` may mean notifications (iTerm2, older Ghostty),
254/// so this is default-deny: unknown terminals never receive the sequence.
255pub fn should_emit_conemu94(windows_terminal: bool, conemu: bool, ghostty_supported: bool) -> bool {
256    windows_terminal || conemu || ghostty_supported
257}
258
259/// Live gate combining [`should_emit_conemu94`] with process-environment
260/// detection: `WT_SESSION`/`WT_PROFILE_ID` (Windows Terminal), `ConEmuPID`
261/// (ConEmu), or exactly `TERM_PROGRAM=ghostty` with
262/// [`ghostty_version_allows_conemu94`].
263pub fn should_emit_conemu94_now() -> bool {
264    let windows_terminal = std::env::var("WT_SESSION").is_ok() || std::env::var("WT_PROFILE_ID").is_ok();
265    let conemu = std::env::var("ConEmuPID").is_ok();
266    let ghostty_supported = std::env::var("TERM_PROGRAM")
267        .map(|program| program.eq_ignore_ascii_case("ghostty"))
268        .unwrap_or(false)
269        && ghostty_version_allows_conemu94(std::env::var("TERM_PROGRAM_VERSION").ok().as_deref());
270    should_emit_conemu94(windows_terminal, conemu, ghostty_supported)
271}
272
273/// Encode a complete replacement record. An explicit owned ID is mandatory;
274/// callers cannot accidentally clear the terminal's root or unrelated records.
275///
276/// `progress` follows OSC 7501: only emitted with `working`/`blocked` when
277/// `Some(0..=100)`. Any other state, `None`, or out-of-range value omits the
278/// key (absent means indeterminate).
279pub fn encode_report(
280    id: &str,
281    state: ProgramState,
282    kind: Option<InteractionKind>,
283    progress: Option<u8>,
284    title: &str,
285    message: &str,
286) -> Result<String> {
287    ensure!(valid_id(id), "invalid program status id");
288    let title = encode_text(title, 192)?;
289    let message = encode_text(message, 2048)?;
290    let mut report = format!("\x1b]7501;state={}:id={id}:app=vtcode", state.as_str());
291    if state == ProgramState::Blocked
292        && let Some(kind) = kind
293    {
294        report.push_str(":kind=");
295        report.push_str(kind.as_str());
296    }
297    if matches!(state, ProgramState::Working | ProgramState::Blocked)
298        && let Some(percent) = progress
299        && percent <= 100
300    {
301        report.push_str(":progress=");
302        report.push_str(&percent.to_string());
303    }
304    report.push_str(":title=");
305    report.push_str(&title);
306    report.push_str(":msg=");
307    report.push_str(&message);
308    report.push_str("\x1b\\");
309    ensure!(report.len() <= 4096, "program status report exceeds byte limit");
310    Ok(report)
311}
312
313/// Encode a record removal. `clear` carries no content keys: the terminal
314/// drops the record and its subtree without reading anything else.
315pub fn encode_clear(id: &str) -> Result<String> {
316    ensure!(valid_id(id), "invalid program status id");
317    let report = format!("\x1b]7501;state=clear:id={id}:app=vtcode\x1b\\");
318    ensure!(report.len() <= 4096, "program status report exceeds byte limit");
319    Ok(report)
320}
321
322#[cfg(test)]
323mod tests;