1use once_cell::sync::Lazy;
6use std::io::{IsTerminal, Write};
7
8pub const ESC_BYTE: u8 = 0x1b;
10
11pub const ESC_CHAR: char = '\x1b';
13
14pub const ESC: &str = "\x1b";
16
17pub const CSI: &str = "\x1b[";
19
20pub const OSC: &str = "\x1b]";
22
23pub const DCS: &str = "\x1bP";
25
26pub const ST: &str = "\x1b\\";
28
29pub const BEL_BYTE: u8 = 0x07;
31
32pub const BEL_CHAR: char = '\x07';
34
35pub const BEL: &str = "\x07";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum HitlNotifyMode {
41 Off,
42 Bell,
43 Rich,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum TerminalNotifyKind {
49 BellOnly,
50 Osc9,
51 Osc777,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum NotifyMethodOverride {
57 Auto,
58 Bell,
59 Osc9,
60}
61
62static DETECTED_NOTIFY_KIND: Lazy<TerminalNotifyKind> = Lazy::new(detect_terminal_notify_kind);
63
64#[inline]
66pub fn play_bell(enabled: bool) {
67 if !is_bell_enabled(enabled) {
68 return;
69 }
70 emit_bell();
71}
72
73#[inline]
75pub fn is_bell_enabled(default_enabled: bool) -> bool {
76 if let Ok(val) = std::env::var("VTCODE_HITL_BELL") {
77 return !matches!(val.trim().to_ascii_lowercase().as_str(), "false" | "0" | "off");
78 }
79 default_enabled
80}
81
82#[inline]
83fn emit_bell() {
84 print!("{BEL}");
85 let _ = std::io::stdout().flush();
86}
87
88#[inline]
89pub fn notify_attention(default_enabled: bool, message: Option<&str>) {
90 notify_attention_with_mode(default_enabled, message, NotifyMethodOverride::Auto);
91}
92
93#[inline]
94pub fn notify_attention_with_mode(default_enabled: bool, message: Option<&str>, method: NotifyMethodOverride) {
95 if !is_bell_enabled(default_enabled) {
96 return;
97 }
98
99 if !std::io::stdout().is_terminal() {
100 return;
101 }
102
103 let mode = hitl_notify_mode(default_enabled);
104 if matches!(mode, HitlNotifyMode::Off) {
105 return;
106 }
107
108 if matches!(mode, HitlNotifyMode::Rich) {
109 let notify_kind = match method {
110 NotifyMethodOverride::Auto => *DETECTED_NOTIFY_KIND,
111 NotifyMethodOverride::Bell => TerminalNotifyKind::BellOnly,
112 NotifyMethodOverride::Osc9 => TerminalNotifyKind::Osc9,
113 };
114 match notify_kind {
115 TerminalNotifyKind::Osc9 => send_osc9_notification(message),
116 TerminalNotifyKind::Osc777 => send_osc777_notification(message),
117 TerminalNotifyKind::BellOnly => {} }
119 }
120
121 emit_bell();
122}
123
124fn hitl_notify_mode(default_enabled: bool) -> HitlNotifyMode {
125 if let Ok(raw) = std::env::var("VTCODE_HITL_NOTIFY") {
126 let v = raw.trim().to_ascii_lowercase();
127 return match v.as_str() {
128 "off" | "0" | "false" => HitlNotifyMode::Off,
129 "bell" => HitlNotifyMode::Bell,
130 "rich" | "osc" | "notify" => HitlNotifyMode::Rich,
131 _ => HitlNotifyMode::Bell,
132 };
133 }
134
135 if default_enabled {
136 HitlNotifyMode::Rich
137 } else {
138 HitlNotifyMode::Off
139 }
140}
141
142fn detect_terminal_notify_kind() -> TerminalNotifyKind {
143 if let Ok(explicit_kind) = std::env::var("VTCODE_NOTIFY_KIND") {
144 let explicit = explicit_kind.trim().to_ascii_lowercase();
145 return match explicit.as_str() {
146 "osc9" => TerminalNotifyKind::Osc9,
147 "osc777" => TerminalNotifyKind::Osc777,
148 "bell" | "off" => TerminalNotifyKind::BellOnly,
149 _ => TerminalNotifyKind::BellOnly,
150 };
151 }
152
153 let term = std::env::var("TERM").unwrap_or_default().to_ascii_lowercase();
154 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default().to_ascii_lowercase();
155 let has_kitty = std::env::var("KITTY_WINDOW_ID").is_ok();
156 let has_iterm = std::env::var("ITERM_SESSION_ID").is_ok();
157 let has_wezterm = std::env::var("WEZTERM_PANE").is_ok();
158 let has_vte = std::env::var("VTE_VERSION").is_ok();
159
160 detect_terminal_notify_kind_from(&term, &term_program, has_kitty, has_iterm, has_wezterm, has_vte)
161}
162
163fn send_osc777_notification(message: Option<&str>) {
164 let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
165 let title = sanitize_notification_text("VT Code");
166 let payload = build_osc777_payload(&title, &body);
167 print!("{payload}{BEL}");
168 let _ = std::io::stdout().flush();
169}
170
171fn send_osc9_notification(message: Option<&str>) {
172 let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
173 let payload = build_osc9_payload(&body);
174 print!("{payload}{BEL}");
175 let _ = std::io::stdout().flush();
176}
177
178fn sanitize_notification_text(raw: &str) -> String {
179 const MAX_LEN: usize = 200;
180 let mut cleaned = raw.chars().filter(|c| *c >= ' ' && *c != '\u{007f}').collect::<String>();
181 if cleaned.len() > MAX_LEN {
182 cleaned.truncate(MAX_LEN);
183 }
184 cleaned.replace(';', ":")
185}
186
187fn detect_terminal_notify_kind_from(
188 term: &str,
189 term_program: &str,
190 has_kitty: bool,
191 has_iterm: bool,
192 has_wezterm: bool,
193 has_vte: bool,
194) -> TerminalNotifyKind {
195 if term.contains("kitty") || has_kitty {
196 return TerminalNotifyKind::Osc777;
197 }
198
199 if term_program.contains("ghostty") {
202 return TerminalNotifyKind::BellOnly;
203 }
204
205 if term_program.contains("iterm")
206 || term_program.contains("wezterm")
207 || term_program.contains("warp")
208 || term_program.contains("apple_terminal")
209 || has_iterm
210 || has_wezterm
211 {
212 return TerminalNotifyKind::Osc9;
213 }
214
215 if has_vte {
216 return TerminalNotifyKind::Osc777;
217 }
218
219 TerminalNotifyKind::BellOnly
220}
221
222fn build_osc777_payload(title: &str, body: &str) -> String {
223 format!("{OSC}777;notify;{title};{body}")
224}
225
226fn build_osc9_payload(body: &str) -> String {
227 format!("{OSC}9;{body}")
228}
229
230#[cfg(test)]
231mod redraw_tests {
232 use super::*;
233
234 #[test]
235 fn terminal_mapping_is_deterministic() {
236 assert_eq!(
237 detect_terminal_notify_kind_from("xterm-kitty", "", false, false, false, false),
238 TerminalNotifyKind::Osc777
239 );
240 assert_eq!(
242 detect_terminal_notify_kind_from("xterm-ghostty", "ghostty", false, false, false, false),
243 TerminalNotifyKind::BellOnly
244 );
245 assert_eq!(
246 detect_terminal_notify_kind_from("xterm-256color", "wezterm", false, false, false, false),
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!("{OSC}9;done"));
262 assert_eq!(build_osc777_payload("VT Code", "finished"), format!("{OSC}777;notify;VT Code;finished"));
263 }
264}
265
266pub const RESET: &str = "\x1b[0m";
268
269pub const BOLD: &str = "\x1b[1m";
271pub const DIM: &str = "\x1b[2m";
272pub const ITALIC: &str = "\x1b[3m";
273pub const UNDERLINE: &str = "\x1b[4m";
274pub const BLINK: &str = "\x1b[5m";
275pub const REVERSE: &str = "\x1b[7m";
276pub const HIDDEN: &str = "\x1b[8m";
277pub const STRIKETHROUGH: &str = "\x1b[9m";
278
279pub const RESET_BOLD_DIM: &str = "\x1b[22m";
280pub const RESET_ITALIC: &str = "\x1b[23m";
281pub const RESET_UNDERLINE: &str = "\x1b[24m";
282pub const RESET_BLINK: &str = "\x1b[25m";
283pub const RESET_REVERSE: &str = "\x1b[27m";
284pub const RESET_HIDDEN: &str = "\x1b[28m";
285pub const RESET_STRIKETHROUGH: &str = "\x1b[29m";
286
287pub const FG_BLACK: &str = "\x1b[30m";
289pub const FG_RED: &str = "\x1b[31m";
290pub const FG_GREEN: &str = "\x1b[32m";
291pub const FG_YELLOW: &str = "\x1b[33m";
292pub const FG_BLUE: &str = "\x1b[34m";
293pub const FG_MAGENTA: &str = "\x1b[35m";
294pub const FG_CYAN: &str = "\x1b[36m";
295pub const FG_WHITE: &str = "\x1b[37m";
296pub const FG_DEFAULT: &str = "\x1b[39m";
297
298pub const BG_BLACK: &str = "\x1b[40m";
300pub const BG_RED: &str = "\x1b[41m";
301pub const BG_GREEN: &str = "\x1b[42m";
302pub const BG_YELLOW: &str = "\x1b[43m";
303pub const BG_BLUE: &str = "\x1b[44m";
304pub const BG_MAGENTA: &str = "\x1b[45m";
305pub const BG_CYAN: &str = "\x1b[46m";
306pub const BG_WHITE: &str = "\x1b[47m";
307pub const BG_DEFAULT: &str = "\x1b[49m";
308
309pub const FG_BRIGHT_BLACK: &str = "\x1b[90m";
311pub const FG_BRIGHT_RED: &str = "\x1b[91m";
312pub const FG_BRIGHT_GREEN: &str = "\x1b[92m";
313pub const FG_BRIGHT_YELLOW: &str = "\x1b[93m";
314pub const FG_BRIGHT_BLUE: &str = "\x1b[94m";
315pub const FG_BRIGHT_MAGENTA: &str = "\x1b[95m";
316pub const FG_BRIGHT_CYAN: &str = "\x1b[96m";
317pub const FG_BRIGHT_WHITE: &str = "\x1b[97m";
318
319pub const BG_BRIGHT_BLACK: &str = "\x1b[100m";
321pub const BG_BRIGHT_RED: &str = "\x1b[101m";
322pub const BG_BRIGHT_GREEN: &str = "\x1b[102m";
323pub const BG_BRIGHT_YELLOW: &str = "\x1b[103m";
324pub const BG_BRIGHT_BLUE: &str = "\x1b[104m";
325pub const BG_BRIGHT_MAGENTA: &str = "\x1b[105m";
326pub const BG_BRIGHT_CYAN: &str = "\x1b[106m";
327pub const BG_BRIGHT_WHITE: &str = "\x1b[107m";
328
329pub const CURSOR_HOME: &str = "\x1b[H";
331pub const CURSOR_HIDE: &str = "\x1b[?25l";
332pub const CURSOR_SHOW: &str = "\x1b[?25h";
333pub const CURSOR_SAVE_DEC: &str = "\x1b7";
334pub const CURSOR_RESTORE_DEC: &str = "\x1b8";
335pub const CURSOR_SAVE_SCO: &str = "\x1b[s";
336pub const CURSOR_RESTORE_SCO: &str = "\x1b[u";
337
338pub const CLEAR_SCREEN: &str = "\x1b[2J";
340pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
341pub const CLEAR_TO_START_OF_SCREEN: &str = "\x1b[1J";
342pub const CLEAR_SAVED_LINES: &str = "\x1b[3J";
343pub const CLEAR_LINE: &str = "\x1b[2K";
344pub const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
345pub const CLEAR_TO_START_OF_LINE: &str = "\x1b[1K";
346
347pub const ALT_BUFFER_ENABLE: &str = "\x1b[?1049h";
349pub const ALT_BUFFER_DISABLE: &str = "\x1b[?1049l";
350pub const SCREEN_SAVE: &str = "\x1b[?47h";
351pub const SCREEN_RESTORE: &str = "\x1b[?47l";
352pub const LINE_WRAP_ENABLE: &str = "\x1b[=7h";
353pub const LINE_WRAP_DISABLE: &str = "\x1b[=7l";
354
355pub const SCROLL_REGION_RESET: &str = "\x1b[r";
358
359pub const INSERT_LINE: &str = "\x1b[L";
362pub const DELETE_LINE: &str = "\x1b[M";
364pub const INSERT_CHAR: &str = "\x1b[@";
366pub const DELETE_CHAR: &str = "\x1b[P";
368pub const ERASE_CHAR: &str = "\x1b[X";
370
371pub const SCROLL_UP: &str = "\x1b[S";
374pub const SCROLL_DOWN: &str = "\x1b[T";
376
377pub const INDEX: &str = "\x1bD";
380pub const NEXT_LINE: &str = "\x1bE";
382pub const TAB_SET: &str = "\x1bH";
384pub const REVERSE_INDEX: &str = "\x1bM";
386pub const FULL_RESET: &str = "\x1bc";
388pub const KEYPAD_APPLICATION: &str = "\x1b=";
390pub const KEYPAD_NUMERIC: &str = "\x1b>";
392
393pub const MOUSE_X10_ENABLE: &str = "\x1b[?9h";
396pub const MOUSE_X10_DISABLE: &str = "\x1b[?9l";
397pub const MOUSE_NORMAL_ENABLE: &str = "\x1b[?1000h";
399pub const MOUSE_NORMAL_DISABLE: &str = "\x1b[?1000l";
400pub const MOUSE_BUTTON_EVENT_ENABLE: &str = "\x1b[?1002h";
402pub const MOUSE_BUTTON_EVENT_DISABLE: &str = "\x1b[?1002l";
403pub const MOUSE_ANY_EVENT_ENABLE: &str = "\x1b[?1003h";
405pub const MOUSE_ANY_EVENT_DISABLE: &str = "\x1b[?1003l";
406pub const MOUSE_SGR_ENABLE: &str = "\x1b[?1006h";
408pub const MOUSE_SGR_DISABLE: &str = "\x1b[?1006l";
409pub const MOUSE_URXVT_ENABLE: &str = "\x1b[?1015h";
411pub const MOUSE_URXVT_DISABLE: &str = "\x1b[?1015l";
412
413pub const BRACKETED_PASTE_ENABLE: &str = "\x1b[?2004h";
416pub const BRACKETED_PASTE_DISABLE: &str = "\x1b[?2004l";
417pub const FOCUS_EVENT_ENABLE: &str = "\x1b[?1004h";
419pub const FOCUS_EVENT_DISABLE: &str = "\x1b[?1004l";
420pub const SYNC_OUTPUT_BEGIN: &str = "\x1b[?2026h";
422pub const SYNC_OUTPUT_END: &str = "\x1b[?2026l";
423pub const APP_CURSOR_KEYS_ENABLE: &str = "\x1b[?1h";
425pub const APP_CURSOR_KEYS_DISABLE: &str = "\x1b[?1l";
426pub const ORIGIN_MODE_ENABLE: &str = "\x1b[?6h";
428pub const ORIGIN_MODE_DISABLE: &str = "\x1b[?6l";
429pub const AUTO_WRAP_ENABLE: &str = "\x1b[?7h";
431pub const AUTO_WRAP_DISABLE: &str = "\x1b[?7l";
432
433pub const DEVICE_ATTRIBUTES_REQUEST: &str = "\x1b[c";
436pub const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
438pub const DEVICE_STATUS_REQUEST: &str = "\x1b[5n";
440
441pub const OSC_SET_TITLE_PREFIX: &str = "\x1b]2;";
444pub const OSC_SET_ICON_PREFIX: &str = "\x1b]1;";
446pub const OSC_SET_ICON_AND_TITLE_PREFIX: &str = "\x1b]0;";
448pub const OSC_FG_COLOR_PREFIX: &str = "\x1b]10;";
450pub const OSC_BG_COLOR_PREFIX: &str = "\x1b]11;";
452pub const OSC_CURSOR_COLOR_PREFIX: &str = "\x1b]12;";
454pub const OSC_HYPERLINK_PREFIX: &str = "\x1b]8;";
456pub const OSC_CLIPBOARD_PREFIX: &str = "\x1b]52;";
458
459pub const CHARSET_UTF8: &str = "\x1b%G";
462pub const CHARSET_DEFAULT: &str = "\x1b%@";
464
465#[inline]
468pub fn cursor_up(n: u16) -> String {
469 format!("{CSI}{n}A")
470}
471
472#[inline]
473pub fn cursor_down(n: u16) -> String {
474 format!("{CSI}{n}B")
475}
476
477#[inline]
478pub fn cursor_right(n: u16) -> String {
479 format!("{CSI}{n}C")
480}
481
482#[inline]
483pub fn cursor_left(n: u16) -> String {
484 format!("{CSI}{n}D")
485}
486
487#[inline]
488pub fn cursor_to(row: u16, col: u16) -> String {
489 format!("{CSI}{row};{col}H")
490}
491
492pub const REDRAW_LINE_PREFIX: &str = "\r\x1b[2K";
496
497#[inline]
498pub fn redraw_line_prefix() -> &'static str {
499 REDRAW_LINE_PREFIX
500}
501
502#[inline]
506pub fn format_redraw_line(content: &str) -> String {
507 format!("{}{}", redraw_line_prefix(), content)
508}
509
510#[inline]
511pub fn fg_256(color_id: u8) -> String {
512 format!("{CSI}38;5;{color_id}m")
513}
514
515#[inline]
516pub fn bg_256(color_id: u8) -> String {
517 format!("{CSI}48;5;{color_id}m")
518}
519
520#[inline]
521pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
522 format!("{CSI}38;2;{r};{g};{b}m")
523}
524
525#[inline]
526pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
527 format!("{CSI}48;2;{r};{g};{b}m")
528}
529
530#[inline]
531pub fn colored(text: &str, color: &str) -> String {
532 format!("{color}{text}{RESET}")
533}
534
535#[inline]
536pub fn bold(text: &str) -> String {
537 format!("{BOLD}{text}{RESET_BOLD_DIM}")
538}
539
540#[inline]
541pub fn italic(text: &str) -> String {
542 format!("{ITALIC}{text}{RESET_ITALIC}")
543}
544
545#[inline]
546pub fn underline(text: &str) -> String {
547 format!("{UNDERLINE}{text}{RESET_UNDERLINE}")
548}
549
550#[inline]
551pub fn dim(text: &str) -> String {
552 format!("{DIM}{text}{RESET_BOLD_DIM}")
553}
554
555#[inline]
556pub fn combine_styles(text: &str, styles: &[&str]) -> String {
557 let mut result = String::with_capacity(text.len() + styles.len() * 10);
558 for style in styles {
559 result.push_str(style);
560 }
561 result.push_str(text);
562 result.push_str(RESET);
563 result
564}
565
566pub mod semantic {
567 use super::*;
568 pub const ERROR: &str = FG_BRIGHT_RED;
569 pub const SUCCESS: &str = FG_BRIGHT_GREEN;
570 pub const WARNING: &str = FG_BRIGHT_YELLOW;
571 pub const INFO: &str = FG_BRIGHT_CYAN;
572 pub const MUTED: &str = DIM;
573 pub const EMPHASIS: &str = BOLD;
574 pub const DEBUG: &str = FG_BRIGHT_BLACK;
575}
576
577#[inline]
578#[must_use]
579pub fn contains_ansi(text: &str) -> bool {
580 text.contains(ESC_CHAR)
581}
582
583#[inline]
584#[must_use]
585pub fn starts_with_ansi(text: &str) -> bool {
586 text.starts_with(ESC_CHAR)
587}
588
589#[inline]
590#[must_use]
591pub fn ends_with_ansi(text: &str) -> bool {
592 text.ends_with('m') && text.contains(ESC)
593}
594
595#[inline]
596#[must_use]
597pub fn display_width(text: &str) -> usize {
598 crate::ansi::strip_ansi(text).len()
599}
600
601pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
602 let current_width = display_width(text);
603 if current_width >= width {
604 text.to_string()
605 } else {
606 let padding = pad_char.to_string().repeat(width - current_width);
607 format!("{text}{padding}")
608 }
609}
610
611pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
612 let stripped = crate::ansi::strip_ansi(text);
613 if stripped.len() <= max_width {
614 return text.to_string();
615 }
616
617 let truncate_at = max_width.saturating_sub(ellipsis.len());
618 let truncated_plain: String = stripped.chars().take(truncate_at).collect();
619
620 if starts_with_ansi(text) {
621 let mut ansi_prefix = String::new();
622 for ch in text.chars() {
623 ansi_prefix.push(ch);
624 if ch == '\x1b' {
625 continue;
626 }
627 if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
628 break;
629 }
630 }
631 format!("{ansi_prefix}{truncated_plain}{ellipsis}{RESET}")
632 } else {
633 format!("{truncated_plain}{ellipsis}")
634 }
635}
636
637#[inline]
638pub fn write_styled<W: Write>(writer: &mut W, text: &str, style: &str) -> std::io::Result<()> {
639 writer.write_all(style.as_bytes())?;
640 writer.write_all(text.as_bytes())?;
641 writer.write_all(RESET.as_bytes())?;
642 Ok(())
643}
644
645#[inline]
646pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
647 buffer.push_str(style);
648 buffer.push_str(text);
649 buffer.push_str(RESET);
650}
651
652#[inline]
654pub fn set_scroll_region(top: u16, bottom: u16) -> String {
655 format!("{CSI}{top};{bottom}r")
656}
657
658#[inline]
660pub fn insert_lines(n: u16) -> String {
661 format!("{CSI}{n}L")
662}
663
664#[inline]
666pub fn delete_lines(n: u16) -> String {
667 format!("{CSI}{n}M")
668}
669
670#[inline]
672pub fn scroll_up(n: u16) -> String {
673 format!("{CSI}{n}S")
674}
675
676#[inline]
678pub fn scroll_down(n: u16) -> String {
679 format!("{CSI}{n}T")
680}
681
682#[inline]
684pub fn set_window_title(title: &str) -> String {
685 format!("{OSC_SET_TITLE_PREFIX}{title}{BEL}")
686}
687
688#[inline]
690pub fn hyperlink_open(url: &str) -> String {
691 format!("{OSC_HYPERLINK_PREFIX};{url}{ST}")
692}
693
694#[inline]
696pub fn hyperlink_close() -> String {
697 format!("{OSC_HYPERLINK_PREFIX};{ST}")
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn redraw_prefix_matches_cli_pattern() {
706 assert_eq!(redraw_line_prefix(), "\r\x1b[2K");
707 }
708
709 #[test]
710 fn redraw_line_formats_expected_sequence() {
711 assert_eq!(format_redraw_line("Done"), "\r\x1b[2KDone");
712 }
713}