Skip to main content

vtcode_commons/
ansi_codes.rs

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