Skip to main content

vtcode_commons/
ansi_codes.rs

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