mirror of
https://github.com/openai/codex.git
synced 2026-04-29 02:41:12 +03:00
This is a follow-up to https://github.com/openai/codex/pull/15922. That previous PR deleted the old `tui` directory and left the new `tui_app_server` directory in place. This PR renames `tui_app_server` to `tui` and fixes up all references.
225 lines
7.9 KiB
Rust
225 lines
7.9 KiB
Rust
//! 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<SetTerminalTitleResult> {
|
|
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\\");
|
|
}
|
|
}
|