1use once_cell::sync::Lazy;
4use ratatui::crossterm::tty::IsTty;
5use std::io::Write;
6
7pub const ESC: &str = "\x1b";
9
10pub const CSI: &str = "\x1b[";
12
13pub const OSC: &str = "\x1b]";
15
16pub const DCS: &str = "\x1bP";
18
19pub const ST: &str = "\x1b\\";
21
22pub const BEL: &str = "\x07";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum HitlNotifyMode {
28 Off,
29 Bell,
30 Rich,
31}
32
33#[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#[inline]
45pub fn play_bell(enabled: bool) {
46 if !is_bell_enabled(enabled) {
47 return;
48 }
49 emit_bell();
50}
51
52#[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 => {} }
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 if term_program.contains("ghostty")
186 || term_program.contains("iterm")
187 || term_program.contains("wezterm")
188 || term_program.contains("warp")
189 || term_program.contains("apple_terminal")
190 || has_iterm
191 || has_wezterm
192 {
193 return TerminalNotifyKind::Osc9;
194 }
195
196 if has_vte {
197 return TerminalNotifyKind::Osc777;
198 }
199
200 TerminalNotifyKind::BellOnly
201}
202
203fn build_osc777_payload(title: &str, body: &str) -> String {
204 format!("{}777;notify;{};{}", OSC, title, body)
205}
206
207fn build_osc9_payload(body: &str) -> String {
208 format!("{}9;{}", OSC, body)
209}
210
211#[cfg(test)]
212mod redraw_tests {
213 use super::*;
214
215 #[test]
216 fn terminal_mapping_is_deterministic() {
217 assert_eq!(
218 detect_terminal_notify_kind_from("xterm-kitty", "", false, false, false, false),
219 TerminalNotifyKind::Osc777
220 );
221 assert_eq!(
222 detect_terminal_notify_kind_from(
223 "xterm-ghostty",
224 "ghostty",
225 false,
226 false,
227 false,
228 false
229 ),
230 TerminalNotifyKind::Osc9
231 );
232 assert_eq!(
233 detect_terminal_notify_kind_from(
234 "xterm-256color",
235 "wezterm",
236 false,
237 false,
238 false,
239 false
240 ),
241 TerminalNotifyKind::Osc9
242 );
243 assert_eq!(
244 detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, true),
245 TerminalNotifyKind::Osc777
246 );
247 assert_eq!(
248 detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, false),
249 TerminalNotifyKind::BellOnly
250 );
251 }
252
253 #[test]
254 fn osc_payload_format_is_stable() {
255 assert_eq!(build_osc9_payload("done"), format!("{}9;done", OSC));
256 assert_eq!(
257 build_osc777_payload("VT Code", "finished"),
258 format!("{}777;notify;VT Code;finished", OSC)
259 );
260 }
261}
262
263pub const RESET: &str = "\x1b[0m";
265
266pub const BOLD: &str = "\x1b[1m";
268pub const DIM: &str = "\x1b[2m";
269pub const ITALIC: &str = "\x1b[3m";
270pub const UNDERLINE: &str = "\x1b[4m";
271pub const BLINK: &str = "\x1b[5m";
272pub const REVERSE: &str = "\x1b[7m";
273pub const HIDDEN: &str = "\x1b[8m";
274pub const STRIKETHROUGH: &str = "\x1b[9m";
275
276pub const RESET_BOLD_DIM: &str = "\x1b[22m";
277pub const RESET_ITALIC: &str = "\x1b[23m";
278pub const RESET_UNDERLINE: &str = "\x1b[24m";
279pub const RESET_BLINK: &str = "\x1b[25m";
280pub const RESET_REVERSE: &str = "\x1b[27m";
281pub const RESET_HIDDEN: &str = "\x1b[28m";
282pub const RESET_STRIKETHROUGH: &str = "\x1b[29m";
283
284pub const FG_BLACK: &str = "\x1b[30m";
286pub const FG_RED: &str = "\x1b[31m";
287pub const FG_GREEN: &str = "\x1b[32m";
288pub const FG_YELLOW: &str = "\x1b[33m";
289pub const FG_BLUE: &str = "\x1b[34m";
290pub const FG_MAGENTA: &str = "\x1b[35m";
291pub const FG_CYAN: &str = "\x1b[36m";
292pub const FG_WHITE: &str = "\x1b[37m";
293pub const FG_DEFAULT: &str = "\x1b[39m";
294
295pub const BG_BLACK: &str = "\x1b[40m";
297pub const BG_RED: &str = "\x1b[41m";
298pub const BG_GREEN: &str = "\x1b[42m";
299pub const BG_YELLOW: &str = "\x1b[43m";
300pub const BG_BLUE: &str = "\x1b[44m";
301pub const BG_MAGENTA: &str = "\x1b[45m";
302pub const BG_CYAN: &str = "\x1b[46m";
303pub const BG_WHITE: &str = "\x1b[47m";
304pub const BG_DEFAULT: &str = "\x1b[49m";
305
306pub const FG_BRIGHT_BLACK: &str = "\x1b[90m";
308pub const FG_BRIGHT_RED: &str = "\x1b[91m";
309pub const FG_BRIGHT_GREEN: &str = "\x1b[92m";
310pub const FG_BRIGHT_YELLOW: &str = "\x1b[93m";
311pub const FG_BRIGHT_BLUE: &str = "\x1b[94m";
312pub const FG_BRIGHT_MAGENTA: &str = "\x1b[95m";
313pub const FG_BRIGHT_CYAN: &str = "\x1b[96m";
314pub const FG_BRIGHT_WHITE: &str = "\x1b[97m";
315
316pub const BG_BRIGHT_BLACK: &str = "\x1b[100m";
318pub const BG_BRIGHT_RED: &str = "\x1b[101m";
319pub const BG_BRIGHT_GREEN: &str = "\x1b[102m";
320pub const BG_BRIGHT_YELLOW: &str = "\x1b[103m";
321pub const BG_BRIGHT_BLUE: &str = "\x1b[104m";
322pub const BG_BRIGHT_MAGENTA: &str = "\x1b[105m";
323pub const BG_BRIGHT_CYAN: &str = "\x1b[106m";
324pub const BG_BRIGHT_WHITE: &str = "\x1b[107m";
325
326pub const CURSOR_HOME: &str = "\x1b[H";
328pub const CURSOR_HIDE: &str = "\x1b[?25l";
329pub const CURSOR_SHOW: &str = "\x1b[?25h";
330pub const CURSOR_SAVE_DEC: &str = "\x1b7";
331pub const CURSOR_RESTORE_DEC: &str = "\x1b8";
332pub const CURSOR_SAVE_SCO: &str = "\x1b[s";
333pub const CURSOR_RESTORE_SCO: &str = "\x1b[u";
334
335pub const CLEAR_SCREEN: &str = "\x1b[2J";
337pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
338pub const CLEAR_TO_START_OF_SCREEN: &str = "\x1b[1J";
339pub const CLEAR_SAVED_LINES: &str = "\x1b[3J";
340pub const CLEAR_LINE: &str = "\x1b[2K";
341pub const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
342pub const CLEAR_TO_START_OF_LINE: &str = "\x1b[1K";
343
344pub const ALT_BUFFER_ENABLE: &str = "\x1b[?1049h";
346pub const ALT_BUFFER_DISABLE: &str = "\x1b[?1049l";
347pub const SCREEN_SAVE: &str = "\x1b[?47h";
348pub const SCREEN_RESTORE: &str = "\x1b[?47l";
349pub const LINE_WRAP_ENABLE: &str = "\x1b[=7h";
350pub const LINE_WRAP_DISABLE: &str = "\x1b[=7l";
351
352#[inline]
355pub fn cursor_up(n: u16) -> String {
356 format!("\x1b[{}A", n)
357}
358
359#[inline]
360pub fn cursor_down(n: u16) -> String {
361 format!("\x1b[{}B", n)
362}
363
364#[inline]
365pub fn cursor_right(n: u16) -> String {
366 format!("\x1b[{}C", n)
367}
368
369#[inline]
370pub fn cursor_left(n: u16) -> String {
371 format!("\x1b[{}D", n)
372}
373
374#[inline]
375pub fn cursor_to(row: u16, col: u16) -> String {
376 format!("\x1b[{};{}H", row, col)
377}
378
379#[inline]
383pub fn redraw_line_prefix() -> &'static str {
384 "\r\x1b[2K"
385}
386
387#[inline]
391pub fn format_redraw_line(content: &str) -> String {
392 format!("{}{}", redraw_line_prefix(), content)
393}
394
395#[inline]
396pub fn fg_256(color_id: u8) -> String {
397 format!("\x1b[38;5;{}m", color_id)
398}
399
400#[inline]
401pub fn bg_256(color_id: u8) -> String {
402 format!("\x1b[48;5;{}m", color_id)
403}
404
405#[inline]
406pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
407 format!("\x1b[38;2;{};{};{}m", r, g, b)
408}
409
410#[inline]
411pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
412 format!("\x1b[48;2;{};{};{}m", r, g, b)
413}
414
415#[inline]
416pub fn colored(text: &str, color: &str) -> String {
417 format!("{}{}{}", color, text, RESET)
418}
419
420#[inline]
421pub fn bold(text: &str) -> String {
422 format!("{}{}{}", BOLD, text, RESET_BOLD_DIM)
423}
424
425#[inline]
426pub fn italic(text: &str) -> String {
427 format!("{}{}{}", ITALIC, text, RESET_ITALIC)
428}
429
430#[inline]
431pub fn underline(text: &str) -> String {
432 format!("{}{}{}", UNDERLINE, text, RESET_UNDERLINE)
433}
434
435#[inline]
436pub fn dim(text: &str) -> String {
437 format!("{}{}{}", DIM, text, RESET_BOLD_DIM)
438}
439
440#[inline]
441pub fn combine_styles(text: &str, styles: &[&str]) -> String {
442 let mut result = String::with_capacity(text.len() + styles.len() * 10);
443 for style in styles {
444 result.push_str(style);
445 }
446 result.push_str(text);
447 result.push_str(RESET);
448 result
449}
450
451pub mod semantic {
452 use super::*;
453 pub const ERROR: &str = FG_BRIGHT_RED;
454 pub const SUCCESS: &str = FG_BRIGHT_GREEN;
455 pub const WARNING: &str = FG_BRIGHT_YELLOW;
456 pub const INFO: &str = FG_BRIGHT_CYAN;
457 pub const MUTED: &str = DIM;
458 pub const EMPHASIS: &str = BOLD;
459 pub const DEBUG: &str = FG_BRIGHT_BLACK;
460}
461
462#[inline]
463pub fn contains_ansi(text: &str) -> bool {
464 text.contains(ESC)
465}
466
467#[inline]
468pub fn starts_with_ansi(text: &str) -> bool {
469 text.starts_with(ESC)
470}
471
472#[inline]
473pub fn ends_with_ansi(text: &str) -> bool {
474 text.ends_with('m') && text.contains(ESC)
475}
476
477#[inline]
478pub fn display_width(text: &str) -> usize {
479 crate::ansi::strip_ansi(text).len()
480}
481
482pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
483 let current_width = display_width(text);
484 if current_width >= width {
485 text.to_string()
486 } else {
487 let padding = pad_char.to_string().repeat(width - current_width);
488 format!("{}{}", text, padding)
489 }
490}
491
492pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
493 let stripped = crate::ansi::strip_ansi(text);
494 if stripped.len() <= max_width {
495 return text.to_string();
496 }
497
498 let truncate_at = max_width.saturating_sub(ellipsis.len());
499 let truncated_plain: String = stripped.chars().take(truncate_at).collect();
500
501 if starts_with_ansi(text) {
502 let mut ansi_prefix = String::new();
503 for ch in text.chars() {
504 ansi_prefix.push(ch);
505 if ch == '\x1b' {
506 continue;
507 }
508 if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
509 break;
510 }
511 }
512 format!("{}{}{}{}", ansi_prefix, truncated_plain, ellipsis, RESET)
513 } else {
514 format!("{}{}", truncated_plain, ellipsis)
515 }
516}
517
518#[inline]
519pub fn write_styled<W: Write>(writer: &mut W, text: &str, style: &str) -> std::io::Result<()> {
520 writer.write_all(style.as_bytes())?;
521 writer.write_all(text.as_bytes())?;
522 writer.write_all(RESET.as_bytes())?;
523 Ok(())
524}
525
526#[inline]
527pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
528 buffer.push_str(style);
529 buffer.push_str(text);
530 buffer.push_str(RESET);
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn redraw_prefix_matches_cli_pattern() {
539 assert_eq!(redraw_line_prefix(), "\r\x1b[2K");
540 }
541
542 #[test]
543 fn redraw_line_formats_expected_sequence() {
544 assert_eq!(format_redraw_line("Done"), "\r\x1b[2KDone");
545 }
546}