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    Iterm2,
38    Kitty,
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::Kitty => send_kitty_notification(message),
88            TerminalNotifyKind::Iterm2 => send_iterm_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    let term = std::env::var("TERM")
116        .unwrap_or_default()
117        .to_ascii_lowercase();
118    let term_program = std::env::var("TERM_PROGRAM")
119        .unwrap_or_default()
120        .to_ascii_lowercase();
121
122    if term.contains("kitty") || std::env::var("KITTY_WINDOW_ID").is_ok() {
123        return TerminalNotifyKind::Kitty;
124    }
125
126    if term_program.contains("iterm") || std::env::var("ITERM_SESSION_ID").is_ok() {
127        return TerminalNotifyKind::Iterm2;
128    }
129
130    TerminalNotifyKind::BellOnly
131}
132
133fn send_kitty_notification(message: Option<&str>) {
134    let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
135    let title = "VT Code";
136    let payload = format!("{}\\;777;notify={};body={}", OSC, title, body);
137    print!("{}{}", payload, BEL);
138    let _ = std::io::stdout().flush();
139}
140
141fn send_iterm_notification(message: Option<&str>) {
142    let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
143    let payload = format!("{}\\;9;{}", OSC, body);
144    print!("{}{}", payload, BEL);
145    let _ = std::io::stdout().flush();
146}
147
148fn sanitize_notification_text(raw: &str) -> String {
149    const MAX_LEN: usize = 200;
150    let mut cleaned = raw
151        .chars()
152        .filter(|c| *c >= ' ' && *c != '\u{007f}')
153        .collect::<String>();
154    if cleaned.len() > MAX_LEN {
155        cleaned.truncate(MAX_LEN);
156    }
157    cleaned.replace(';', ":")
158}
159
160// === Reset ===
161pub const RESET: &str = "\x1b[0m";
162
163// === Text Styles ===
164pub const BOLD: &str = "\x1b[1m";
165pub const DIM: &str = "\x1b[2m";
166pub const ITALIC: &str = "\x1b[3m";
167pub const UNDERLINE: &str = "\x1b[4m";
168pub const BLINK: &str = "\x1b[5m";
169pub const REVERSE: &str = "\x1b[7m";
170pub const HIDDEN: &str = "\x1b[8m";
171pub const STRIKETHROUGH: &str = "\x1b[9m";
172
173pub const RESET_BOLD_DIM: &str = "\x1b[22m";
174pub const RESET_ITALIC: &str = "\x1b[23m";
175pub const RESET_UNDERLINE: &str = "\x1b[24m";
176pub const RESET_BLINK: &str = "\x1b[25m";
177pub const RESET_REVERSE: &str = "\x1b[27m";
178pub const RESET_HIDDEN: &str = "\x1b[28m";
179pub const RESET_STRIKETHROUGH: &str = "\x1b[29m";
180
181// === Foreground Colors (30-37) ===
182pub const FG_BLACK: &str = "\x1b[30m";
183pub const FG_RED: &str = "\x1b[31m";
184pub const FG_GREEN: &str = "\x1b[32m";
185pub const FG_YELLOW: &str = "\x1b[33m";
186pub const FG_BLUE: &str = "\x1b[34m";
187pub const FG_MAGENTA: &str = "\x1b[35m";
188pub const FG_CYAN: &str = "\x1b[36m";
189pub const FG_WHITE: &str = "\x1b[37m";
190pub const FG_DEFAULT: &str = "\x1b[39m";
191
192// === Background Colors (40-47) ===
193pub const BG_BLACK: &str = "\x1b[40m";
194pub const BG_RED: &str = "\x1b[41m";
195pub const BG_GREEN: &str = "\x1b[42m";
196pub const BG_YELLOW: &str = "\x1b[43m";
197pub const BG_BLUE: &str = "\x1b[44m";
198pub const BG_MAGENTA: &str = "\x1b[45m";
199pub const BG_CYAN: &str = "\x1b[46m";
200pub const BG_WHITE: &str = "\x1b[47m";
201pub const BG_DEFAULT: &str = "\x1b[49m";
202
203// === Bright Foreground Colors (90-97) ===
204pub const FG_BRIGHT_BLACK: &str = "\x1b[90m";
205pub const FG_BRIGHT_RED: &str = "\x1b[91m";
206pub const FG_BRIGHT_GREEN: &str = "\x1b[92m";
207pub const FG_BRIGHT_YELLOW: &str = "\x1b[93m";
208pub const FG_BRIGHT_BLUE: &str = "\x1b[94m";
209pub const FG_BRIGHT_MAGENTA: &str = "\x1b[95m";
210pub const FG_BRIGHT_CYAN: &str = "\x1b[96m";
211pub const FG_BRIGHT_WHITE: &str = "\x1b[97m";
212
213// === Bright Background Colors (100-107) ===
214pub const BG_BRIGHT_BLACK: &str = "\x1b[100m";
215pub const BG_BRIGHT_RED: &str = "\x1b[101m";
216pub const BG_BRIGHT_GREEN: &str = "\x1b[102m";
217pub const BG_BRIGHT_YELLOW: &str = "\x1b[103m";
218pub const BG_BRIGHT_BLUE: &str = "\x1b[104m";
219pub const BG_BRIGHT_MAGENTA: &str = "\x1b[105m";
220pub const BG_BRIGHT_CYAN: &str = "\x1b[106m";
221pub const BG_BRIGHT_WHITE: &str = "\x1b[107m";
222
223// === Cursor Control ===
224pub const CURSOR_HOME: &str = "\x1b[H";
225pub const CURSOR_HIDE: &str = "\x1b[?25l";
226pub const CURSOR_SHOW: &str = "\x1b[?25h";
227pub const CURSOR_SAVE_DEC: &str = "\x1b7";
228pub const CURSOR_RESTORE_DEC: &str = "\x1b8";
229pub const CURSOR_SAVE_SCO: &str = "\x1b[s";
230pub const CURSOR_RESTORE_SCO: &str = "\x1b[u";
231
232// === Erase Functions ===
233pub const CLEAR_SCREEN: &str = "\x1b[2J";
234pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
235pub const CLEAR_TO_START_OF_SCREEN: &str = "\x1b[1J";
236pub const CLEAR_SAVED_LINES: &str = "\x1b[3J";
237pub const CLEAR_LINE: &str = "\x1b[2K";
238pub const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
239pub const CLEAR_TO_START_OF_LINE: &str = "\x1b[1K";
240
241// === Screen Modes ===
242pub const ALT_BUFFER_ENABLE: &str = "\x1b[?1049h";
243pub const ALT_BUFFER_DISABLE: &str = "\x1b[?1049l";
244pub const SCREEN_SAVE: &str = "\x1b[?47h";
245pub const SCREEN_RESTORE: &str = "\x1b[?47l";
246pub const LINE_WRAP_ENABLE: &str = "\x1b[=7h";
247pub const LINE_WRAP_DISABLE: &str = "\x1b[=7l";
248
249// === Helper Functions ===
250
251#[inline]
252pub fn cursor_up(n: u16) -> String {
253    format!("\x1b[{}A", n)
254}
255
256#[inline]
257pub fn cursor_down(n: u16) -> String {
258    format!("\x1b[{}B", n)
259}
260
261#[inline]
262pub fn cursor_right(n: u16) -> String {
263    format!("\x1b[{}C", n)
264}
265
266#[inline]
267pub fn cursor_left(n: u16) -> String {
268    format!("\x1b[{}D", n)
269}
270
271#[inline]
272pub fn cursor_to(row: u16, col: u16) -> String {
273    format!("\x1b[{};{}H", row, col)
274}
275
276#[inline]
277pub fn fg_256(color_id: u8) -> String {
278    format!("\x1b[38;5;{}m", color_id)
279}
280
281#[inline]
282pub fn bg_256(color_id: u8) -> String {
283    format!("\x1b[48;5;{}m", color_id)
284}
285
286#[inline]
287pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
288    format!("\x1b[38;2;{};{};{}m", r, g, b)
289}
290
291#[inline]
292pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
293    format!("\x1b[48;2;{};{};{}m", r, g, b)
294}
295
296#[inline]
297pub fn colored(text: &str, color: &str) -> String {
298    format!("{}{}{}", color, text, RESET)
299}
300
301#[inline]
302pub fn bold(text: &str) -> String {
303    format!("{}{}{}", BOLD, text, RESET_BOLD_DIM)
304}
305
306#[inline]
307pub fn italic(text: &str) -> String {
308    format!("{}{}{}", ITALIC, text, RESET_ITALIC)
309}
310
311#[inline]
312pub fn underline(text: &str) -> String {
313    format!("{}{}{}", UNDERLINE, text, RESET_UNDERLINE)
314}
315
316#[inline]
317pub fn dim(text: &str) -> String {
318    format!("{}{}{}", DIM, text, RESET_BOLD_DIM)
319}
320
321#[inline]
322pub fn combine_styles(text: &str, styles: &[&str]) -> String {
323    let mut result = String::with_capacity(text.len() + styles.len() * 10);
324    for style in styles {
325        result.push_str(style);
326    }
327    result.push_str(text);
328    result.push_str(RESET);
329    result
330}
331
332pub mod semantic {
333    use super::*;
334    pub const ERROR: &str = FG_BRIGHT_RED;
335    pub const SUCCESS: &str = FG_BRIGHT_GREEN;
336    pub const WARNING: &str = FG_BRIGHT_YELLOW;
337    pub const INFO: &str = FG_BRIGHT_CYAN;
338    pub const MUTED: &str = DIM;
339    pub const EMPHASIS: &str = BOLD;
340    pub const DEBUG: &str = FG_BRIGHT_BLACK;
341}
342
343#[inline]
344pub fn contains_ansi(text: &str) -> bool {
345    text.contains(ESC)
346}
347
348#[inline]
349pub fn starts_with_ansi(text: &str) -> bool {
350    text.starts_with(ESC)
351}
352
353#[inline]
354pub fn ends_with_ansi(text: &str) -> bool {
355    text.ends_with('m') && text.contains(ESC)
356}
357
358#[inline]
359pub fn display_width(text: &str) -> usize {
360    crate::ansi::strip_ansi(text).len()
361}
362
363pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
364    let current_width = display_width(text);
365    if current_width >= width {
366        text.to_string()
367    } else {
368        let padding = pad_char.to_string().repeat(width - current_width);
369        format!("{}{}", text, padding)
370    }
371}
372
373pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
374    let stripped = crate::ansi::strip_ansi(text);
375    if stripped.len() <= max_width {
376        return text.to_string();
377    }
378
379    let truncate_at = max_width.saturating_sub(ellipsis.len());
380    let truncated_plain: String = stripped.chars().take(truncate_at).collect();
381
382    if starts_with_ansi(text) {
383        let mut ansi_prefix = String::new();
384        for ch in text.chars() {
385            ansi_prefix.push(ch);
386            if ch == '\x1b' {
387                continue;
388            }
389            if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
390                break;
391            }
392        }
393        format!("{}{}{}{}", ansi_prefix, truncated_plain, ellipsis, RESET)
394    } else {
395        format!("{}{}", truncated_plain, ellipsis)
396    }
397}
398
399#[inline]
400pub fn write_styled<W: std::io::Write>(
401    writer: &mut W,
402    text: &str,
403    style: &str,
404) -> std::io::Result<()> {
405    writer.write_all(style.as_bytes())?;
406    writer.write_all(text.as_bytes())?;
407    writer.write_all(RESET.as_bytes())?;
408    Ok(())
409}
410
411#[inline]
412pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
413    buffer.push_str(style);
414    buffer.push_str(text);
415    buffer.push_str(RESET);
416}