1use anyhow::{Result, ensure};
4use base64::{Engine, engine::general_purpose::STANDARD};
5
6#[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#[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#[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 Progress {
69 percent: Option<u8>,
70 },
71}
72
73pub 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 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
115pub const MIN_VTE_VERSION_WITH_SAFE_OSC: u32 = 5400;
122
123pub 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
134pub fn vte_allows_unknown_osc() -> bool {
136 vte_version_allows_unknown_osc(std::env::var("VTE_VERSION").ok().as_deref())
137}
138
139const MAX_ITERM21337_DETAIL_CHARS: usize = 200;
141
142pub 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
156pub 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
168pub 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
184pub fn should_emit_iterm21337(iterm_session: bool, tmux_session: bool, screen_session: bool) -> bool {
187 iterm_session && !tmux_session && !screen_session
188}
189
190pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ConEmuProgress {
205 Remove,
207 Normal(u8),
209 Error,
211 Indeterminate,
213 Paused,
215}
216
217pub 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
229pub const MIN_GHOSTTY_VERSION_WITH_CONEMU94: (u32, u32) = (1, 2);
234
235pub 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
251pub fn should_emit_conemu94(windows_terminal: bool, conemu: bool, ghostty_supported: bool) -> bool {
256 windows_terminal || conemu || ghostty_supported
257}
258
259pub 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
273pub 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
313pub 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;