Files
ghostty-rs/crates/ghostty-vt/src/osc/mod.rs
T
ddidderr f4c020101c fix: close remaining gaps found by the completeness audit
Addresses the partial ports listed in
docs/rewrite/notes/review-completeness-codex.md (details in
docs/rewrite/notes/gaps-X2.md):
- Flatpak: the exit watcher no longer consumes the host command; a
  shared FlatpakSignalHandle lets closing a surface still SIGHUP the
  host process group.
- Child environment is byte-preserving end to end (ghostty_os::EnvMap
  of OsStrings) and Snap paths are filtered like filterSnapPaths.
- argv handling no longer panics on non-UTF-8 arguments.
- URI percent-decoding keeps raw bytes; OSC 7 pwd is passed through
  without lossy conversion.
- Keybinding comparison/hashing uses full Unicode case folding from a
  table generated from CaseFolding.txt (Unicode 17.0.0).
- Link regexes set Oniguruma's whole-search retry limit through the new
  minimal ghostty-onig crate (one SAFETY-commented FFI call).
- OSC 9 NUL-capacity check ordering matches Zig.
- Wayland registry binds/clears globals advertised after startup.
- Custom shaders may pass sampler2D to helper functions (naga adapter).
- +list-themes preview renders OSC 8 links, styled underlines and
  follows color-scheme reports.

Also includes the inspector's integration edits in shared files
(renderer state hook, termio VT recording, core/apprt-gtk wiring), X1's
config/input/cli additions for completion generation, and a fix for a
race in the termio backpressure test (it wrote before `stty raw`
applied, so the cooked line discipline dropped bytes past MAX_CANON).

Test Plan:
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
2026-10-12 04:39:24 +02:00

822 lines
25 KiB
Rust

//! OSC (Operating System Command) related functions and types
//! (ZIG/src/terminal/osc.zig).
//!
//! OSC is another set of control sequences for terminal programs that start with
//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings
//! and other irregular formatting so a dedicated parser is created to handle it.
//!
//! Rust port notes (W1):
//! - Zig `[:0]const u8` fields become owned `Vec<u8>` (no NUL terminator, not
//! necessarily valid UTF-8).
//! - Zig's "fixed buffer vs. allocating writer" is a single `Vec<u8>` with an
//! optional maximum length (`MAX_BUF`, which always reserves one byte for
//! the NUL terminator the Zig code writes). The Zig `alloc: ?Allocator` is
//! `alloc: bool` ("an allocator is available").
//! - `Parser::end` returns an owned `Command` instead of a pointer into the
//! parser.
pub mod encoding;
pub mod parsers;
pub use parsers::color;
pub use parsers::semantic_prompt;
use crate::kitty::color as kitty_color;
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// This generally shouldn't ever be set except as an initial zero value.
/// Ignore it.
Invalid,
/// Set the window title of the terminal
///
/// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
/// with each code unit further encoded with two hex digits).
///
/// If title mode 2 is set or the terminal is setup for unconditional
/// utf-8 titles text is interpreted as utf-8. Else text is interpreted
/// as latin1.
ChangeWindowTitle(Vec<u8>),
/// Set the icon of the terminal window. The name of the icon is not
/// well defined, so this is currently ignored by Ghostty at the time
/// of writing this. We just parse it so that we don't get parse errors
/// in the log.
ChangeWindowIcon(Vec<u8>),
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
SemanticPrompt(SemanticPrompt),
/// Set or get clipboard contents. If data is null, then the current
/// clipboard contents are sent to the pty. If data is set, this
/// contents is set on the clipboard.
ClipboardContents {
kind: u8,
data: Vec<u8>,
},
/// OSC 7. Reports the current working directory of the shell. This is
/// a moderately flawed escape sequence but one that many major terminals
/// support so we also support it. To understand the flaws, read through
/// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20
ReportPwd {
/// The reported pwd value. This is not checked for validity. It should
/// be a file URL but it is up to the caller to utilize this value.
value: Vec<u8>,
},
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
/// naming scheme for cursors but it looks like terminals such as Foot
/// are moving towards using the W3C CSS cursor names. For OSC parsing,
/// we just parse whatever string is given.
MouseShape {
value: Vec<u8>,
},
/// OSC color operations to set, reset, or report color settings. Some OSCs
/// allow multiple operations to be specified in a single OSC so we need a
/// list-like datastructure to manage them.
///
/// Currently, these OSCs are handled by `color_operation`:
///
/// 4, 5, 10-19, 104, 105, 110-119
ColorOperation {
op: color::Operation,
requests: color::List,
terminator: Terminator,
},
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
KittyColorProtocol(kitty_color::OSC),
/// Show a desktop notification (OSC 9 or OSC 777)
ShowDesktopNotification {
title: Vec<u8>,
body: Vec<u8>,
},
/// Start a hyperlink (OSC 8)
HyperlinkStart {
id: Option<Vec<u8>>,
uri: Vec<u8>,
},
/// End a hyperlink (OSC 8)
HyperlinkEnd,
/// ConEmu sleep (OSC 9;1)
ConemuSleep {
duration_ms: u16,
},
/// ConEmu show GUI message box (OSC 9;2)
ConemuShowMessageBox(Vec<u8>),
/// ConEmu change tab title (OSC 9;3)
ConemuChangeTabTitle(ConemuChangeTabTitle),
/// ConEmu progress report (OSC 9;4)
ConemuProgressReport(ProgressReport),
/// ConEmu wait input (OSC 9;5)
ConemuWaitInput,
/// ConEmu GUI macro (OSC 9;6)
ConemuGuimacro(Vec<u8>),
/// ConEmu run process (OSC 9;7)
ConemuRunProcess(Vec<u8>),
/// ConEmu output environment variable (OSC 9;8)
ConemuOutputEnvironmentVariable(Vec<u8>),
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
/// https://conemu.github.io/en/TerminalModes.html
ConemuXtermEmulation {
/// None => do not change
/// Some(false) => turn off
/// Some(true) => turn on
keyboard: Option<bool>,
/// None => do not change
/// Some(false) => turn off
/// Some(true) => turn on
output: Option<bool>,
},
/// ConEmu comment (OSC 9;11)
ConemuComment(Vec<u8>),
/// Kitty text sizing protocol (OSC 66)
KittyTextSizing(parsers::kitty_text_sizing::OSC),
KittyClipboardProtocol(KittyClipboardProtocol),
/// OSC 3008. Hierarchical context signalling (UAPI spec).
/// https://uapi-group.org/specifications/specs/osc_context/
ContextSignal(parsers::context_signal::Command),
}
pub type SemanticPrompt = parsers::semantic_prompt::Command;
pub type KittyClipboardProtocol = parsers::kitty_clipboard_protocol::OSC;
/// Payload of `Command::ConemuChangeTabTitle`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConemuChangeTabTitle {
Reset,
Value(Vec<u8>),
}
/// The discriminant of a [`Command`] (Zig `Command.Key`). Order matches Zig.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Key {
Invalid,
ChangeWindowTitle,
ChangeWindowIcon,
SemanticPrompt,
ClipboardContents,
ReportPwd,
MouseShape,
ColorOperation,
KittyColorProtocol,
ShowDesktopNotification,
HyperlinkStart,
HyperlinkEnd,
ConemuSleep,
ConemuShowMessageBox,
ConemuChangeTabTitle,
ConemuProgressReport,
ConemuWaitInput,
ConemuGuimacro,
ConemuRunProcess,
ConemuOutputEnvironmentVariable,
ConemuXtermEmulation,
ConemuComment,
KittyTextSizing,
KittyClipboardProtocol,
ContextSignal,
}
impl Command {
pub fn key(&self) -> Key {
match self {
Command::Invalid => Key::Invalid,
Command::ChangeWindowTitle(_) => Key::ChangeWindowTitle,
Command::ChangeWindowIcon(_) => Key::ChangeWindowIcon,
Command::SemanticPrompt(_) => Key::SemanticPrompt,
Command::ClipboardContents { .. } => Key::ClipboardContents,
Command::ReportPwd { .. } => Key::ReportPwd,
Command::MouseShape { .. } => Key::MouseShape,
Command::ColorOperation { .. } => Key::ColorOperation,
Command::KittyColorProtocol(_) => Key::KittyColorProtocol,
Command::ShowDesktopNotification { .. } => Key::ShowDesktopNotification,
Command::HyperlinkStart { .. } => Key::HyperlinkStart,
Command::HyperlinkEnd => Key::HyperlinkEnd,
Command::ConemuSleep { .. } => Key::ConemuSleep,
Command::ConemuShowMessageBox(_) => Key::ConemuShowMessageBox,
Command::ConemuChangeTabTitle(_) => Key::ConemuChangeTabTitle,
Command::ConemuProgressReport(_) => Key::ConemuProgressReport,
Command::ConemuWaitInput => Key::ConemuWaitInput,
Command::ConemuGuimacro(_) => Key::ConemuGuimacro,
Command::ConemuRunProcess(_) => Key::ConemuRunProcess,
Command::ConemuOutputEnvironmentVariable(_) => Key::ConemuOutputEnvironmentVariable,
Command::ConemuXtermEmulation { .. } => Key::ConemuXtermEmulation,
Command::ConemuComment(_) => Key::ConemuComment,
Command::KittyTextSizing(_) => Key::KittyTextSizing,
Command::KittyClipboardProtocol(_) => Key::KittyClipboardProtocol,
Command::ContextSignal(_) => Key::ContextSignal,
}
}
}
/// ConEmu progress report (OSC 9;4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressReport {
pub state: ProgressState,
pub progress: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressState {
Remove,
Set,
Error,
Indeterminate,
Pause,
}
/// The terminator used to end an OSC command. For OSC commands that demand
/// a response, we try to match the terminator used in the request since that
/// is most likely to be accepted by the calling program.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Terminator {
/// The preferred string terminator is ESC followed by \
#[default]
St,
/// Some applications and terminals use BELL (0x07) as the string terminator.
Bel,
}
impl Terminator {
/// Initialize the terminator based on the last byte seen. If the
/// last byte is a BEL then we use BEL, otherwise we just assume ST.
pub fn init(ch: Option<u8>) -> Terminator {
match ch {
Some(0x07) => Terminator::Bel,
_ => Terminator::St,
}
}
/// The terminator as a string.
pub fn string(self) -> &'static [u8] {
match self {
Terminator::St => b"\x1b\\",
Terminator::Bel => b"\x07",
}
}
}
impl std::fmt::Display for Terminator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The terminator is always ASCII.
f.write_str(std::str::from_utf8(self.string()).unwrap_or(""))
}
}
/// State of the OSC parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Start,
Invalid,
// OSC command prefixes. Not all of these are valid OSCs, but may be
// needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to
// have a state "77" even though there is no OSC 77).
S0,
S1,
S2,
S3,
S4,
S5,
S6,
S7,
S8,
S9,
S30,
S300,
S3008,
S10,
S11,
S12,
S13,
S14,
S15,
S16,
S17,
S18,
S19,
S21,
S22,
S52,
S55,
S66,
S77,
S104,
S110,
S111,
S112,
S113,
S114,
S115,
S116,
S117,
S118,
S119,
S133,
S552,
S777,
S1337,
S5522,
}
/// How OSC payload data is being accumulated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Writer {
/// Not accumulating (still parsing the numeric prefix).
None,
/// Fixed-size buffer of `MAX_BUF` bytes.
Fixed,
/// Growable buffer.
Allocating,
}
#[derive(Debug, Clone)]
pub struct Parser {
/// Whether an allocator is available, i.e. whether data longer than
/// MAX_BUF may be accepted for some commands (e.g. OSC 52), and whether
/// commands that require an allocator are processed. (Zig:
/// `alloc: ?Allocator`.)
pub alloc: bool,
/// Current state of the parser.
pub state: State,
/// Buffer for temporary storage of OSC data
buffer: Vec<u8>,
writer: Writer,
}
impl Default for Parser {
fn default() -> Self {
Self::init(false)
}
}
impl Parser {
/// Maximum size of a "normal" OSC.
pub const MAX_BUF: usize = 2048;
pub fn init(alloc: bool) -> Parser {
Parser {
alloc,
state: State::Start,
buffer: Vec::new(),
writer: Writer::None,
}
}
/// Reset the parser state.
pub fn reset(&mut self) {
self.state = State::Start;
self.buffer.clear();
self.writer = Writer::None;
}
/// Make sure that we have an allocator. If we don't, set the state to
/// invalid so that any additional OSC data is discarded.
fn ensure_allocator(&mut self) -> bool {
if self.alloc {
return true;
}
log::warn!(
"An allocator is required to process OSC {:?} but none was provided.",
self.state
);
self.state = State::Invalid;
false
}
/// Set up a fixed Writer to collect the rest of the OSC data.
fn write_to_fixed(&mut self) {
self.writer = Writer::Fixed;
}
/// Set up an allocating Writer to collect the rest of the OSC data. If we
/// don't have an allocator, fall back to writing to a fixed buffer and
/// hope that it's big enough.
fn write_to_allocating(&mut self) {
if !self.alloc {
self.write_to_fixed();
return;
}
self.writer = Writer::Allocating;
}
/// Helper for the per-command parsers. Mirrors the Zig prologue
/// `writer orelse invalid; writer.writeByte(0) catch invalid;
/// writer.buffered()`: returns a copy of the accumulated data (without
/// the NUL terminator) or `None` (and marks the parser invalid) if no
/// data was being accumulated or there was no room for the terminator.
pub fn data(&mut self) -> Option<Vec<u8>> {
if self.writer == Writer::None {
self.state = State::Invalid;
return None;
}
// Reserve room for the NUL terminator.
if self.writer == Writer::Fixed && self.buffer.len() >= Self::MAX_BUF {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
}
/// Zig `writer.writeByte(0)` capacity check: when the fixed buffer is
/// full there is no room for the NUL terminator, so the parser becomes
/// invalid and this returns false.
pub fn reserve_nul(&mut self) -> bool {
if self.writer == Writer::Fixed && self.buffer.len() >= Self::MAX_BUF {
self.state = State::Invalid;
return false;
}
true
}
/// Like [`Parser::data`] but without reserving room for the NUL
/// terminator (for parsers that, like Zig's color parsers, read
/// `writer.buffered()` directly).
pub fn buffered(&mut self) -> Option<Vec<u8>> {
if self.writer == Writer::None {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
}
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
pub fn has_writer(&self) -> bool {
self.writer != Writer::None
}
#[inline]
fn write_byte(&mut self, c: u8) {
match self.writer {
Writer::None => unreachable!(),
Writer::Fixed => {
if self.buffer.len() >= Self::MAX_BUF {
// Overflow: discard any further input.
self.state = State::Invalid;
} else {
self.buffer.push(c);
}
}
Writer::Allocating => self.buffer.push(c),
}
}
/// Consume the next character c and advance the parser state.
///
/// The payload-accumulation fast path is inlined into callers; the
/// numeric-prefix state machine lives in [`Parser::next_state`].
#[inline]
pub fn next(&mut self, c: u8) {
// If the state becomes invalid for any reason, just discard
// any further input.
if self.state == State::Invalid {
return;
}
// If a writer has been initialized, we just accumulate the rest of the
// OSC sequence in the writer's buffer and skip the state machine.
if self.writer != Writer::None {
self.write_byte(c);
return;
}
self.next_state(c);
}
/// Consume a run of characters; exactly equivalent to calling
/// [`Parser::next`] for each byte, but payload bytes are appended in bulk.
pub fn next_slice(&mut self, mut bytes: &[u8]) {
while let Some((&c, rest)) = bytes.split_first() {
if self.state == State::Invalid {
return;
}
match self.writer {
Writer::None => {
self.next_state(c);
bytes = rest;
}
Writer::Allocating => {
self.buffer.extend_from_slice(bytes);
return;
}
Writer::Fixed => {
let room = Self::MAX_BUF.saturating_sub(self.buffer.len());
if bytes.len() <= room {
self.buffer.extend_from_slice(bytes);
} else {
// Overflow: keep what fits, discard the rest.
self.buffer.extend_from_slice(&bytes[..room]);
self.state = State::Invalid;
}
return;
}
}
}
}
#[inline(never)]
fn next_state(&mut self, c: u8) {
#[allow(clippy::enum_glob_use)]
use State::*;
match self.state {
// handled above, so should never be here
Invalid => unreachable!(),
Start => {
self.state = match c {
b'0' => S0,
b'1' => S1,
b'2' => S2,
b'3' => S3,
b'4' => S4,
b'5' => S5,
b'6' => S6,
b'7' => S7,
b'8' => S8,
b'9' => S9,
_ => Invalid,
}
}
S3 => {
self.state = match c {
b'0' => S30,
_ => Invalid,
}
}
S30 => {
self.state = match c {
b'0' => S300,
_ => Invalid,
}
}
S300 => {
self.state = match c {
b'8' => S3008,
_ => Invalid,
}
}
S3008 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
S1 => match c {
b';' => self.write_to_fixed(),
b'0' => self.state = S10,
b'1' => self.state = S11,
b'2' => self.state = S12,
b'3' => self.state = S13,
b'4' => self.state = S14,
b'5' => self.state = S15,
b'6' => self.state = S16,
b'7' => self.state = S17,
b'8' => self.state = S18,
b'9' => self.state = S19,
_ => self.state = Invalid,
},
S10 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'4' => self.state = S104,
_ => self.state = Invalid,
},
S104 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
_ => self.state = Invalid,
},
S11 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'0' => self.state = S110,
b'1' => self.state = S111,
b'2' => self.state = S112,
b'3' => self.state = S113,
b'4' => self.state = S114,
b'5' => self.state = S115,
b'6' => self.state = S116,
b'7' => self.state = S117,
b'8' => self.state = S118,
b'9' => self.state = S119,
_ => self.state = Invalid,
},
S4 | S12 | S14 | S15 | S16 | S17 | S18 | S19 | S21 | S110 | S111 | S112 | S113
| S114 | S115 | S116 | S117 | S118 | S119 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
_ => self.state = Invalid,
},
S13 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'3' => self.state = S133,
_ => self.state = Invalid,
},
S2 => match c {
b';' => self.write_to_fixed(),
b'1' => self.state = S21,
b'2' => self.state = S22,
_ => self.state = Invalid,
},
S5 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'2' => self.state = S52,
b'5' => self.state = S55,
_ => self.state = Invalid,
},
S6 => match c {
b'6' => self.state = S66,
_ => self.state = Invalid,
},
S52 | S66 => match c {
b';' => self.write_to_allocating(),
_ => self.state = Invalid,
},
S55 => match c {
b'2' => self.state = S552,
_ => self.state = Invalid,
},
S7 => match c {
b';' => self.write_to_fixed(),
b'7' => self.state = S77,
_ => self.state = Invalid,
},
S77 => match c {
b'7' => self.state = S777,
_ => self.state = Invalid,
},
S133 => match c {
b';' => self.write_to_fixed(),
b'7' => self.state = S1337,
_ => self.state = Invalid,
},
S552 => match c {
b'2' => self.state = S5522,
_ => self.state = Invalid,
},
S1337 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
S5522 => match c {
b';' => self.write_to_allocating(),
_ => self.state = Invalid,
},
S0 | S22 | S777 | S8 | S9 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
}
}
/// End the sequence and return the command, if any. If the return value
/// is None, then no valid command was found. The optional terminator_ch
/// is the final character in the OSC sequence. This is used to determine
/// the response terminator.
pub fn end(&mut self, terminator_ch: Option<u8>) -> Option<Command> {
#[allow(clippy::enum_glob_use)]
use State::*;
match self.state {
Start => None,
Invalid => None,
S0 | S2 => parsers::change_window_title::parse(self, terminator_ch),
S1 => parsers::change_window_icon::parse(self, terminator_ch),
S4 | S5 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S104 | S110
| S111 | S112 | S113 | S114 | S115 | S116 | S117 | S118 | S119 => {
parsers::color::parse(self, terminator_ch)
}
S7 => parsers::report_pwd::parse(self, terminator_ch),
S8 => parsers::hyperlink::parse(self, terminator_ch),
S9 => parsers::osc9::parse(self, terminator_ch),
S21 => parsers::kitty_color::parse(self, terminator_ch),
S22 => parsers::mouse_shape::parse(self, terminator_ch),
S52 => parsers::clipboard_operation::parse(self, terminator_ch),
S55 => None,
S3 | S30 | S300 => None,
S3008 => parsers::context_signal::parse(self, terminator_ch),
S6 => None,
S66 => parsers::kitty_text_sizing::parse(self, terminator_ch),
S77 => None,
S133 => parsers::semantic_prompt::parse(self, terminator_ch),
S552 => None,
S777 => parsers::rxvt_extension::parse(self, terminator_ch),
S1337 => parsers::iterm2::parse(self, terminator_ch),
S5522 => parsers::kitty_clipboard_protocol::parse(self, terminator_ch),
}
}
}
#[cfg(test)]
mod next_slice_tests {
use super::*;
#[test]
fn next_slice_matches_next() {
for alloc in [false, true] {
for prefix in ["0;", "52;c;", "4;", "7;", "8;;", "99;", "x"] {
for n in [0usize, 1, 5, 2040, 2046, 2047, 2048, 2049, 2050, 3000] {
let mut input = prefix.as_bytes().to_vec();
input.extend((0..n).map(|i| b'a' + (i % 26) as u8));
let mut a = Parser::init(alloc);
let mut b = Parser::init(alloc);
for &c in &input {
a.next(c);
}
let mid = input.len() / 2;
b.next_slice(&input[..mid]);
b.next_slice(&input[mid..]);
assert_eq!(a.state, b.state);
assert_eq!(a.buffer, b.buffer);
assert_eq!(a.writer, b.writer);
}
}
}
}
}