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