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
13/// Escape character as a raw byte (ESC = 0x1B = 27)
14pub const ESC_BYTE: u8 = 0x1b;
15
16/// Escape character as a `char`
17pub const ESC_CHAR: char = '\x1b';
18
19/// Escape character as a string slice
20pub const ESC: &str = "\x1b";
21
22/// Control Sequence Introducer (CSI = ESC[)
23pub const CSI: &str = "\x1b[";
24
25/// Operating System Command (OSC = ESC])
26pub const OSC: &str = "\x1b]";
27
28/// Device Control String (DCS = ESC P)
29pub const DCS: &str = "\x1bP";
30
31/// String Terminator (ST = ESC \)
32pub const ST: &str = "\x1b\\";
33
34/// Bell character as a raw byte (BEL = 0x07)
35pub(crate) const BEL_BYTE: u8 = 0x07;
36
37/// Bell character as a `char`
38pub const BEL_CHAR: char = '\x07';
39
40/// Bell character as a string slice
41const BEL: &str = "\x07";
42
43/// Notification preference (rich OSC vs bell-only)
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum HitlNotifyMode {
46    Off,
47    Bell,
48    Rich,
49}
50
51/// Terminal-specific notification capabilities
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum TerminalNotifyKind {
54    BellOnly,
55    Osc9,
56    Osc777,
57}
58
59/// Explicit terminal notification transport override.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum NotifyMethodOverride {
62    Auto,
63    Bell,
64    Osc9,
65}
66
67static DETECTED_NOTIFY_KIND: Lazy<TerminalNotifyKind> = Lazy::new(detect_terminal_notify_kind);
68
69/// Play the terminal bell when enabled.
70#[inline]
71pub fn play_bell(enabled: bool) {
72    if !is_bell_enabled(enabled) {
73        return;
74    }
75    emit_bell();
76}
77
78/// Determine whether the bell should play, honoring an env override.
79#[inline]
80fn is_bell_enabled(default_enabled: bool) -> bool {
81    if let Ok(val) = std::env::var("VTCODE_HITL_BELL") {
82        return !matches!(val.trim().to_ascii_lowercase().as_str(), "false" | "0" | "off");
83    }
84    default_enabled
85}
86
87#[inline]
88fn emit_bell() {
89    print!("{BEL}");
90    let _ = std::io::stdout().flush();
91}
92
93#[inline]
94pub fn notify_attention(default_enabled: bool, message: Option<&str>) {
95    notify_attention_with_mode(default_enabled, message, NotifyMethodOverride::Auto);
96}
97
98#[inline]
99pub fn notify_attention_with_mode(default_enabled: bool, message: Option<&str>, method: NotifyMethodOverride) {
100    if !is_bell_enabled(default_enabled) {
101        return;
102    }
103
104    if !std::io::stdout().is_terminal() {
105        return;
106    }
107
108    let mode = hitl_notify_mode(default_enabled);
109    if matches!(mode, HitlNotifyMode::Off) {
110        return;
111    }
112
113    if matches!(mode, HitlNotifyMode::Rich) {
114        let notify_kind = match method {
115            NotifyMethodOverride::Auto => *DETECTED_NOTIFY_KIND,
116            NotifyMethodOverride::Bell => TerminalNotifyKind::BellOnly,
117            NotifyMethodOverride::Osc9 => TerminalNotifyKind::Osc9,
118        };
119        match notify_kind {
120            TerminalNotifyKind::Osc9 => send_osc9_notification(message),
121            TerminalNotifyKind::Osc777 => send_osc777_notification(message),
122            TerminalNotifyKind::BellOnly => {} // No-op
123        }
124    }
125
126    emit_bell();
127}
128
129fn hitl_notify_mode(default_enabled: bool) -> HitlNotifyMode {
130    if let Ok(raw) = std::env::var("VTCODE_HITL_NOTIFY") {
131        let v = raw.trim().to_ascii_lowercase();
132        return match v.as_str() {
133            "off" | "0" | "false" => HitlNotifyMode::Off,
134            "bell" => HitlNotifyMode::Bell,
135            "rich" | "osc" | "notify" => HitlNotifyMode::Rich,
136            _ => HitlNotifyMode::Bell,
137        };
138    }
139
140    if default_enabled {
141        HitlNotifyMode::Rich
142    } else {
143        HitlNotifyMode::Off
144    }
145}
146
147fn detect_terminal_notify_kind() -> TerminalNotifyKind {
148    if let Ok(explicit_kind) = std::env::var("VTCODE_NOTIFY_KIND") {
149        let explicit = explicit_kind.trim().to_ascii_lowercase();
150        return match explicit.as_str() {
151            "osc9" => TerminalNotifyKind::Osc9,
152            "osc777" => TerminalNotifyKind::Osc777,
153            "bell" | "off" => TerminalNotifyKind::BellOnly,
154            _ => TerminalNotifyKind::BellOnly,
155        };
156    }
157
158    let term = std::env::var("TERM").unwrap_or_default().to_ascii_lowercase();
159    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default().to_ascii_lowercase();
160    let has_kitty = std::env::var("KITTY_WINDOW_ID").is_ok();
161    let has_iterm = std::env::var("ITERM_SESSION_ID").is_ok();
162    let has_wezterm = std::env::var("WEZTERM_PANE").is_ok();
163    let has_vte = std::env::var("VTE_VERSION").is_ok();
164
165    detect_terminal_notify_kind_from(&term, &term_program, has_kitty, has_iterm, has_wezterm, has_vte)
166}
167
168fn send_osc777_notification(message: Option<&str>) {
169    let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
170    let title = sanitize_notification_text("VT Code");
171    let payload = build_osc777_payload(&title, &body);
172    print!("{payload}{BEL}");
173    let _ = std::io::stdout().flush();
174}
175
176fn send_osc9_notification(message: Option<&str>) {
177    let body = sanitize_notification_text(message.unwrap_or("Human approval required"));
178    let payload = build_osc9_payload(&body);
179    print!("{payload}{BEL}");
180    let _ = std::io::stdout().flush();
181}
182
183fn sanitize_notification_text(raw: &str) -> String {
184    const MAX_LEN: usize = 200;
185    let mut cleaned = raw.chars().filter(|c| *c >= ' ' && *c != '\u{007f}').collect::<String>();
186    if cleaned.len() > MAX_LEN {
187        cleaned.truncate(MAX_LEN);
188    }
189    cleaned.replace(';', ":")
190}
191
192fn detect_terminal_notify_kind_from(
193    term: &str,
194    term_program: &str,
195    has_kitty: bool,
196    has_iterm: bool,
197    has_wezterm: bool,
198    has_vte: bool,
199) -> TerminalNotifyKind {
200    if term.contains("kitty") || has_kitty {
201        return TerminalNotifyKind::Osc777;
202    }
203
204    // Ghostty doesn't officially support OSC 9 or OSC 777 notifications
205    // Use bell-only to avoid "unknown error" messages
206    if term_program.contains("ghostty") {
207        return TerminalNotifyKind::BellOnly;
208    }
209
210    if term_program.contains("iterm")
211        || term_program.contains("wezterm")
212        || term_program.contains("warp")
213        || term_program.contains("apple_terminal")
214        || has_iterm
215        || has_wezterm
216    {
217        return TerminalNotifyKind::Osc9;
218    }
219
220    if has_vte {
221        return TerminalNotifyKind::Osc777;
222    }
223
224    TerminalNotifyKind::BellOnly
225}
226
227fn build_osc777_payload(title: &str, body: &str) -> String {
228    format!("{OSC}777;notify;{title};{body}")
229}
230
231fn build_osc9_payload(body: &str) -> String {
232    format!("{OSC}9;{body}")
233}
234
235#[cfg(test)]
236mod redraw_tests {
237    use super::*;
238
239    #[test]
240    fn terminal_mapping_is_deterministic() {
241        assert_eq!(
242            detect_terminal_notify_kind_from("xterm-kitty", "", false, false, false, false),
243            TerminalNotifyKind::Osc777
244        );
245        // Ghostty doesn't support OSC 9/777, use bell-only to avoid "unknown error"
246        assert_eq!(
247            detect_terminal_notify_kind_from("xterm-ghostty", "ghostty", false, false, false, false),
248            TerminalNotifyKind::BellOnly
249        );
250        assert_eq!(
251            detect_terminal_notify_kind_from("xterm-256color", "wezterm", false, false, false, false),
252            TerminalNotifyKind::Osc9
253        );
254        assert_eq!(
255            detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, true),
256            TerminalNotifyKind::Osc777
257        );
258        assert_eq!(
259            detect_terminal_notify_kind_from("xterm-256color", "", false, false, false, false),
260            TerminalNotifyKind::BellOnly
261        );
262    }
263
264    #[test]
265    fn osc_payload_format_is_stable() {
266        assert_eq!(build_osc9_payload("done"), format!("{OSC}9;done"));
267        assert_eq!(build_osc777_payload("VT Code", "finished"), format!("{OSC}777;notify;VT Code;finished"));
268    }
269}
270
271// === Reset ===
272pub const RESET: &str = "\x1b[0m";
273
274// === Text Styles ===
275pub const BOLD: &str = "\x1b[1m";
276pub const DIM: &str = "\x1b[2m";
277pub const ITALIC: &str = "\x1b[3m";
278pub const UNDERLINE: &str = "\x1b[4m";
279pub const BLINK: &str = "\x1b[5m";
280pub const REVERSE: &str = "\x1b[7m";
281pub const HIDDEN: &str = "\x1b[8m";
282pub const STRIKETHROUGH: &str = "\x1b[9m";
283
284pub const RESET_BOLD_DIM: &str = "\x1b[22m";
285pub const RESET_ITALIC: &str = "\x1b[23m";
286pub const RESET_UNDERLINE: &str = "\x1b[24m";
287pub const RESET_BLINK: &str = "\x1b[25m";
288pub const RESET_REVERSE: &str = "\x1b[27m";
289pub const RESET_HIDDEN: &str = "\x1b[28m";
290pub const RESET_STRIKETHROUGH: &str = "\x1b[29m";
291
292// === Foreground Colors (30-37) ===
293pub const FG_BLACK: &str = "\x1b[30m";
294pub const FG_RED: &str = "\x1b[31m";
295pub const FG_GREEN: &str = "\x1b[32m";
296pub const FG_YELLOW: &str = "\x1b[33m";
297pub const FG_BLUE: &str = "\x1b[34m";
298pub const FG_MAGENTA: &str = "\x1b[35m";
299pub const FG_CYAN: &str = "\x1b[36m";
300pub const FG_WHITE: &str = "\x1b[37m";
301pub const FG_DEFAULT: &str = "\x1b[39m";
302
303// === Background Colors (40-47) ===
304pub const BG_BLACK: &str = "\x1b[40m";
305pub const BG_RED: &str = "\x1b[41m";
306pub const BG_GREEN: &str = "\x1b[42m";
307pub const BG_YELLOW: &str = "\x1b[43m";
308pub const BG_BLUE: &str = "\x1b[44m";
309pub const BG_MAGENTA: &str = "\x1b[45m";
310pub const BG_CYAN: &str = "\x1b[46m";
311pub const BG_WHITE: &str = "\x1b[47m";
312pub const BG_DEFAULT: &str = "\x1b[49m";
313
314// === Bright Foreground Colors (90-97) ===
315pub const FG_BRIGHT_BLACK: &str = "\x1b[90m";
316pub const FG_BRIGHT_RED: &str = "\x1b[91m";
317pub const FG_BRIGHT_GREEN: &str = "\x1b[92m";
318pub const FG_BRIGHT_YELLOW: &str = "\x1b[93m";
319pub const FG_BRIGHT_BLUE: &str = "\x1b[94m";
320pub const FG_BRIGHT_MAGENTA: &str = "\x1b[95m";
321pub const FG_BRIGHT_CYAN: &str = "\x1b[96m";
322pub const FG_BRIGHT_WHITE: &str = "\x1b[97m";
323
324// === Bright Background Colors (100-107) ===
325pub const BG_BRIGHT_BLACK: &str = "\x1b[100m";
326pub const BG_BRIGHT_RED: &str = "\x1b[101m";
327pub const BG_BRIGHT_GREEN: &str = "\x1b[102m";
328pub const BG_BRIGHT_YELLOW: &str = "\x1b[103m";
329pub const BG_BRIGHT_BLUE: &str = "\x1b[104m";
330pub const BG_BRIGHT_MAGENTA: &str = "\x1b[105m";
331pub const BG_BRIGHT_CYAN: &str = "\x1b[106m";
332pub const BG_BRIGHT_WHITE: &str = "\x1b[107m";
333
334// === Cursor Control ===
335pub const CURSOR_HOME: &str = "\x1b[H";
336pub const CURSOR_HIDE: &str = "\x1b[?25l";
337pub const CURSOR_SHOW: &str = "\x1b[?25h";
338pub const CURSOR_SAVE_DEC: &str = "\x1b7";
339pub const CURSOR_RESTORE_DEC: &str = "\x1b8";
340pub const CURSOR_SAVE_SCO: &str = "\x1b[s";
341pub const CURSOR_RESTORE_SCO: &str = "\x1b[u";
342
343// === Erase Functions ===
344pub const CLEAR_SCREEN: &str = "\x1b[2J";
345pub const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
346pub const CLEAR_TO_START_OF_SCREEN: &str = "\x1b[1J";
347pub const CLEAR_SAVED_LINES: &str = "\x1b[3J";
348pub const CLEAR_LINE: &str = "\x1b[2K";
349pub const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
350pub const CLEAR_TO_START_OF_LINE: &str = "\x1b[1K";
351
352// === Screen Modes ===
353pub const ALT_BUFFER_ENABLE: &str = "\x1b[?1049h";
354pub const ALT_BUFFER_DISABLE: &str = "\x1b[?1049l";
355pub const SCREEN_SAVE: &str = "\x1b[?47h";
356pub const SCREEN_RESTORE: &str = "\x1b[?47l";
357pub const LINE_WRAP_ENABLE: &str = "\x1b[=7h";
358pub const LINE_WRAP_DISABLE: &str = "\x1b[=7l";
359
360// === Scroll Region ===
361/// Set Scrolling Region (DECSTBM) — CSI Ps ; Ps r
362pub const SCROLL_REGION_RESET: &str = "\x1b[r";
363
364// === Insert / Delete ===
365/// Insert Ps Line(s) (default = 1) (IL)
366pub const INSERT_LINE: &str = "\x1b[L";
367/// Delete Ps Line(s) (default = 1) (DL)
368pub const DELETE_LINE: &str = "\x1b[M";
369/// Insert Ps Character(s) (default = 1) (ICH)
370pub const INSERT_CHAR: &str = "\x1b[@";
371/// Delete Ps Character(s) (default = 1) (DCH)
372pub const DELETE_CHAR: &str = "\x1b[P";
373/// Erase Ps Character(s) (default = 1) (ECH)
374pub const ERASE_CHAR: &str = "\x1b[X";
375
376// === Scroll Control ===
377/// Scroll up Ps lines (default = 1) (SU)
378pub const SCROLL_UP: &str = "\x1b[S";
379/// Scroll down Ps lines (default = 1) (SD)
380pub const SCROLL_DOWN: &str = "\x1b[T";
381
382// === ESC-level Controls (C1 equivalents) ===
383/// Index — move cursor down one line, scroll if at bottom (IND)
384pub const INDEX: &str = "\x1bD";
385/// Next Line — move to first position of next line (NEL)
386pub const NEXT_LINE: &str = "\x1bE";
387/// Horizontal Tab Set (HTS)
388pub const TAB_SET: &str = "\x1bH";
389/// Reverse Index — move cursor up one line, scroll if at top (RI)
390pub const REVERSE_INDEX: &str = "\x1bM";
391/// Full Reset (RIS) — reset terminal to initial state
392pub const FULL_RESET: &str = "\x1bc";
393/// Application Keypad (DECPAM)
394pub const KEYPAD_APPLICATION: &str = "\x1b=";
395/// Normal Keypad (DECPNM)
396pub const KEYPAD_NUMERIC: &str = "\x1b>";
397
398// === Mouse Tracking Modes (DECSET/DECRST) ===
399/// X10 mouse reporting — button press only (mode 9)
400pub const MOUSE_X10_ENABLE: &str = "\x1b[?9h";
401pub const MOUSE_X10_DISABLE: &str = "\x1b[?9l";
402/// Normal mouse tracking — press and release (mode 1000)
403pub const MOUSE_NORMAL_ENABLE: &str = "\x1b[?1000h";
404pub const MOUSE_NORMAL_DISABLE: &str = "\x1b[?1000l";
405/// Button-event mouse tracking (mode 1002)
406pub const MOUSE_BUTTON_EVENT_ENABLE: &str = "\x1b[?1002h";
407pub const MOUSE_BUTTON_EVENT_DISABLE: &str = "\x1b[?1002l";
408/// Any-event mouse tracking (mode 1003)
409pub const MOUSE_ANY_EVENT_ENABLE: &str = "\x1b[?1003h";
410pub const MOUSE_ANY_EVENT_DISABLE: &str = "\x1b[?1003l";
411/// SGR extended mouse coordinates (mode 1006)
412pub const MOUSE_SGR_ENABLE: &str = "\x1b[?1006h";
413pub const MOUSE_SGR_DISABLE: &str = "\x1b[?1006l";
414/// URXVT extended mouse coordinates (mode 1015)
415pub const MOUSE_URXVT_ENABLE: &str = "\x1b[?1015h";
416pub const MOUSE_URXVT_DISABLE: &str = "\x1b[?1015l";
417
418// === Terminal Mode Controls (DECSET/DECRST) ===
419/// Bracketed Paste Mode (mode 2004)
420pub const BRACKETED_PASTE_ENABLE: &str = "\x1b[?2004h";
421pub const BRACKETED_PASTE_DISABLE: &str = "\x1b[?2004l";
422/// Focus Event Tracking (mode 1004)
423pub const FOCUS_EVENT_ENABLE: &str = "\x1b[?1004h";
424pub const FOCUS_EVENT_DISABLE: &str = "\x1b[?1004l";
425/// Synchronized Output (mode 2026) — batch rendering
426pub const SYNC_OUTPUT_BEGIN: &str = "\x1b[?2026h";
427pub const SYNC_OUTPUT_END: &str = "\x1b[?2026l";
428/// Application Cursor Keys (DECCKM, mode 1)
429pub const APP_CURSOR_KEYS_ENABLE: &str = "\x1b[?1h";
430pub const APP_CURSOR_KEYS_DISABLE: &str = "\x1b[?1l";
431/// Origin Mode (DECOM, mode 6)
432pub const ORIGIN_MODE_ENABLE: &str = "\x1b[?6h";
433pub const ORIGIN_MODE_DISABLE: &str = "\x1b[?6l";
434/// Auto-Wrap Mode (DECAWM, mode 7)
435pub const AUTO_WRAP_ENABLE: &str = "\x1b[?7h";
436pub const AUTO_WRAP_DISABLE: &str = "\x1b[?7l";
437
438// === Device Status / Attributes ===
439/// Primary Device Attributes (DA1) — request
440pub const DEVICE_ATTRIBUTES_REQUEST: &str = "\x1b[c";
441/// Device Status Report — request cursor position (DSR CPR)
442pub const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
443/// Device Status Report — request terminal status
444pub const DEVICE_STATUS_REQUEST: &str = "\x1b[5n";
445
446// === OSC Sequences (Operating System Commands) ===
447/// Set window title — OSC 2 ; Pt BEL
448pub const OSC_SET_TITLE_PREFIX: &str = "\x1b]2;";
449/// Set icon name — OSC 1 ; Pt BEL
450pub const OSC_SET_ICON_PREFIX: &str = "\x1b]1;";
451/// Set icon name and title — OSC 0 ; Pt BEL
452pub const OSC_SET_ICON_AND_TITLE_PREFIX: &str = "\x1b]0;";
453/// Query/set foreground color — OSC 10
454pub const OSC_FG_COLOR_PREFIX: &str = "\x1b]10;";
455/// Query/set background color — OSC 11
456pub const OSC_BG_COLOR_PREFIX: &str = "\x1b]11;";
457/// Query/set cursor color — OSC 12
458pub const OSC_CURSOR_COLOR_PREFIX: &str = "\x1b]12;";
459/// Hyperlink — OSC 8
460const OSC_HYPERLINK_PREFIX: &str = "\x1b]8;";
461/// Clipboard access — OSC 52
462pub const OSC_CLIPBOARD_PREFIX: &str = "\x1b]52;";
463
464// === Character Set Designation (ISO 2022) ===
465/// Select UTF-8 character set
466pub const CHARSET_UTF8: &str = "\x1b%G";
467/// Select default (ISO 8859-1) character set
468pub const CHARSET_DEFAULT: &str = "\x1b%@";
469
470// === Helper Functions ===
471
472#[inline]
473pub fn cursor_up(n: u16) -> String {
474    format!("{CSI}{n}A")
475}
476
477#[inline]
478pub fn cursor_down(n: u16) -> String {
479    format!("{CSI}{n}B")
480}
481
482#[inline]
483pub fn cursor_right(n: u16) -> String {
484    format!("{CSI}{n}C")
485}
486
487#[inline]
488pub fn cursor_left(n: u16) -> String {
489    format!("{CSI}{n}D")
490}
491
492#[inline]
493pub fn cursor_to(row: u16, col: u16) -> String {
494    format!("{CSI}{row};{col}H")
495}
496
497/// Build a portable in-place redraw prefix (`CR` + `EL2`).
498///
499/// This is the common CLI pattern for one-line progress updates.
500const REDRAW_LINE_PREFIX: &str = "\r\x1b[2K";
501
502#[inline]
503fn redraw_line_prefix() -> &'static str {
504    REDRAW_LINE_PREFIX
505}
506
507/// Format a one-line in-place update payload.
508///
509/// Equivalent to: `\\r\\x1b[2K{content}`.
510#[inline]
511fn format_redraw_line(content: &str) -> String {
512    format!("{}{}", redraw_line_prefix(), content)
513}
514
515#[inline]
516pub fn fg_256(color_id: u8) -> String {
517    format!("{CSI}38;5;{color_id}m")
518}
519
520#[inline]
521pub fn bg_256(color_id: u8) -> String {
522    format!("{CSI}48;5;{color_id}m")
523}
524
525#[inline]
526pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
527    format!("{CSI}38;2;{r};{g};{b}m")
528}
529
530#[inline]
531pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
532    format!("{CSI}48;2;{r};{g};{b}m")
533}
534
535#[inline]
536pub fn colored(text: &str, color: &str) -> String {
537    format!("{color}{text}{RESET}")
538}
539
540#[inline]
541pub fn bold(text: &str) -> String {
542    format!("{BOLD}{text}{RESET_BOLD_DIM}")
543}
544
545#[inline]
546pub fn italic(text: &str) -> String {
547    format!("{ITALIC}{text}{RESET_ITALIC}")
548}
549
550#[inline]
551pub fn underline(text: &str) -> String {
552    format!("{UNDERLINE}{text}{RESET_UNDERLINE}")
553}
554
555#[inline]
556pub fn dim(text: &str) -> String {
557    format!("{DIM}{text}{RESET_BOLD_DIM}")
558}
559
560#[inline]
561pub fn combine_styles(text: &str, styles: &[&str]) -> String {
562    let mut result = String::with_capacity(text.len() + styles.len() * 10);
563    for style in styles {
564        result.push_str(style);
565    }
566    result.push_str(text);
567    result.push_str(RESET);
568    result
569}
570
571pub mod semantic {
572    use super::*;
573    pub const ERROR: &str = FG_BRIGHT_RED;
574    pub const SUCCESS: &str = FG_BRIGHT_GREEN;
575    pub const WARNING: &str = FG_BRIGHT_YELLOW;
576    pub const INFO: &str = FG_BRIGHT_CYAN;
577    pub const MUTED: &str = DIM;
578    pub const EMPHASIS: &str = BOLD;
579    pub const DEBUG: &str = FG_BRIGHT_BLACK;
580}
581
582#[inline]
583#[must_use]
584pub fn contains_ansi(text: &str) -> bool {
585    text.contains(ESC_CHAR)
586}
587
588#[inline]
589#[must_use]
590pub fn starts_with_ansi(text: &str) -> bool {
591    text.starts_with(ESC_CHAR)
592}
593
594#[inline]
595#[must_use]
596pub fn ends_with_ansi(text: &str) -> bool {
597    text.ends_with('m') && text.contains(ESC)
598}
599
600#[inline]
601#[must_use]
602pub fn display_width(text: &str) -> usize {
603    crate::ansi::strip_ansi(text).len()
604}
605
606pub fn pad_to_width(text: &str, width: usize, pad_char: char) -> String {
607    let current_width = display_width(text);
608    if current_width >= width {
609        text.to_string()
610    } else {
611        let padding = pad_char.to_string().repeat(width - current_width);
612        format!("{text}{padding}")
613    }
614}
615
616pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str) -> String {
617    let stripped = crate::ansi::strip_ansi(text);
618    if stripped.len() <= max_width {
619        return text.to_string();
620    }
621
622    let truncate_at = max_width.saturating_sub(ellipsis.len());
623    let truncated_plain: String = stripped.chars().take(truncate_at).collect();
624
625    if starts_with_ansi(text) {
626        let mut ansi_prefix = String::new();
627        for ch in text.chars() {
628            ansi_prefix.push(ch);
629            if ch == '\x1b' {
630                continue;
631            }
632            if ch.is_alphabetic() && ansi_prefix.contains('\x1b') {
633                break;
634            }
635        }
636        format!("{ansi_prefix}{truncated_plain}{ellipsis}{RESET}")
637    } else {
638        format!("{truncated_plain}{ellipsis}")
639    }
640}
641
642#[inline]
643pub fn write_styled<W: Write>(writer: &mut W, text: &str, style: &str) -> std::io::Result<()> {
644    writer.write_all(style.as_bytes())?;
645    writer.write_all(text.as_bytes())?;
646    writer.write_all(RESET.as_bytes())?;
647    Ok(())
648}
649
650#[inline]
651pub fn format_styled_into(buffer: &mut String, text: &str, style: &str) {
652    buffer.push_str(style);
653    buffer.push_str(text);
654    buffer.push_str(RESET);
655}
656
657/// Set scrolling region (DECSTBM) — top and bottom rows (1-indexed)
658#[inline]
659pub fn set_scroll_region(top: u16, bottom: u16) -> String {
660    format!("{CSI}{top};{bottom}r")
661}
662
663/// Insert Ps lines at cursor position
664#[inline]
665pub fn insert_lines(n: u16) -> String {
666    format!("{CSI}{n}L")
667}
668
669/// Delete Ps lines at cursor position
670#[inline]
671pub fn delete_lines(n: u16) -> String {
672    format!("{CSI}{n}M")
673}
674
675/// Scroll up Ps lines
676#[inline]
677pub fn scroll_up(n: u16) -> String {
678    format!("{CSI}{n}S")
679}
680
681/// Scroll down Ps lines
682#[inline]
683pub fn scroll_down(n: u16) -> String {
684    format!("{CSI}{n}T")
685}
686
687/// Build an OSC sequence to set the terminal window title
688#[inline]
689pub fn set_window_title(title: &str) -> String {
690    format!("{OSC_SET_TITLE_PREFIX}{title}{BEL}")
691}
692
693/// Build an OSC 8 hyperlink open sequence
694#[inline]
695pub fn hyperlink_open(url: &str) -> String {
696    format!("{OSC_HYPERLINK_PREFIX};{url}{ST}")
697}
698
699/// Build an OSC 8 hyperlink close sequence
700#[inline]
701pub fn hyperlink_close() -> String {
702    format!("{OSC_HYPERLINK_PREFIX};{ST}")
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn redraw_prefix_matches_cli_pattern() {
711        assert_eq!(redraw_line_prefix(), "\r\x1b[2K");
712    }
713
714    #[test]
715    fn redraw_line_formats_expected_sequence() {
716        assert_eq!(format_redraw_line("Done"), "\r\x1b[2KDone");
717    }
718}