1#![expect(
2 clippy::let_underscore_must_use,
3 reason = "ANSI output cleanup intentionally ignores the best-effort flush result."
4)]
5
6use once_cell::sync::Lazy;
11use std::io::{IsTerminal, Write};
12
13use crate::utils::parse_bool_env_value;
14
15const BELL_ENV: &str = "VTCODE_HITL_BELL";
17
18const NOTIFY_ENV: &str = "VTCODE_HITL_NOTIFY";
20
21pub const ESC_BYTE: u8 = 0x1b;
23
24pub const ESC_CHAR: char = '\x1b';
26
27pub const ESC: &str = "\x1b";
29
30pub const CSI: &str = "\x1b[";
32
33pub const OSC: &str = "\x1b]";
35
36pub const DCS: &str = "\x1bP";
38
39pub const ST: &str = "\x1b\\";
41
42pub(crate) const BEL_BYTE: u8 = 0x07;
44
45pub const BEL_CHAR: char = '\x07';
47
48const BEL: &str = "\x07";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum HitlNotifyMode {
54 Off,
55 Bell,
56 Rich,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TerminalNotifyKind {
62 BellOnly,
63 Osc9,
64 Osc777,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum NotifyMethodOverride {
70 Auto,
71 Bell,
72 Osc9,
73}
74
75static DETECTED_NOTIFY_KIND: Lazy<TerminalNotifyKind> = Lazy::new(detect_terminal_notify_kind);
76
77#[inline]
79pub fn play_bell(enabled: bool) {
80 if !is_bell_enabled(enabled) {
81 return;
82 }
83 emit_bell();
84}
85
86#[inline]
92fn is_bell_enabled(default_enabled: bool) -> bool {
93 crate::utils::parse_env_bool(BELL_ENV, default_enabled)
94}
95
96#[inline]
97fn emit_bell() {
98 print!("{BEL}");
99 let _ = std::io::stdout().flush();
100}
101
102#[inline]
103pub fn notify_attention(default_enabled: bool, message: Option<&str>) {
104 notify_attention_with_mode(default_enabled, message, NotifyMethodOverride::Auto);
105}
106
107#[inline]
108pub fn notify_attention_with_mode(default_enabled: bool, message: Option<&str>, method: NotifyMethodOverride) {
109 if !is_bell_enabled(default_enabled) {
110 return;
111 }
112
113 if !std::io::stdout().is_terminal() {
114 return;
115 }
116
117 let mode = hitl_notify_mode(default_enabled);
118 if matches!(mode, HitlNotifyMode::Off) {
119 return;
120 }
121
122 if matches!(mode, HitlNotifyMode::Rich) {
123 let notify_kind = match method {
124 NotifyMethodOverride::Auto => *DETECTED_NOTIFY_KIND,
125 NotifyMethodOverride::Bell => TerminalNotifyKind::BellOnly,
126 NotifyMethodOverride::Osc9 => TerminalNotifyKind::Osc9,
127 };
128 match notify_kind {
129 TerminalNotifyKind::Osc9 => send_osc9_notification(message),
130 TerminalNotifyKind::Osc777 => send_osc777_notification(message),
131 TerminalNotifyKind::BellOnly => {} }
133 }
134
135 emit_bell();
136}
137
138fn hitl_notify_mode(default_enabled: bool) -> HitlNotifyMode {
139 if let Ok(raw) = std::env::var(NOTIFY_ENV) {
140 let v = raw.trim().to_ascii_lowercase();
141 return match v.as_str() {
142 "bell" => HitlNotifyMode::Bell,
143 "rich" | "osc" | "notify" => HitlNotifyMode::Rich,
144 _ if parse_bool_env_value(&v) == Some(false) => HitlNotifyMode::Off,
147 _ => HitlNotifyMode::Bell,
148 };
149 }
150
151 if default_enabled {
152 HitlNotifyMode::Rich
153 } else {
154 HitlNotifyMode::Off
155 }
156}
157
158fn detect_terminal_notify_kind() -> TerminalNotifyKind {
159 if let Ok(explicit_kind) = std::env::var("VTCODE_NOTIFY_KIND") {
160 let explicit = explicit_kind.trim().to_ascii_lowercase();
161 return match explicit.as_str() {
162 "osc9" => TerminalNotifyKind::Osc9,
163 "osc777" => TerminalNotifyKind::Osc777,
164 "bell" | "off" => TerminalNotifyKind::BellOnly,
165 _ => TerminalNotifyKind::BellOnly,
166 };
167 }
168
169 let term = std::env::var("TERM").unwrap_or_default().to_ascii_lowercase();
170 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default().to_ascii_lowercase();
171 let has_kitty = std::env::var("KITTY_WINDOW_ID").is_ok();
172 let has_iterm = std::env::var("ITERM_SESSION_ID").is_ok();
173 let has_wezterm = std::env::var("WEZTERM_PANE").is_ok();
174 let has_vte = std::env::var("VTE_VERSION").is_ok();
175
176 detect_terminal_notify_kind_from(&term, &term_program, has_kitty, has_iterm, has_wezterm, has_vte)
177}
178
179fn send_osc777_notification(message: Option<&str>) {
180 let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
181 let title = sanitize_notification_text("VT Code");
182 let payload = build_osc777_payload(&title, &body);
183 print!("{payload}{BEL}");
184 let _ = std::io::stdout().flush();
185}
186
187fn send_osc9_notification(message: Option<&str>) {
188 let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
189 let payload = build_osc9_payload(&body);
190 print!("{payload}{BEL}");
191 let _ = std::io::stdout().flush();
192}
193
194fn sanitize_notification_text(raw: &str) -> String {
195 const MAX_LEN: usize = 200;
196 let cleaned = raw.chars().filter(|c| *c >= ' ' && *c != '\u{007f}').collect::<String>();
197 let cleaned = crate::formatting::truncate_byte_budget(&cleaned, MAX_LEN, "");
198 cleaned.replace(';', ":")
199}
200
201fn detect_terminal_notify_kind_from(
202 term: &str,
203 term_program: &str,
204 has_kitty: bool,
205 has_iterm: bool,
206 has_wezterm: bool,
207 has_vte: bool,
208) -> TerminalNotifyKind {
209 if term.contains("kitty") || has_kitty {
210 return TerminalNotifyKind::Osc777;
211 }
212
213 if term_program.contains("ghostty") {
216 return TerminalNotifyKind::BellOnly;
217 }
218
219 if term_program.contains("iterm")
220 || term_program.contains("wezterm")
221 || term_program.contains("warp")
222 || term_program.contains("apple_terminal")
223 || has_iterm
224 || has_wezterm
225 {
226 return TerminalNotifyKind::Osc9;
227 }
228
229 if has_vte {
230 return TerminalNotifyKind::Osc777;
231 }
232
233 TerminalNotifyKind::BellOnly
234}
235
236fn build_osc777_payload(title: &str, body: &str) -> String {
237 format!("{OSC}777;notify;{title};{body}")
238}
239
240fn build_osc9_payload(body: &str) -> String {
241 format!("{OSC}9;{body}")
242}
243
244#[cfg(test)]
245mod redraw_tests {
246 use super::*;
247
248 #[test]
249 fn terminal_mapping_is_deterministic() {
250 assert_eq!(
251 detect_terminal_notify_kind_from("xterm-kitty", "", false, false, false, false),
252 TerminalNotifyKind::Osc777
253 );
254 assert_eq!(
256 detect_terminal_notify_kind_from("xterm-ghostty", "ghostty", false, false, false, false),
257 TerminalNotifyKind::BellOnly
258 );
259 assert_eq!(
260 detect_terminal_notify_kind_from("xterm-256color", "wezterm", false, false, false, false),
261 TerminalNotifyKind::Osc9
262 );
263 assert_eq!(
264 detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, true),
265 TerminalNotifyKind::Osc777
266 );
267 assert_eq!(
268 detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, false),
269 TerminalNotifyKind::BellOnly
270 );
271 }
272
273 #[test]
274 fn osc_payload_format_is_stable() {
275 assert_eq!(build_osc9_payload("done"), format!("{OSC}9;done"));
276 assert_eq!(build_osc777_payload("VT Code", "finished"), format!("{OSC}777;notify;VT Code;finished"));
277 }
278
279 #[test]
280 fn notification_sanitization_does_not_split_utf8() {
281 let raw = format!("{}界", "a".repeat(199));
282
283 assert_eq!(sanitize_notification_text(&raw), "a".repeat(199));
284 }
285}
286
287pub const RESET: &str = "\x1b[0m";
289
290pub const BOLD: &str = "\x1b[1m";
292pub const DIM: &str = "\x1b[2m";
293pub const ITALIC: &str = "\x1b[3m";
294pub const UNDERLINE: &str = "\x1b[4m";
295pub const BLINK: &str = "\x1b[5m";
296pub const REVERSE: &str = "\x1b[7m";
297pub const HIDDEN: &str = "\x1b[8m";
298pub const STRIKETHROUGH: &str = "\x1b[9m";
299
300pub const RESET_BOLD_DIM: &str = "\x1b[22m";
301pub const RESET_ITALIC: &str = "\x1b[23m";
302pub const RESET_UNDERLINE: &str = "\x1b[24m";
303pub const RESET_BLINK: &str = "\x1b[25m";
304pub const RESET_REVERSE: &str = "\x1b[27m";
305pub const RESET_HIDDEN: &str = "\x1b[28m";
306pub const RESET_STRIKETHROUGH: &str = "\x1b[29m";
307
308pub const FG_BLACK: &str = "\x1b[30m";
310pub const FG_RED: &str = "\x1b[31m";
311pub const FG_GREEN: &str = "\x1b[32m";
312pub const FG_YELLOW: &str = "\x1b[33m";
313pub const FG_BLUE: &str = "\x1b[34m";
314pub const FG_MAGENTA: &str = "\x1b[35m";
315pub const FG_CYAN: &str = "\x1b[36m";
316pub const FG_WHITE: &str = "\x1b[37m";
317pub const FG_DEFAULT: &str = "\x1b[39m";
318
319pub const BG_BLACK: &str = "\x1b[40m";
321pub const BG_RED: &str = "\x1b[41m";
322pub const BG_GREEN: &str = "\x1b[42m";
323pub const BG_YELLOW: &str = "\x1b[43m";
324pub const BG_BLUE: &str = "\x1b[44m";
325pub const BG_MAGENTA: &str = "\x1b[45m";
326pub const BG_CYAN: &str = "\x1b[46m";
327pub const BG_WHITE: &str = "\x1b[47m";
328pub const BG_DEFAULT: &str = "\x1b[49m";
329
330pub const FG_BRIGHT_BLACK: &str = "\x1b[90m";
332pub const FG_BRIGHT_RED: &str = "\x1b[91m";
333pub const FG_BRIGHT_GREEN: &str = "\x1b[92m";
334pub const FG_BRIGHT_YELLOW: &str = "\x1b[93m";
335pub const FG_BRIGHT_BLUE: &str = "\x1b[94m";
336pub const FG_BRIGHT_MAGENTA: &str = "\x1b[95m";
337pub const FG_BRIGHT_CYAN: &str = "\x1b[96m";
338pub const FG_BRIGHT_WHITE: &str = "\x1b[97m";
339
340pub const BG_BRIGHT_BLACK: &str = "\x1b[100m";
342pub const BG_BRIGHT_RED: &str = "\x1b[101m";
343pub const BG_BRIGHT_GREEN: &str = "\x1b[102m";
344pub const BG_BRIGHT_YELLOW: &str = "\x1b[103m";
345pub const BG_BRIGHT_BLUE: &str = "\x1b[104m";
346pub const BG_BRIGHT_MAGENTA: &str = "\x1b[105m";
347pub const BG_BRIGHT_CYAN: &str = "\x1b[106m";
348pub const BG_BRIGHT_WHITE: &str = "\x1b[107m";
349
350pub const CURSOR_HOME: &str = "\x1b[H";
352pub const CURSOR_HIDE: &str = "\x1b[?25l";
353pub const CURSOR_SHOW: &str = "\x1b[?25h";
354pub const CURSOR_SAVE_DEC: &str = "\x1b7";
355pub const CURSOR_RESTORE_DEC: &str = "\x1b8";
356pub const CURSOR_SAVE_SCO: &str = "\x1b[s";
357pub const CURSOR_RESTORE_SCO: &str = "\x1b[u";
358
359pub const CLEAR_SCREEN: &str = "\x1b[2J";
361pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
362pub const CLEAR_TO_START_OF_SCREEN: &str = "\x1b[1J";
363pub const CLEAR_SAVED_LINES: &str = "\x1b[3J";
364pub const CLEAR_LINE: &str = "\x1b[2K";
365pub const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
366pub const CLEAR_TO_START_OF_LINE: &str = "\x1b[1K";
367
368pub const ALT_BUFFER_ENABLE: &str = "\x1b[?1049h";
370pub const ALT_BUFFER_DISABLE: &str = "\x1b[?1049l";
371pub const SCREEN_SAVE: &str = "\x1b[?47h";
372pub const SCREEN_RESTORE: &str = "\x1b[?47l";
373pub const LINE_WRAP_ENABLE: &str = "\x1b[=7h";
374pub const LINE_WRAP_DISABLE: &str = "\x1b[=7l";
375
376pub const SCROLL_REGION_RESET: &str = "\x1b[r";
379
380pub const INSERT_LINE: &str = "\x1b[L";
383pub const DELETE_LINE: &str = "\x1b[M";
385pub const INSERT_CHAR: &str = "\x1b[@";
387pub const DELETE_CHAR: &str = "\x1b[P";
389pub const ERASE_CHAR: &str = "\x1b[X";
391
392pub const SCROLL_UP: &str = "\x1b[S";
395pub const SCROLL_DOWN: &str = "\x1b[T";
397
398pub const INDEX: &str = "\x1bD";
401pub const NEXT_LINE: &str = "\x1bE";
403pub const TAB_SET: &str = "\x1bH";
405pub const REVERSE_INDEX: &str = "\x1bM";
407pub const FULL_RESET: &str = "\x1bc";
409pub const KEYPAD_APPLICATION: &str = "\x1b=";
411pub const KEYPAD_NUMERIC: &str = "\x1b>";
413
414pub const MOUSE_X10_ENABLE: &str = "\x1b[?9h";
417pub const MOUSE_X10_DISABLE: &str = "\x1b[?9l";
418pub const MOUSE_NORMAL_ENABLE: &str = "\x1b[?1000h";
420pub const MOUSE_NORMAL_DISABLE: &str = "\x1b[?1000l";
421pub const MOUSE_BUTTON_EVENT_ENABLE: &str = "\x1b[?1002h";
423pub const MOUSE_BUTTON_EVENT_DISABLE: &str = "\x1b[?1002l";
424pub const MOUSE_ANY_EVENT_ENABLE: &str = "\x1b[?1003h";
426pub const MOUSE_ANY_EVENT_DISABLE: &str = "\x1b[?1003l";
427pub const MOUSE_SGR_ENABLE: &str = "\x1b[?1006h";
429pub const MOUSE_SGR_DISABLE: &str = "\x1b[?1006l";
430pub const MOUSE_URXVT_ENABLE: &str = "\x1b[?1015h";
432pub const MOUSE_URXVT_DISABLE: &str = "\x1b[?1015l";
433
434pub const BRACKETED_PASTE_ENABLE: &str = "\x1b[?2004h";
437pub const BRACKETED_PASTE_DISABLE: &str = "\x1b[?2004l";
438pub const FOCUS_EVENT_ENABLE: &str = "\x1b[?1004h";
440pub const FOCUS_EVENT_DISABLE: &str = "\x1b[?1004l";
441pub const SYNC_OUTPUT_BEGIN: &str = "\x1b[?2026h";
443pub const SYNC_OUTPUT_END: &str = "\x1b[?2026l";
444pub const APP_CURSOR_KEYS_ENABLE: &str = "\x1b[?1h";
446pub const APP_CURSOR_KEYS_DISABLE: &str = "\x1b[?1l";
447pub const ORIGIN_MODE_ENABLE: &str = "\x1b[?6h";
449pub const ORIGIN_MODE_DISABLE: &str = "\x1b[?6l";
450pub const AUTO_WRAP_ENABLE: &str = "\x1b[?7h";
452pub const AUTO_WRAP_DISABLE: &str = "\x1b[?7l";
453
454pub const DEVICE_ATTRIBUTES_REQUEST: &str = "\x1b[c";
457pub const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
459pub const DEVICE_STATUS_REQUEST: &str = "\x1b[5n";
461pub const COLOR_SCHEME_MODE_REQUEST: &str = "\x1b[?996n";
464pub const COLOR_SCHEME_REPORT_PREFIX: &str = "\x1b[?997;";
467pub const COLOR_SCHEME_REPORTS_ENABLE: &str = "\x1b[?2031h";
471pub const COLOR_SCHEME_REPORTS_DISABLE: &str = "\x1b[?2031l";
473
474pub const OSC_SET_TITLE_PREFIX: &str = "\x1b]2;";
477pub const OSC_SET_ICON_PREFIX: &str = "\x1b]1;";
479pub const OSC_SET_ICON_AND_TITLE_PREFIX: &str = "\x1b]0;";
481pub const OSC_FG_COLOR_PREFIX: &str = "\x1b]10;";
483pub const OSC_BG_COLOR_PREFIX: &str = "\x1b]11;";
485pub const OSC_CURSOR_COLOR_PREFIX: &str = "\x1b]12;";
487const OSC_HYPERLINK_PREFIX: &str = "\x1b]8;";
489pub const OSC_CLIPBOARD_PREFIX: &str = "\x1b]52;";
491
492pub const CHARSET_UTF8: &str = "\x1b%G";
495pub const CHARSET_DEFAULT: &str = "\x1b%@";
497
498#[inline]
501pub fn cursor_up(n: u16) -> String {
502 format!("{CSI}{n}A")
503}
504
505#[inline]
506pub fn cursor_down(n: u16) -> String {
507 format!("{CSI}{n}B")
508}
509
510#[inline]
511pub fn cursor_right(n: u16) -> String {
512 format!("{CSI}{n}C")
513}
514
515#[inline]
516pub fn cursor_left(n: u16) -> String {
517 format!("{CSI}{n}D")
518}
519
520#[inline]
521pub fn cursor_to(row: u16, col: u16) -> String {
522 format!("{CSI}{row};{col}H")
523}
524
525const REDRAW_LINE_PREFIX: &str = "\r\x1b[2K";
529
530#[inline]
531fn redraw_line_prefix() -> &'static str {
532 REDRAW_LINE_PREFIX
533}
534
535#[inline]
539fn format_redraw_line(content: &str) -> String {
540 format!("{}{}", redraw_line_prefix(), content)
541}
542
543#[inline]
544pub fn fg_256(color_id: u8) -> String {
545 format!("{CSI}38;5;{color_id}m")
546}
547
548#[inline]
549pub fn bg_256(color_id: u8) -> String {
550 format!("{CSI}48;5;{color_id}m")
551}
552
553#[inline]
554pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
555 format!("{CSI}38;2;{r};{g};{b}m")
556}
557
558#[inline]
559pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
560 format!("{CSI}48;2;{r};{g};{b}m")
561}
562
563#[inline]
564pub fn colored(text: &str, color: &str) -> String {
565 format!("{color}{text}{RESET}")
566}
567
568#[inline]
569pub fn bold(text: &str) -> String {
570 format!("{BOLD}{text}{RESET_BOLD_DIM}")
571}
572
573#[inline]
574pub fn italic(text: &str) -> String {
575 format!("{ITALIC}{text}{RESET_ITALIC}")
576}
577
578#[inline]
579pub fn underline(text: &str) -> String {
580 format!("{UNDERLINE}{text}{RESET_UNDERLINE}")
581}
582
583#[inline]
584pub fn dim(text: &str) -> String {
585 format!("{DIM}{text}{RESET_BOLD_DIM}")
586}
587
588#[inline]
589pub fn combine_styles(text: &str, styles: &[&str]) -> String {
590 let mut result = String::with_capacity(text.len() + styles.len() * 10);
591 for style in styles {
592 result.push_str(style);
593 }
594 result.push_str(text);
595 result.push_str(RESET);
596 result
597}
598
599pub mod semantic {
600 use super::*;
601 pub const ERROR: &str = FG_BRIGHT_RED;
602 pub const SUCCESS: &str = FG_BRIGHT_GREEN;
603 pub const WARNING: &str = FG_BRIGHT_YELLOW;
604 pub const INFO: &str = FG_BRIGHT_CYAN;
605 pub const MUTED: &str = DIM;
606 pub const EMPHASIS: &str = BOLD;
607 pub const DEBUG: &str = FG_BRIGHT_BLACK;
608}
609
610#[inline]
611#[must_use]
612pub fn contains_ansi(text: &str) -> bool {
613 text.contains(ESC_CHAR)
614}
615
616#[inline]
617#[must_use]
618pub fn starts_with_ansi(text: &str) -> bool {
619 text.starts_with(ESC_CHAR)
620}
621
622#[inline]
623#[must_use]
624pub fn ends_with_ansi(text: &str) -> bool {
625 text.ends_with('m') && text.contains(ESC)
626}
627
628#[inline]
629#[must_use]
630pub fn display_width(text: &str) -> usize {
631 crate::preview::display_width(&crate::ansi::strip_ansi(text))
632}
633
634pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
635 let current_width = display_width(text);
636 if current_width >= width {
637 text.to_string()
638 } else {
639 let padding = pad_char.to_string().repeat(width - current_width);
640 format!("{text}{padding}")
641 }
642}
643
644pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
645 let stripped = crate::ansi::strip_ansi(text);
646 if crate::preview::display_width(&stripped) <= max_width {
647 return text.to_string();
648 }
649
650 let truncated_plain = crate::preview::truncate_with_ellipsis(&stripped, max_width, ellipsis);
651
652 if starts_with_ansi(text) {
653 let mut ansi_prefix = String::new();
654 for ch in text.chars() {
655 ansi_prefix.push(ch);
656 if ch == '\x1b' {
657 continue;
658 }
659 if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
660 break;
661 }
662 }
663 format!("{ansi_prefix}{truncated_plain}{RESET}")
664 } else {
665 truncated_plain
666 }
667}
668
669#[inline]
670pub fn write_styled<W: Write>(writer: &mut W, text: &str, style: &str) -> std::io::Result<()> {
671 writer.write_all(style.as_bytes())?;
672 writer.write_all(text.as_bytes())?;
673 writer.write_all(RESET.as_bytes())?;
674 Ok(())
675}
676
677#[inline]
678pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
679 buffer.push_str(style);
680 buffer.push_str(text);
681 buffer.push_str(RESET);
682}
683
684#[inline]
686pub fn set_scroll_region(top: u16, bottom: u16) -> String {
687 format!("{CSI}{top};{bottom}r")
688}
689
690#[inline]
692pub fn insert_lines(n: u16) -> String {
693 format!("{CSI}{n}L")
694}
695
696#[inline]
698pub fn delete_lines(n: u16) -> String {
699 format!("{CSI}{n}M")
700}
701
702#[inline]
704pub fn scroll_up(n: u16) -> String {
705 format!("{CSI}{n}S")
706}
707
708#[inline]
710pub fn scroll_down(n: u16) -> String {
711 format!("{CSI}{n}T")
712}
713
714#[inline]
716pub fn set_window_title(title: &str) -> String {
717 format!("{OSC_SET_TITLE_PREFIX}{title}{BEL}")
718}
719
720#[inline]
727pub fn set_icon_name(name: &str) -> String {
728 format!("{OSC_SET_ICON_PREFIX}{name}{BEL}")
729}
730
731#[inline]
737pub fn set_terminal_title_and_icon(title: &str) -> String {
738 format!("{}{}", set_icon_name(title), set_window_title(title))
739}
740
741#[inline]
749pub fn set_iterm2_profile(profile_name: &str) -> String {
750 format!("{OSC}1337;SetProfile={profile_name}{BEL}")
751}
752
753#[inline]
755pub fn hyperlink_open(url: &str) -> String {
756 format!("{OSC_HYPERLINK_PREFIX};{url}{ST}")
757}
758
759#[inline]
761pub fn hyperlink_close() -> String {
762 format!("{OSC_HYPERLINK_PREFIX};{ST}")
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn redraw_prefix_matches_cli_pattern() {
771 assert_eq!(redraw_line_prefix(), "\r\x1b[2K");
772 }
773
774 #[test]
775 fn redraw_line_formats_expected_sequence() {
776 assert_eq!(format_redraw_line("Done"), "\r\x1b[2KDone");
777 }
778
779 #[test]
780 fn icon_and_title_payloads_use_distinct_osc_codes() {
781 assert_eq!(set_icon_name("VT"), "\x1b]1;VT\x07");
782 assert_eq!(set_window_title("Code"), "\x1b]2;Code\x07");
783 }
784
785 #[test]
786 fn terminal_title_and_icon_emits_both_sequences_in_order() {
787 let combined = set_terminal_title_and_icon("demo-project");
788 assert_eq!(combined, "\x1b]1;demo-project\x07\x1b]2;demo-project\x07");
789
790 let cleared = set_terminal_title_and_icon("");
791 assert_eq!(cleared, "\x1b]1;\x07\x1b]2;\x07");
792 }
793
794 #[test]
795 fn iterm2_profile_switch_uses_proprietary_sequence() {
796 assert_eq!(set_iterm2_profile("VT Code"), "\x1b]1337;SetProfile=VT Code\x07");
797 assert_eq!(set_iterm2_profile("Default"), "\x1b]1337;SetProfile=Default\x07");
798 }
799
800 #[test]
801 fn width_helpers_measure_cells_not_bytes_and_ignore_ansi() {
802 assert_eq!(display_width("\x1b[1m日本\x1b[0m"), 4);
803 assert_eq!(truncate_to_width("éé", 3, "…"), "éé");
805 assert_eq!(truncate_to_width("日本語", 5, "…"), "日本…");
806 assert_eq!(truncate_to_width("\x1b[1mabcdef\x1b[0m", 4, "…"), format!("\x1b[1mabc…{RESET}"));
807 }
808
809 #[test]
810 fn bell_override_uses_the_canonical_boolean_set() {
811 let guard = crate::env_lock::lock();
812 let previous = std::env::var_os(BELL_ENV);
813
814 for value in ["0", "false", "no", "NO", " off "] {
815 guard.set_var(BELL_ENV, value);
816 assert!(!is_bell_enabled(true), "{value:?} should disable the bell");
817 }
818
819 for value in ["1", "true", "yes", "on"] {
820 guard.set_var(BELL_ENV, value);
821 assert!(is_bell_enabled(false), "{value:?} should enable the bell");
822 }
823
824 for value in ["debug", "maybe"] {
826 guard.set_var(BELL_ENV, value);
827 assert!(is_bell_enabled(true), "{value:?} should keep the default on");
828 assert!(!is_bell_enabled(false), "{value:?} should keep the default off");
829 }
830
831 guard.remove_var(BELL_ENV);
832 assert!(is_bell_enabled(true));
833 assert!(!is_bell_enabled(false));
834
835 guard.restore_var(BELL_ENV, previous);
836 }
837
838 #[test]
839 fn notify_mode_override_honors_canonical_falsy_values() {
840 let guard = crate::env_lock::lock();
841 let previous = std::env::var_os(NOTIFY_ENV);
842
843 for value in ["off", "0", "false", "no", "NO"] {
844 guard.set_var(NOTIFY_ENV, value);
845 assert_eq!(hitl_notify_mode(true), HitlNotifyMode::Off, "{value:?} should disable notifications");
846 }
847
848 guard.set_var(NOTIFY_ENV, "bell");
849 assert_eq!(hitl_notify_mode(true), HitlNotifyMode::Bell);
850
851 for value in ["rich", "osc", "notify"] {
852 guard.set_var(NOTIFY_ENV, value);
853 assert_eq!(hitl_notify_mode(false), HitlNotifyMode::Rich, "{value:?} should request rich notifications");
854 }
855
856 guard.remove_var(NOTIFY_ENV);
857 assert_eq!(hitl_notify_mode(true), HitlNotifyMode::Rich);
858 assert_eq!(hitl_notify_mode(false), HitlNotifyMode::Off);
859
860 guard.restore_var(NOTIFY_ENV, previous);
861 }
862}