//! Terminal-title output helpers for the TUI. //! //! This module owns the low-level OSC title write path and the sanitization //! that happens immediately before we emit it. It is intentionally narrow: //! callers decide when the title should change and whether an empty title means //! "leave the old title alone" or "clear the title Codex last wrote". //! This module does not attempt to read or restore the terminal's previous //! title because that is not portable across terminals. //! //! Sanitization is necessary because title content is assembled from untrusted //! text sources such as model output, thread names, project paths, and config. //! Before we place that text inside an OSC sequence, we strip: //! - control characters that could terminate or reshape the escape sequence //! - bidi/invisible formatting codepoints that can visually reorder or hide //! text (the same family of issues discussed in Trojan Source writeups) //! - redundant whitespace that would make titles noisy or hard to scan use std::fmt; use std::io; use std::io::IsTerminal; use std::io::stdout; use crossterm::Command; use ratatui::crossterm::execute; /// Practical upper bound on title length, measured in Rust `char`s. /// /// Most terminals silently truncate titles beyond a few hundred characters. /// 240 leaves headroom for the OSC framing bytes while keeping titles /// readable in tab bars and window managers. const MAX_TERMINAL_TITLE_CHARS: usize = 240; /// Outcome of a [`set_terminal_title`] call. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) enum SetTerminalTitleResult { /// A sanitized title was written, or stdout is not a terminal so no write was needed. Applied, /// Sanitization removed every visible character, so no title was emitted. /// /// This is distinct from clearing the title. Callers decide whether an /// empty post-sanitization value should result in no-op behavior, clearing /// the title Codex manages, or some other fallback. NoVisibleContent, } /// Writes a sanitized OSC window-title sequence to stdout. /// /// The input is treated as untrusted display text: control characters, /// invisible formatting characters, and redundant whitespace are removed before /// the title is emitted. If sanitization removes all visible content, the /// function returns [`SetTerminalTitleResult::NoVisibleContent`] instead of /// clearing the title because clearing and restoring are policy decisions for /// higher-level callers. Mechanically, sanitization collapses whitespace runs /// to single spaces, drops disallowed codepoints, and bounds the result to /// [`MAX_TERMINAL_TITLE_CHARS`] visible characters before writing OSC 0. pub(crate) fn set_terminal_title(title: &str) -> io::Result { if !stdout().is_terminal() { return Ok(SetTerminalTitleResult::Applied); } let title = sanitize_terminal_title(title); if title.is_empty() { return Ok(SetTerminalTitleResult::NoVisibleContent); } execute!(stdout(), SetWindowTitle(title))?; Ok(SetTerminalTitleResult::Applied) } /// Clears the current terminal title by writing an empty OSC title payload. /// /// This clears the visible title; it does not restore whatever title the shell /// or a previous program may have set before Codex started managing the title. pub(crate) fn clear_terminal_title() -> io::Result<()> { if !stdout().is_terminal() { return Ok(()); } execute!(stdout(), SetWindowTitle(String::new())) } #[derive(Debug, Clone)] struct SetWindowTitle(String); impl Command for SetWindowTitle { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { // xterm/ctlseqs documents OSC 0/2 title sequences with ST (ESC \) termination. // Most terminals also accept BEL for compatibility, but ST is the canonical form. write!(f, "\x1b]0;{}\x1b\\", self.0) } #[cfg(windows)] fn execute_winapi(&self) -> io::Result<()> { Err(std::io::Error::other( "tried to execute SetWindowTitle using WinAPI; use ANSI instead", )) } #[cfg(windows)] fn is_ansi_code_supported(&self) -> bool { true } } /// Normalizes untrusted title text into a single bounded display line. /// /// This removes terminal control characters, strips invisible/bidi formatting /// characters, collapses any whitespace run into a single ASCII space, and /// truncates after [`MAX_TERMINAL_TITLE_CHARS`] emitted characters. fn sanitize_terminal_title(title: &str) -> String { let mut sanitized = String::new(); let mut chars_written = 0; let mut pending_space = false; for ch in title.chars() { if ch.is_whitespace() { // Only set pending if we've already written content; this // strips leading whitespace without an extra trim pass. pending_space = !sanitized.is_empty(); continue; } if is_disallowed_terminal_title_char(ch) { continue; } if pending_space { let remaining = MAX_TERMINAL_TITLE_CHARS.saturating_sub(chars_written); if remaining > 1 { sanitized.push(' '); chars_written += 1; pending_space = false; } } if chars_written >= MAX_TERMINAL_TITLE_CHARS { break; } sanitized.push(ch); chars_written += 1; } sanitized } /// Returns whether `ch` should be dropped from terminal-title output. /// /// This includes both plain control characters and a curated set of invisible /// formatting codepoints. The bidi entries here cover the Trojan-Source-style /// text-reordering controls that can make a title render misleadingly relative /// to its underlying byte sequence. fn is_disallowed_terminal_title_char(ch: char) -> bool { if ch.is_control() { return true; } // Strip Trojan-Source-related bidi controls plus common non-rendering // formatting characters so title text cannot smuggle terminal control // semantics or visually misleading content. matches!( ch, '\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}' | '\u{1BCA0}'..='\u{1BCA3}' | '\u{E0100}'..='\u{E01EF}' ) } #[cfg(test)] mod tests { use super::MAX_TERMINAL_TITLE_CHARS; use super::SetWindowTitle; use super::sanitize_terminal_title; use crossterm::Command; use pretty_assertions::assert_eq; #[test] fn sanitizes_terminal_title() { let sanitized = sanitize_terminal_title(" Project\t|\nWorking\x1b\x07\u{009D}\u{009C} | Thread "); assert_eq!(sanitized, "Project | Working | Thread"); } #[test] fn strips_invisible_format_chars_from_terminal_title() { let sanitized = sanitize_terminal_title( "Pro\u{202E}j\u{2066}e\u{200F}c\u{061C}t\u{200B} \u{FEFF}T\u{2060}itle", ); assert_eq!(sanitized, "Project Title"); } #[test] fn truncates_terminal_title() { let input = "a".repeat(MAX_TERMINAL_TITLE_CHARS + 10); let sanitized = sanitize_terminal_title(&input); assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); } #[test] fn truncation_prefers_visible_char_over_pending_space() { let input = format!("{} b", "a".repeat(MAX_TERMINAL_TITLE_CHARS - 1)); let sanitized = sanitize_terminal_title(&input); assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); assert_eq!(sanitized.chars().last(), Some('b')); } #[test] fn writes_osc_title_with_string_terminator() { let mut out = String::new(); SetWindowTitle("hello".to_string()) .write_ansi(&mut out) .expect("encode terminal title"); assert_eq!(out, "\x1b]0;hello\x1b\\"); } }