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