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