Skip to main content

vtcode_commons/
ansi_codes.rs

1#![expect(
2    clippy::let_underscore_must_use,
3    reason = "ANSI output cleanup intentionally ignores the best-effort flush result."
4)]
5
6//! Shared ANSI escape sequence constants and small builders for VT Code.
7//!
8//! See `docs/reference/ansi-in-vtcode.md` for the cross-crate integration map.
9
10use once_cell::sync::Lazy;
11use std::io::{IsTerminal, Write};
12
13use crate::utils::parse_bool_env_value;
14
15/// Env override that forces the HITL bell on or off.
16const BELL_ENV: &str = "VTCODE_HITL_BELL";
17
18/// Env override that selects the HITL notification mode.
19const NOTIFY_ENV: &str = "VTCODE_HITL_NOTIFY";
20
21/// Escape character as a raw byte (ESC = 0x1B = 27)
22pub const ESC_BYTE: u8 = 0x1b;
23
24/// Escape character as a `char`
25pub const ESC_CHAR: char = '\x1b';
26
27/// Escape character as a string slice
28pub const ESC: &str = "\x1b";
29
30/// Control Sequence Introducer (CSI = ESC[)
31pub const CSI: &str = "\x1b[";
32
33/// Operating System Command (OSC = ESC])
34pub const OSC: &str = "\x1b]";
35
36/// Device Control String (DCS = ESC P)
37pub const DCS: &str = "\x1bP";
38
39/// String Terminator (ST = ESC \)
40pub const ST: &str = "\x1b\\";
41
42/// Bell character as a raw byte (BEL = 0x07)
43pub(crate) const BEL_BYTE: u8 = 0x07;
44
45/// Bell character as a `char`
46pub const BEL_CHAR: char = '\x07';
47
48/// Bell character as a string slice
49const BEL: &str = "\x07";
50
51/// Notification preference (rich OSC vs bell-only)
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum HitlNotifyMode {
54    Off,
55    Bell,
56    Rich,
57}
58
59/// Terminal-specific notification capabilities
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TerminalNotifyKind {
62    BellOnly,
63    Osc9,
64    Osc777,
65}
66
67/// Explicit terminal notification transport override.
68#[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/// Play the terminal bell when enabled.
78#[inline]
79pub fn play_bell(enabled: bool) {
80    if !is_bell_enabled(enabled) {
81        return;
82    }
83    emit_bell();
84}
85
86/// Determine whether the bell should play, honoring an env override.
87///
88/// The override is read through the canonical tri-state boolean helper
89/// (`0|false|no|off` disable, `1|true|yes|on` enable, anything else keeps
90/// `default_enabled`), so `VTCODE_HITL_BELL=no` no longer counts as enabled.
91#[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 => {} // No-op
132        }
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            // Canonical falsy values (including `no`) switch notifications off;
145            // unknown values keep the lenient bell fallback.
146            _ 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    // Ghostty doesn't officially support OSC 9 or OSC 777 notifications
214    // Use bell-only to avoid "unknown error" messages
215    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        // Ghostty doesn't support OSC 9/777, use bell-only to avoid "unknown error"
255        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
287// === Reset ===
288pub const RESET: &str = "\x1b[0m";
289
290// === Text Styles ===
291pub 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
308// === Foreground Colors (30-37) ===
309pub 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
319// === Background Colors (40-47) ===
320pub 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
330// === Bright Foreground Colors (90-97) ===
331pub 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
340// === Bright Background Colors (100-107) ===
341pub 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
350// === Cursor Control ===
351pub 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
359// === Erase Functions ===
360pub 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
368// === Screen Modes ===
369pub 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
376// === Scroll Region ===
377/// Set Scrolling Region (DECSTBM) — CSI Ps ; Ps r
378pub const SCROLL_REGION_RESET: &str = "\x1b[r";
379
380// === Insert / Delete ===
381/// Insert Ps Line(s) (default = 1) (IL)
382pub const INSERT_LINE: &str = "\x1b[L";
383/// Delete Ps Line(s) (default = 1) (DL)
384pub const DELETE_LINE: &str = "\x1b[M";
385/// Insert Ps Character(s) (default = 1) (ICH)
386pub const INSERT_CHAR: &str = "\x1b[@";
387/// Delete Ps Character(s) (default = 1) (DCH)
388pub const DELETE_CHAR: &str = "\x1b[P";
389/// Erase Ps Character(s) (default = 1) (ECH)
390pub const ERASE_CHAR: &str = "\x1b[X";
391
392// === Scroll Control ===
393/// Scroll up Ps lines (default = 1) (SU)
394pub const SCROLL_UP: &str = "\x1b[S";
395/// Scroll down Ps lines (default = 1) (SD)
396pub const SCROLL_DOWN: &str = "\x1b[T";
397
398// === ESC-level Controls (C1 equivalents) ===
399/// Index — move cursor down one line, scroll if at bottom (IND)
400pub const INDEX: &str = "\x1bD";
401/// Next Line — move to first position of next line (NEL)
402pub const NEXT_LINE: &str = "\x1bE";
403/// Horizontal Tab Set (HTS)
404pub const TAB_SET: &str = "\x1bH";
405/// Reverse Index — move cursor up one line, scroll if at top (RI)
406pub const REVERSE_INDEX: &str = "\x1bM";
407/// Full Reset (RIS) — reset terminal to initial state
408pub const FULL_RESET: &str = "\x1bc";
409/// Application Keypad (DECPAM)
410pub const KEYPAD_APPLICATION: &str = "\x1b=";
411/// Normal Keypad (DECPNM)
412pub const KEYPAD_NUMERIC: &str = "\x1b>";
413
414// === Mouse Tracking Modes (DECSET/DECRST) ===
415/// X10 mouse reporting — button press only (mode 9)
416pub const MOUSE_X10_ENABLE: &str = "\x1b[?9h";
417pub const MOUSE_X10_DISABLE: &str = "\x1b[?9l";
418/// Normal mouse tracking — press and release (mode 1000)
419pub const MOUSE_NORMAL_ENABLE: &str = "\x1b[?1000h";
420pub const MOUSE_NORMAL_DISABLE: &str = "\x1b[?1000l";
421/// Button-event mouse tracking (mode 1002)
422pub const MOUSE_BUTTON_EVENT_ENABLE: &str = "\x1b[?1002h";
423pub const MOUSE_BUTTON_EVENT_DISABLE: &str = "\x1b[?1002l";
424/// Any-event mouse tracking (mode 1003)
425pub const MOUSE_ANY_EVENT_ENABLE: &str = "\x1b[?1003h";
426pub const MOUSE_ANY_EVENT_DISABLE: &str = "\x1b[?1003l";
427/// SGR extended mouse coordinates (mode 1006)
428pub const MOUSE_SGR_ENABLE: &str = "\x1b[?1006h";
429pub const MOUSE_SGR_DISABLE: &str = "\x1b[?1006l";
430/// URXVT extended mouse coordinates (mode 1015)
431pub const MOUSE_URXVT_ENABLE: &str = "\x1b[?1015h";
432pub const MOUSE_URXVT_DISABLE: &str = "\x1b[?1015l";
433
434// === Terminal Mode Controls (DECSET/DECRST) ===
435/// Bracketed Paste Mode (mode 2004)
436pub const BRACKETED_PASTE_ENABLE: &str = "\x1b[?2004h";
437pub const BRACKETED_PASTE_DISABLE: &str = "\x1b[?2004l";
438/// Focus Event Tracking (mode 1004)
439pub const FOCUS_EVENT_ENABLE: &str = "\x1b[?1004h";
440pub const FOCUS_EVENT_DISABLE: &str = "\x1b[?1004l";
441/// Synchronized Output (mode 2026) — batch rendering
442pub const SYNC_OUTPUT_BEGIN: &str = "\x1b[?2026h";
443pub const SYNC_OUTPUT_END: &str = "\x1b[?2026l";
444/// Application Cursor Keys (DECCKM, mode 1)
445pub const APP_CURSOR_KEYS_ENABLE: &str = "\x1b[?1h";
446pub const APP_CURSOR_KEYS_DISABLE: &str = "\x1b[?1l";
447/// Origin Mode (DECOM, mode 6)
448pub const ORIGIN_MODE_ENABLE: &str = "\x1b[?6h";
449pub const ORIGIN_MODE_DISABLE: &str = "\x1b[?6l";
450/// Auto-Wrap Mode (DECAWM, mode 7)
451pub const AUTO_WRAP_ENABLE: &str = "\x1b[?7h";
452pub const AUTO_WRAP_DISABLE: &str = "\x1b[?7l";
453
454// === Device Status / Attributes ===
455/// Primary Device Attributes (DA1) — request
456pub const DEVICE_ATTRIBUTES_REQUEST: &str = "\x1b[c";
457/// Device Status Report — request cursor position (DSR CPR)
458pub const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
459/// Device Status Report — request terminal status
460pub const DEVICE_STATUS_REQUEST: &str = "\x1b[5n";
461/// Contour VT extension — query the terminal's color-scheme mode (`CSI ? 996 n`).
462/// Supported terminals answer with a color-scheme report ([`COLOR_SCHEME_REPORT_PREFIX`]).
463pub const COLOR_SCHEME_MODE_REQUEST: &str = "\x1b[?996n";
464/// Contour VT extension — color-scheme report (`CSI ? 997 ; Ps n`), where
465/// `Ps` 1 means dark mode and `Ps` 2 means light mode.
466pub const COLOR_SCHEME_REPORT_PREFIX: &str = "\x1b[?997;";
467/// Contour VT extension — enable unsolicited color-scheme reports
468/// (`CSI ? 2031 h`); the terminal then emits [`COLOR_SCHEME_REPORT_PREFIX`]
469/// DSRs whenever its palette changes.
470pub const COLOR_SCHEME_REPORTS_ENABLE: &str = "\x1b[?2031h";
471/// Contour VT extension — disable unsolicited color-scheme reports (`CSI ? 2031 l`).
472pub const COLOR_SCHEME_REPORTS_DISABLE: &str = "\x1b[?2031l";
473/// Terminal visibility reports — query the current visibility state (`CSI ? 998 n`).
474/// Supported terminals answer with a visibility report ([`VISIBILITY_REPORT_PREFIX`]).
475/// See <https://rockorager.dev/misc/visibility-reports/>.
476pub const VISIBILITY_STATE_REQUEST: &str = "\x1b[?998n";
477/// Terminal visibility report (`CSI ? 999 ; Ps n`), where `Ps` 1 means
478/// potentially visible and `Ps` 2 means not visible. Unknown values must be
479/// treated as potentially visible (fail-visible).
480pub const VISIBILITY_REPORT_PREFIX: &str = "\x1b[?999;";
481/// Terminal visibility reports — enable unsolicited visibility reports
482/// (`CSI ? 2033 h`); the terminal then emits [`VISIBILITY_REPORT_PREFIX`]
483/// DSRs whenever the effective visibility state changes.
484pub const VISIBILITY_REPORTS_ENABLE: &str = "\x1b[?2033h";
485/// Terminal visibility reports — disable unsolicited visibility reports (`CSI ? 2033 l`).
486pub const VISIBILITY_REPORTS_DISABLE: &str = "\x1b[?2033l";
487
488// === OSC Sequences (Operating System Commands) ===
489/// Set window title — OSC 2 ; Pt BEL
490pub const OSC_SET_TITLE_PREFIX: &str = "\x1b]2;";
491/// Set icon name — OSC 1 ; Pt BEL
492pub const OSC_SET_ICON_PREFIX: &str = "\x1b]1;";
493/// Set icon name and title — OSC 0 ; Pt BEL
494pub const OSC_SET_ICON_AND_TITLE_PREFIX: &str = "\x1b]0;";
495/// Query/set foreground color — OSC 10
496pub const OSC_FG_COLOR_PREFIX: &str = "\x1b]10;";
497/// Query/set background color — OSC 11
498pub const OSC_BG_COLOR_PREFIX: &str = "\x1b]11;";
499/// Query/set cursor color — OSC 12
500pub const OSC_CURSOR_COLOR_PREFIX: &str = "\x1b]12;";
501/// Hyperlink — OSC 8
502const OSC_HYPERLINK_PREFIX: &str = "\x1b]8;";
503/// Clipboard access — OSC 52
504pub const OSC_CLIPBOARD_PREFIX: &str = "\x1b]52;";
505
506// === Character Set Designation (ISO 2022) ===
507/// Select UTF-8 character set
508pub const CHARSET_UTF8: &str = "\x1b%G";
509/// Select default (ISO 8859-1) character set
510pub const CHARSET_DEFAULT: &str = "\x1b%@";
511
512// === Helper Functions ===
513
514#[inline]
515pub fn cursor_up(n: u16) -> String {
516    format!("{CSI}{n}A")
517}
518
519#[inline]
520pub fn cursor_down(n: u16) -> String {
521    format!("{CSI}{n}B")
522}
523
524#[inline]
525pub fn cursor_right(n: u16) -> String {
526    format!("{CSI}{n}C")
527}
528
529#[inline]
530pub fn cursor_left(n: u16) -> String {
531    format!("{CSI}{n}D")
532}
533
534#[inline]
535pub fn cursor_to(row: u16, col: u16) -> String {
536    format!("{CSI}{row};{col}H")
537}
538
539/// Build a portable in-place redraw prefix (`CR` + `EL2`).
540///
541/// This is the common CLI pattern for one-line progress updates.
542const REDRAW_LINE_PREFIX: &str = "\r\x1b[2K";
543
544#[inline]
545fn redraw_line_prefix() -> &'static str {
546    REDRAW_LINE_PREFIX
547}
548
549/// Format a one-line in-place update payload.
550///
551/// Equivalent to: `\\r\\x1b[2K{content}`.
552#[inline]
553fn format_redraw_line(content: &str) -> String {
554    format!("{}{}", redraw_line_prefix(), content)
555}
556
557#[inline]
558pub fn fg_256(color_id: u8) -> String {
559    format!("{CSI}38;5;{color_id}m")
560}
561
562#[inline]
563pub fn bg_256(color_id: u8) -> String {
564    format!("{CSI}48;5;{color_id}m")
565}
566
567#[inline]
568pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
569    format!("{CSI}38;2;{r};{g};{b}m")
570}
571
572#[inline]
573pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
574    format!("{CSI}48;2;{r};{g};{b}m")
575}
576
577#[inline]
578pub fn colored(text: &str, color: &str) -> String {
579    format!("{color}{text}{RESET}")
580}
581
582#[inline]
583pub fn bold(text: &str) -> String {
584    format!("{BOLD}{text}{RESET_BOLD_DIM}")
585}
586
587#[inline]
588pub fn italic(text: &str) -> String {
589    format!("{ITALIC}{text}{RESET_ITALIC}")
590}
591
592#[inline]
593pub fn underline(text: &str) -> String {
594    format!("{UNDERLINE}{text}{RESET_UNDERLINE}")
595}
596
597#[inline]
598pub fn dim(text: &str) -> String {
599    format!("{DIM}{text}{RESET_BOLD_DIM}")
600}
601
602#[inline]
603pub fn combine_styles(text: &str, styles: &[&str]) -> String {
604    let mut result = String::with_capacity(text.len() + styles.len() * 10);
605    for style in styles {
606        result.push_str(style);
607    }
608    result.push_str(text);
609    result.push_str(RESET);
610    result
611}
612
613pub mod semantic {
614    use super::*;
615    pub const ERROR: &str = FG_BRIGHT_RED;
616    pub const SUCCESS: &str = FG_BRIGHT_GREEN;
617    pub const WARNING: &str = FG_BRIGHT_YELLOW;
618    pub const INFO: &str = FG_BRIGHT_CYAN;
619    pub const MUTED: &str = DIM;
620    pub const EMPHASIS: &str = BOLD;
621    pub const DEBUG: &str = FG_BRIGHT_BLACK;
622}
623
624#[inline]
625#[must_use]
626pub fn contains_ansi(text: &str) -> bool {
627    text.contains(ESC_CHAR)
628}
629
630#[inline]
631#[must_use]
632pub fn starts_with_ansi(text: &str) -> bool {
633    text.starts_with(ESC_CHAR)
634}
635
636#[inline]
637#[must_use]
638pub fn ends_with_ansi(text: &str) -> bool {
639    text.ends_with('m') && text.contains(ESC)
640}
641
642#[inline]
643#[must_use]
644pub fn display_width(text: &str) -> usize {
645    crate::preview::display_width(&crate::ansi::strip_ansi(text))
646}
647
648pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
649    let current_width = display_width(text);
650    if current_width >= width {
651        text.to_string()
652    } else {
653        let padding = pad_char.to_string().repeat(width - current_width);
654        format!("{text}{padding}")
655    }
656}
657
658pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
659    let stripped = crate::ansi::strip_ansi(text);
660    if crate::preview::display_width(&stripped) <= max_width {
661        return text.to_string();
662    }
663
664    let truncated_plain = crate::preview::truncate_with_ellipsis(&stripped, max_width, ellipsis);
665
666    if starts_with_ansi(text) {
667        let mut ansi_prefix = String::new();
668        for ch in text.chars() {
669            ansi_prefix.push(ch);
670            if ch == '\x1b' {
671                continue;
672            }
673            if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
674                break;
675            }
676        }
677        format!("{ansi_prefix}{truncated_plain}{RESET}")
678    } else {
679        truncated_plain
680    }
681}
682
683#[inline]
684pub fn write_styled<W: Write>(writer: &mut W, text: &str, style: &str) -> std::io::Result<()> {
685    writer.write_all(style.as_bytes())?;
686    writer.write_all(text.as_bytes())?;
687    writer.write_all(RESET.as_bytes())?;
688    Ok(())
689}
690
691#[inline]
692pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
693    buffer.push_str(style);
694    buffer.push_str(text);
695    buffer.push_str(RESET);
696}
697
698/// Set scrolling region (DECSTBM) — top and bottom rows (1-indexed)
699#[inline]
700pub fn set_scroll_region(top: u16, bottom: u16) -> String {
701    format!("{CSI}{top};{bottom}r")
702}
703
704/// Insert Ps lines at cursor position
705#[inline]
706pub fn insert_lines(n: u16) -> String {
707    format!("{CSI}{n}L")
708}
709
710/// Delete Ps lines at cursor position
711#[inline]
712pub fn delete_lines(n: u16) -> String {
713    format!("{CSI}{n}M")
714}
715
716/// Scroll up Ps lines
717#[inline]
718pub fn scroll_up(n: u16) -> String {
719    format!("{CSI}{n}S")
720}
721
722/// Scroll down Ps lines
723#[inline]
724pub fn scroll_down(n: u16) -> String {
725    format!("{CSI}{n}T")
726}
727
728/// Build an OSC sequence to set the terminal window title
729#[inline]
730pub fn set_window_title(title: &str) -> String {
731    format!("{OSC_SET_TITLE_PREFIX}{title}{BEL}")
732}
733
734/// Build an OSC sequence to set the terminal icon name.
735///
736/// This is the text label terminals show in taskbars, docks, and tab
737/// overviews (for example iTerm2, Windows Terminal, Kitty, Ghostty, and
738/// WezTerm). It pairs with [`set_window_title`]: emulators that keep icon
739/// and title separate honor `OSC 1`, while `OSC 0` aliases both.
740#[inline]
741pub fn set_icon_name(name: &str) -> String {
742    format!("{OSC_SET_ICON_PREFIX}{name}{BEL}")
743}
744
745/// Build combined icon (`OSC 1`) plus title (`OSC 2`) sequences.
746///
747/// Emitting both explicitly keeps the built-in profile/tab icon label in
748/// sync on emulators that distinguish icon from title, instead of relying
749/// on every emulator aliasing `OSC 0` the same way.
750#[inline]
751pub fn set_terminal_title_and_icon(title: &str) -> String {
752    format!("{}{}", set_icon_name(title), set_window_title(title))
753}
754
755/// Build an iTerm2 proprietary sequence that switches the current session
756/// to a named profile (`OSC 1337 ; SetProfile=`).
757///
758/// This is iTerm2-only: other terminals ignore the proprietary `1337`
759/// sequence. It is the only programmatic way to change the graphical tab
760/// icon, which iTerm2 takes from the session profile (no escape sequence
761/// can set profile artwork directly).
762#[inline]
763pub fn set_iterm2_profile(profile_name: &str) -> String {
764    format!("{OSC}1337;SetProfile={profile_name}{BEL}")
765}
766
767/// Build an OSC 8 hyperlink open sequence
768#[inline]
769pub fn hyperlink_open(url: &str) -> String {
770    format!("{OSC_HYPERLINK_PREFIX};{url}{ST}")
771}
772
773/// Build an OSC 8 hyperlink close sequence
774#[inline]
775pub fn hyperlink_close() -> String {
776    format!("{OSC_HYPERLINK_PREFIX};{ST}")
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    #[test]
784    fn redraw_prefix_matches_cli_pattern() {
785        assert_eq!(redraw_line_prefix(), "\r\x1b[2K");
786    }
787
788    #[test]
789    fn redraw_line_formats_expected_sequence() {
790        assert_eq!(format_redraw_line("Done"), "\r\x1b[2KDone");
791    }
792
793    #[test]
794    fn icon_and_title_payloads_use_distinct_osc_codes() {
795        assert_eq!(set_icon_name("VT"), "\x1b]1;VT\x07");
796        assert_eq!(set_window_title("Code"), "\x1b]2;Code\x07");
797    }
798
799    #[test]
800    fn terminal_title_and_icon_emits_both_sequences_in_order() {
801        let combined = set_terminal_title_and_icon("demo-project");
802        assert_eq!(combined, "\x1b]1;demo-project\x07\x1b]2;demo-project\x07");
803
804        let cleared = set_terminal_title_and_icon("");
805        assert_eq!(cleared, "\x1b]1;\x07\x1b]2;\x07");
806    }
807
808    #[test]
809    fn iterm2_profile_switch_uses_proprietary_sequence() {
810        assert_eq!(set_iterm2_profile("VT Code"), "\x1b]1337;SetProfile=VT Code\x07");
811        assert_eq!(set_iterm2_profile("Default"), "\x1b]1337;SetProfile=Default\x07");
812    }
813
814    #[test]
815    fn width_helpers_measure_cells_not_bytes_and_ignore_ansi() {
816        assert_eq!(display_width("\x1b[1m日本\x1b[0m"), 4);
817        // Two chars (4 bytes) fit a 3-cell budget; bytes would have truncated them.
818        assert_eq!(truncate_to_width("éé", 3, "…"), "éé");
819        assert_eq!(truncate_to_width("日本語", 5, "…"), "日本…");
820        assert_eq!(truncate_to_width("\x1b[1mabcdef\x1b[0m", 4, "…"), format!("\x1b[1mabc…{RESET}"));
821    }
822
823    #[test]
824    fn bell_override_uses_the_canonical_boolean_set() {
825        let guard = crate::env_lock::lock();
826        let previous = std::env::var_os(BELL_ENV);
827
828        for value in ["0", "false", "no", "NO", " off "] {
829            guard.set_var(BELL_ENV, value);
830            assert!(!is_bell_enabled(true), "{value:?} should disable the bell");
831        }
832
833        for value in ["1", "true", "yes", "on"] {
834            guard.set_var(BELL_ENV, value);
835            assert!(is_bell_enabled(false), "{value:?} should enable the bell");
836        }
837
838        // Unknown values behave like an unset variable and keep the caller default.
839        for value in ["debug", "maybe"] {
840            guard.set_var(BELL_ENV, value);
841            assert!(is_bell_enabled(true), "{value:?} should keep the default on");
842            assert!(!is_bell_enabled(false), "{value:?} should keep the default off");
843        }
844
845        guard.remove_var(BELL_ENV);
846        assert!(is_bell_enabled(true));
847        assert!(!is_bell_enabled(false));
848
849        guard.restore_var(BELL_ENV, previous);
850    }
851
852    #[test]
853    fn notify_mode_override_honors_canonical_falsy_values() {
854        let guard = crate::env_lock::lock();
855        let previous = std::env::var_os(NOTIFY_ENV);
856
857        for value in ["off", "0", "false", "no", "NO"] {
858            guard.set_var(NOTIFY_ENV, value);
859            assert_eq!(hitl_notify_mode(true), HitlNotifyMode::Off, "{value:?} should disable notifications");
860        }
861
862        guard.set_var(NOTIFY_ENV, "bell");
863        assert_eq!(hitl_notify_mode(true), HitlNotifyMode::Bell);
864
865        for value in ["rich", "osc", "notify"] {
866            guard.set_var(NOTIFY_ENV, value);
867            assert_eq!(hitl_notify_mode(false), HitlNotifyMode::Rich, "{value:?} should request rich notifications");
868        }
869
870        guard.remove_var(NOTIFY_ENV);
871        assert_eq!(hitl_notify_mode(true), HitlNotifyMode::Rich);
872        assert_eq!(hitl_notify_mode(false), HitlNotifyMode::Off);
873
874        guard.restore_var(NOTIFY_ENV, previous);
875    }
876}