Skip to main content

vtcode_commons/
ansi_codes.rs

1//! ANSI escape sequence constants and utilities
2
3use once_cell::sync::Lazy;
4use ratatui::crossterm::tty::IsTty;
5use std::io::Write;
6
7/// Escape character (ESC = 0x1B = 27)
8pub const ESC: &str = "\x1b";
9
10/// Control Sequence Introducer (CSI = ESC[)
11pub const CSI: &str = "\x1b[";
12
13/// Operating System Command (OSC = ESC])
14pub const OSC: &str = "\x1b]";
15
16/// Device Control String (DCS = ESC P)
17pub const DCS: &str = "\x1bP";
18
19/// String Terminator (ST = ESC \)
20pub const ST: &str = "\x1b\\";
21
22/// Bell character (BEL = 0x07)
23pub const BEL: &str = "\x07";
24
25/// Notification preference (rich OSC vs bell-only)
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum HitlNotifyMode {
28    Off,
29    Bell,
30    Rich,
31}
32
33/// Terminal-specific notification capabilities
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TerminalNotifyKind {
36    BellOnly,
37    Osc9,
38    Osc777,
39}
40
41static DETECTED_NOTIFY_KIND: Lazy<TerminalNotifyKind> = Lazy::new(detect_terminal_notify_kind);
42
43/// Play the terminal bell when enabled.
44#[inline]
45pub fn play_bell(enabled: bool) {
46    if !is_bell_enabled(enabled) {
47        return;
48    }
49    emit_bell();
50}
51
52/// Determine whether the bell should play, honoring an env override.
53#[inline]
54pub fn is_bell_enabled(default_enabled: bool) -> bool {
55    if let Ok(val) = std::env::var("VTCODE_HITL_BELL") {
56        return !matches!(
57            val.trim().to_ascii_lowercase().as_str(),
58            "false" | "0" | "off"
59        );
60    }
61    default_enabled
62}
63
64#[inline]
65fn emit_bell() {
66    print!("{}", BEL);
67    let _ = std::io::stdout().flush();
68}
69
70#[inline]
71pub fn notify_attention(default_enabled: bool, message: Option<&str>) {
72    if !is_bell_enabled(default_enabled) {
73        return;
74    }
75
76    if !std::io::stdout().is_tty() {
77        return;
78    }
79
80    let mode = hitl_notify_mode(default_enabled);
81    if matches!(mode, HitlNotifyMode::Off) {
82        return;
83    }
84
85    if matches!(mode, HitlNotifyMode::Rich) {
86        match *DETECTED_NOTIFY_KIND {
87            TerminalNotifyKind::Osc9 => send_osc9_notification(message),
88            TerminalNotifyKind::Osc777 => send_osc777_notification(message),
89            TerminalNotifyKind::BellOnly => {} // No-op
90        }
91    }
92
93    emit_bell();
94}
95
96fn hitl_notify_mode(default_enabled: bool) -> HitlNotifyMode {
97    if let Ok(raw) = std::env::var("VTCODE_HITL_NOTIFY") {
98        let v = raw.trim().to_ascii_lowercase();
99        return match v.as_str() {
100            "off" | "0" | "false" => HitlNotifyMode::Off,
101            "bell" => HitlNotifyMode::Bell,
102            "rich" | "osc" | "notify" => HitlNotifyMode::Rich,
103            _ => HitlNotifyMode::Bell,
104        };
105    }
106
107    if default_enabled {
108        HitlNotifyMode::Rich
109    } else {
110        HitlNotifyMode::Off
111    }
112}
113
114fn detect_terminal_notify_kind() -> TerminalNotifyKind {
115    if let Ok(explicit_kind) = std::env::var("VTCODE_NOTIFY_KIND") {
116        let explicit = explicit_kind.trim().to_ascii_lowercase();
117        return match explicit.as_str() {
118            "osc9" => TerminalNotifyKind::Osc9,
119            "osc777" => TerminalNotifyKind::Osc777,
120            "bell" | "off" => TerminalNotifyKind::BellOnly,
121            _ => TerminalNotifyKind::BellOnly,
122        };
123    }
124
125    let term = std::env::var("TERM")
126        .unwrap_or_default()
127        .to_ascii_lowercase();
128    let term_program = std::env::var("TERM_PROGRAM")
129        .unwrap_or_default()
130        .to_ascii_lowercase();
131    let has_kitty = std::env::var("KITTY_WINDOW_ID").is_ok();
132    let has_iterm = std::env::var("ITERM_SESSION_ID").is_ok();
133    let has_wezterm = std::env::var("WEZTERM_PANE").is_ok();
134    let has_vte = std::env::var("VTE_VERSION").is_ok();
135
136    detect_terminal_notify_kind_from(
137        &term,
138        &term_program,
139        has_kitty,
140        has_iterm,
141        has_wezterm,
142        has_vte,
143    )
144}
145
146fn send_osc777_notification(message: Option<&str>) {
147    let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
148    let title = sanitize_notification_text("VT Code");
149    let payload = build_osc777_payload(&title, &body);
150    print!("{}{}", payload, BEL);
151    let _ = std::io::stdout().flush();
152}
153
154fn send_osc9_notification(message: Option<&str>) {
155    let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
156    let payload = build_osc9_payload(&body);
157    print!("{}{}", payload, BEL);
158    let _ = std::io::stdout().flush();
159}
160
161fn sanitize_notification_text(raw: &str) -> String {
162    const MAX_LEN: usize = 200;
163    let mut cleaned = raw
164        .chars()
165        .filter(|c| *c >= ' ' && *c != '\u{007f}')
166        .collect::<String>();
167    if cleaned.len() > MAX_LEN {
168        cleaned.truncate(MAX_LEN);
169    }
170    cleaned.replace(';', ":")
171}
172
173fn detect_terminal_notify_kind_from(
174    term: &str,
175    term_program: &str,
176    has_kitty: bool,
177    has_iterm: bool,
178    has_wezterm: bool,
179    has_vte: bool,
180) -> TerminalNotifyKind {
181    if term.contains("kitty") || has_kitty {
182        return TerminalNotifyKind::Osc777;
183    }
184
185    // Ghostty doesn't officially support OSC 9 or OSC 777 notifications
186    // Use bell-only to avoid "unknown error" messages
187    if term_program.contains("ghostty") {
188        return TerminalNotifyKind::BellOnly;
189    }
190
191    if term_program.contains("iterm")
192        || term_program.contains("wezterm")
193        || term_program.contains("warp")
194        || term_program.contains("apple_terminal")
195        || has_iterm
196        || has_wezterm
197    {
198        return TerminalNotifyKind::Osc9;
199    }
200
201    if has_vte {
202        return TerminalNotifyKind::Osc777;
203    }
204
205    TerminalNotifyKind::BellOnly
206}
207
208fn build_osc777_payload(title: &str, body: &str) -> String {
209    format!("{}777;notify;{};{}", OSC, title, body)
210}
211
212fn build_osc9_payload(body: &str) -> String {
213    format!("{}9;{}", OSC, body)
214}
215
216#[cfg(test)]
217mod redraw_tests {
218    use super::*;
219
220    #[test]
221    fn terminal_mapping_is_deterministic() {
222        assert_eq!(
223            detect_terminal_notify_kind_from("xterm-kitty", "", false, false, false, false),
224            TerminalNotifyKind::Osc777
225        );
226        // Ghostty doesn't support OSC 9/777, use bell-only to avoid "unknown error"
227        assert_eq!(
228            detect_terminal_notify_kind_from(
229                "xterm-ghostty",
230                "ghostty",
231                false,
232                false,
233                false,
234                false
235            ),
236            TerminalNotifyKind::BellOnly
237        );
238        assert_eq!(
239            detect_terminal_notify_kind_from(
240                "xterm-256color",
241                "wezterm",
242                false,
243                false,
244                false,
245                false
246            ),
247            TerminalNotifyKind::Osc9
248        );
249        assert_eq!(
250            detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, true),
251            TerminalNotifyKind::Osc777
252        );
253        assert_eq!(
254            detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, false),
255            TerminalNotifyKind::BellOnly
256        );
257    }
258
259    #[test]
260    fn osc_payload_format_is_stable() {
261        assert_eq!(build_osc9_payload("done"), format!("{}9;done", OSC));
262        assert_eq!(
263            build_osc777_payload("VT Code", "finished"),
264            format!("{}777;notify;VT Code;finished", OSC)
265        );
266    }
267}
268
269// === Reset ===
270pub const RESET: &str = "\x1b[0m";
271
272// === Text Styles ===
273pub const BOLD: &str = "\x1b[1m";
274pub const DIM: &str = "\x1b[2m";
275pub const ITALIC: &str = "\x1b[3m";
276pub const UNDERLINE: &str = "\x1b[4m";
277pub const BLINK: &str = "\x1b[5m";
278pub const REVERSE: &str = "\x1b[7m";
279pub const HIDDEN: &str = "\x1b[8m";
280pub const STRIKETHROUGH: &str = "\x1b[9m";
281
282pub const RESET_BOLD_DIM: &str = "\x1b[22m";
283pub const RESET_ITALIC: &str = "\x1b[23m";
284pub const RESET_UNDERLINE: &str = "\x1b[24m";
285pub const RESET_BLINK: &str = "\x1b[25m";
286pub const RESET_REVERSE: &str = "\x1b[27m";
287pub const RESET_HIDDEN: &str = "\x1b[28m";
288pub const RESET_STRIKETHROUGH: &str = "\x1b[29m";
289
290// === Foreground Colors (30-37) ===
291pub const FG_BLACK: &str = "\x1b[30m";
292pub const FG_RED: &str = "\x1b[31m";
293pub const FG_GREEN: &str = "\x1b[32m";
294pub const FG_YELLOW: &str = "\x1b[33m";
295pub const FG_BLUE: &str = "\x1b[34m";
296pub const FG_MAGENTA: &str = "\x1b[35m";
297pub const FG_CYAN: &str = "\x1b[36m";
298pub const FG_WHITE: &str = "\x1b[37m";
299pub const FG_DEFAULT: &str = "\x1b[39m";
300
301// === Background Colors (40-47) ===
302pub const BG_BLACK: &str = "\x1b[40m";
303pub const BG_RED: &str = "\x1b[41m";
304pub const BG_GREEN: &str = "\x1b[42m";
305pub const BG_YELLOW: &str = "\x1b[43m";
306pub const BG_BLUE: &str = "\x1b[44m";
307pub const BG_MAGENTA: &str = "\x1b[45m";
308pub const BG_CYAN: &str = "\x1b[46m";
309pub const BG_WHITE: &str = "\x1b[47m";
310pub const BG_DEFAULT: &str = "\x1b[49m";
311
312// === Bright Foreground Colors (90-97) ===
313pub const FG_BRIGHT_BLACK: &str = "\x1b[90m";
314pub const FG_BRIGHT_RED: &str = "\x1b[91m";
315pub const FG_BRIGHT_GREEN: &str = "\x1b[92m";
316pub const FG_BRIGHT_YELLOW: &str = "\x1b[93m";
317pub const FG_BRIGHT_BLUE: &str = "\x1b[94m";
318pub const FG_BRIGHT_MAGENTA: &str = "\x1b[95m";
319pub const FG_BRIGHT_CYAN: &str = "\x1b[96m";
320pub const FG_BRIGHT_WHITE: &str = "\x1b[97m";
321
322// === Bright Background Colors (100-107) ===
323pub const BG_BRIGHT_BLACK: &str = "\x1b[100m";
324pub const BG_BRIGHT_RED: &str = "\x1b[101m";
325pub const BG_BRIGHT_GREEN: &str = "\x1b[102m";
326pub const BG_BRIGHT_YELLOW: &str = "\x1b[103m";
327pub const BG_BRIGHT_BLUE: &str = "\x1b[104m";
328pub const BG_BRIGHT_MAGENTA: &str = "\x1b[105m";
329pub const BG_BRIGHT_CYAN: &str = "\x1b[106m";
330pub const BG_BRIGHT_WHITE: &str = "\x1b[107m";
331
332// === Cursor Control ===
333pub const CURSOR_HOME: &str = "\x1b[H";
334pub const CURSOR_HIDE: &str = "\x1b[?25l";
335pub const CURSOR_SHOW: &str = "\x1b[?25h";
336pub const CURSOR_SAVE_DEC: &str = "\x1b7";
337pub const CURSOR_RESTORE_DEC: &str = "\x1b8";
338pub const CURSOR_SAVE_SCO: &str = "\x1b[s";
339pub const CURSOR_RESTORE_SCO: &str = "\x1b[u";
340
341// === Erase Functions ===
342pub const CLEAR_SCREEN: &str = "\x1b[2J";
343pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
344pub const CLEAR_TO_START_OF_SCREEN: &str = "\x1b[1J";
345pub const CLEAR_SAVED_LINES: &str = "\x1b[3J";
346pub const CLEAR_LINE: &str = "\x1b[2K";
347pub const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
348pub const CLEAR_TO_START_OF_LINE: &str = "\x1b[1K";
349
350// === Screen Modes ===
351pub const ALT_BUFFER_ENABLE: &str = "\x1b[?1049h";
352pub const ALT_BUFFER_DISABLE: &str = "\x1b[?1049l";
353pub const SCREEN_SAVE: &str = "\x1b[?47h";
354pub const SCREEN_RESTORE: &str = "\x1b[?47l";
355pub const LINE_WRAP_ENABLE: &str = "\x1b[=7h";
356pub const LINE_WRAP_DISABLE: &str = "\x1b[=7l";
357
358// === Helper Functions ===
359
360#[inline]
361pub fn cursor_up(n: u16) -> String {
362    format!("\x1b[{}A", n)
363}
364
365#[inline]
366pub fn cursor_down(n: u16) -> String {
367    format!("\x1b[{}B", n)
368}
369
370#[inline]
371pub fn cursor_right(n: u16) -> String {
372    format!("\x1b[{}C", n)
373}
374
375#[inline]
376pub fn cursor_left(n: u16) -> String {
377    format!("\x1b[{}D", n)
378}
379
380#[inline]
381pub fn cursor_to(row: u16, col: u16) -> String {
382    format!("\x1b[{};{}H", row, col)
383}
384
385/// Build a portable in-place redraw prefix (`CR` + `EL2`).
386///
387/// This is the common CLI pattern for one-line progress updates.
388#[inline]
389pub fn redraw_line_prefix() -> &'static str {
390    "\r\x1b[2K"
391}
392
393/// Format a one-line in-place update payload.
394///
395/// Equivalent to: `\\r\\x1b[2K{content}`.
396#[inline]
397pub fn format_redraw_line(content: &str) -> String {
398    format!("{}{}", redraw_line_prefix(), content)
399}
400
401#[inline]
402pub fn fg_256(color_id: u8) -> String {
403    format!("\x1b[38;5;{}m", color_id)
404}
405
406#[inline]
407pub fn bg_256(color_id: u8) -> String {
408    format!("\x1b[48;5;{}m", color_id)
409}
410
411#[inline]
412pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
413    format!("\x1b[38;2;{};{};{}m", r, g, b)
414}
415
416#[inline]
417pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
418    format!("\x1b[48;2;{};{};{}m", r, g, b)
419}
420
421#[inline]
422pub fn colored(text: &str, color: &str) -> String {
423    format!("{}{}{}", color, text, RESET)
424}
425
426#[inline]
427pub fn bold(text: &str) -> String {
428    format!("{}{}{}", BOLD, text, RESET_BOLD_DIM)
429}
430
431#[inline]
432pub fn italic(text: &str) -> String {
433    format!("{}{}{}", ITALIC, text, RESET_ITALIC)
434}
435
436#[inline]
437pub fn underline(text: &str) -> String {
438    format!("{}{}{}", UNDERLINE, text, RESET_UNDERLINE)
439}
440
441#[inline]
442pub fn dim(text: &str) -> String {
443    format!("{}{}{}", DIM, text, RESET_BOLD_DIM)
444}
445
446#[inline]
447pub fn combine_styles(text: &str, styles: &[&str]) -> String {
448    let mut result = String::with_capacity(text.len() + styles.len() * 10);
449    for style in styles {
450        result.push_str(style);
451    }
452    result.push_str(text);
453    result.push_str(RESET);
454    result
455}
456
457pub mod semantic {
458    use super::*;
459    pub const ERROR: &str = FG_BRIGHT_RED;
460    pub const SUCCESS: &str = FG_BRIGHT_GREEN;
461    pub const WARNING: &str = FG_BRIGHT_YELLOW;
462    pub const INFO: &str = FG_BRIGHT_CYAN;
463    pub const MUTED: &str = DIM;
464    pub const EMPHASIS: &str = BOLD;
465    pub const DEBUG: &str = FG_BRIGHT_BLACK;
466}
467
468#[inline]
469pub fn contains_ansi(text: &str) -> bool {
470    text.contains(ESC)
471}
472
473#[inline]
474pub fn starts_with_ansi(text: &str) -> bool {
475    text.starts_with(ESC)
476}
477
478#[inline]
479pub fn ends_with_ansi(text: &str) -> bool {
480    text.ends_with('m') && text.contains(ESC)
481}
482
483#[inline]
484pub fn display_width(text: &str) -> usize {
485    crate::ansi::strip_ansi(text).len()
486}
487
488pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
489    let current_width = display_width(text);
490    if current_width >= width {
491        text.to_string()
492    } else {
493        let padding = pad_char.to_string().repeat(width - current_width);
494        format!("{}{}", text, padding)
495    }
496}
497
498pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
499    let stripped = crate::ansi::strip_ansi(text);
500    if stripped.len() <= max_width {
501        return text.to_string();
502    }
503
504    let truncate_at = max_width.saturating_sub(ellipsis.len());
505    let truncated_plain: String = stripped.chars().take(truncate_at).collect();
506
507    if starts_with_ansi(text) {
508        let mut ansi_prefix = String::new();
509        for ch in text.chars() {
510            ansi_prefix.push(ch);
511            if ch == '\x1b' {
512                continue;
513            }
514            if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
515                break;
516            }
517        }
518        format!("{}{}{}{}", ansi_prefix, truncated_plain, ellipsis, RESET)
519    } else {
520        format!("{}{}", truncated_plain, ellipsis)
521    }
522}
523
524#[inline]
525pub fn write_styled<W: Write>(writer: &mut W, text: &str, style: &str) -> std::io::Result<()> {
526    writer.write_all(style.as_bytes())?;
527    writer.write_all(text.as_bytes())?;
528    writer.write_all(RESET.as_bytes())?;
529    Ok(())
530}
531
532#[inline]
533pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
534    buffer.push_str(style);
535    buffer.push_str(text);
536    buffer.push_str(RESET);
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn redraw_prefix_matches_cli_pattern() {
545        assert_eq!(redraw_line_prefix(), "\r\x1b[2K");
546    }
547
548    #[test]
549    fn redraw_line_formats_expected_sequence() {
550        assert_eq!(format_redraw_line("Done"), "\r\x1b[2KDone");
551    }
552}