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