diff --git a/core/src/terminal/kitty.rs b/core/src/terminal/kitty.rs new file mode 100644 index 00000000..c097c4ea --- /dev/null +++ b/core/src/terminal/kitty.rs @@ -0,0 +1,314 @@ +//! The negotiated half of the kitty keyboard protocol: the mode stack a program +//! pushes, pops and queries. +//! +//! Legacy terminal key encoding is lossy: Enter, Ctrl+M and Ctrl+Enter all +//! arrive as `\r`, and Shift+Enter is indistinguishable from Enter. Under this +//! protocol a program asks for unambiguous keys and every modified key arrives +//! as `CSI ; u`. It is opt-in per program and stack-based, so +//! a TUI can enable it, a child process can push its own setting, and popping +//! restores whatever the parent had. +//! +//! The stack is per screen buffer, which is the protocol's safety net rather +//! than a detail: a full-screen program does its work on the alternate screen, +//! so whatever it pushes there — and forgets to pop, or never gets the chance to +//! pop because it was killed — cannot follow the shell back to the main screen. +//! +//! This lives in the daemon, next to the [`vte::Parser`] that already reads +//! every PTY byte ([`super::status`]), rather than in the window drawing the +//! terminal. The negotiation is a property of the *session*: it survives a +//! window closing and reopening, it is the same answer for two windows showing +//! one terminal, and a program that pushed its mode long ago must still be +//! encoded for after the push has scrolled out of the replay ring. Only the +//! *encoder* is frontend work, because it needs a DOM `KeyboardEvent`. +//! +//! Reference: the protocol as implemented by Ghostty (`src/terminal/kitty/key.zig`). + +/// Flags are five bits; anything wider is a malformed request. +pub const FLAGS_MAX: u16 = 31; + +/// How deep the mode stack goes before the oldest entry is dropped. Programs +/// push on entry and pop on exit; a leaked push must not be able to grow this +/// without bound, so it wraps rather than allocating. +const STACK_DEPTH: usize = 8; + +/// Which buffer a terminal is showing. Each keeps its own mode stack. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Normal, + Alternate, +} + +/// The per-session mode stacks, one per screen buffer. +#[derive(Debug)] +pub struct KittyKeyboard { + normal: Vec, + alternate: Vec, + screen: Screen, +} + +impl Default for KittyKeyboard { + fn default() -> Self { + Self { + normal: vec![0], + alternate: vec![0], + screen: Screen::Normal, + } + } +} + +impl KittyKeyboard { + /// The flags currently in force, 0 when the protocol is off. + pub fn flags(&self) -> u8 { + self.stack().last().copied().unwrap_or(0) + } + + fn stack(&self) -> &Vec { + match self.screen { + Screen::Normal => &self.normal, + Screen::Alternate => &self.alternate, + } + } + + fn stack_mut(&mut self) -> &mut Vec { + match self.screen { + Screen::Normal => &mut self.normal, + Screen::Alternate => &mut self.alternate, + } + } + + /// Follow the terminal onto the other screen buffer. + /// + /// The stacks do not merge and the one being left is not cleared: a program + /// that drops to the main screen to run a child and comes back expects to + /// find its own mode still in force. What it cannot do is impose that mode + /// on the shell. + pub fn set_screen(&mut self, screen: Screen) { + self.screen = screen; + } + + /// `CSI > flags u` — push a level. Bits above the five the protocol defines + /// are masked rather than refused: dropping the push while still honouring + /// the program's later pop would unwind a level it never pushed, taking the + /// shell's mode with it. Ghostty masks for the same reason. + pub fn push(&mut self, flags: u16) { + let flags = u8::try_from(flags & FLAGS_MAX).unwrap_or(0); + let stack = self.stack_mut(); + stack.push(flags); + // Wrap rather than grow: drop the oldest entry once we exceed the depth. + if stack.len() > STACK_DEPTH { + stack.remove(0); + } + } + + /// `CSI < n u` — pop `count` levels. + pub fn pop(&mut self, count: u16) { + let stack = self.stack_mut(); + // A pop deeper than the stack is a program losing track of its own + // state; treat it as "put everything back" rather than half-unwinding. + if usize::from(count) >= STACK_DEPTH { + stack.clear(); + stack.push(0); + return; + } + for _ in 0..count { + if stack.len() > 1 { + stack.pop(); + } else { + stack[0] = 0; + } + } + } + + /// `CSI = flags ; mode u` — set/or/clear in place. Modes outside 1..3 are + /// malformed; leaving the state alone beats guessing, since a garbled + /// sequence would otherwise silently change key encoding. + pub fn set(&mut self, flags: u16, mode: u16) { + if flags > FLAGS_MAX { + return; + } + let flags = u8::try_from(flags).unwrap_or(0); + let stack = self.stack_mut(); + let Some(top) = stack.last_mut() else { return }; + match mode { + 1 => *top = flags, + 2 => *top |= flags, + 3 => *top &= !flags, + _ => {} + } + } + + /// Reset to "off" on both screens, back on the main one. A full terminal + /// reset (RIS, `ESC c`) or a soft reset (DECSTR, `CSI ! p`) clears the mode; + /// `reset` is how a user recovers a terminal whose keyboard is encoded for a + /// protocol nothing is reading. + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Zero both stacks without touching which screen is active — the screen + /// keeps tracking the program's buffer, only the flags are declared dead. + /// + /// Called at a shell prompt (OSC 133;A). An interactive prompt means the + /// shell owns the terminal: no full-screen program is alive, so flags still + /// set on *either* screen were leaked by a program that died without + /// popping. The per-screen stacks stop such a leak reaching the shell, but + /// the alternate screen's copy would otherwise wait there for the next + /// vim/less, which inherits the dead program's mode as keys it cannot parse. + /// Kitty's own shell integration performs the same reset at each prompt. + /// + /// The accepted cost, same as kitty's: a TUI suspended with Ctrl+Z loses its + /// pushed mode when the prompt redraws, and `fg` resumes it un-enhanced + /// until it renegotiates. + pub fn clear_leaked(&mut self) { + self.normal.clear(); + self.normal.push(0); + self.alternate.clear(); + self.alternate.push(0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DISAMBIGUATE: u16 = 1; + + #[test] + fn is_off_until_a_program_asks_for_it() { + assert_eq!(KittyKeyboard::default().flags(), 0); + } + + #[test] + fn pushes_and_pops_restore_the_parent_mode() { + let mut k = KittyKeyboard::default(); + k.push(DISAMBIGUATE); + assert_eq!(k.flags(), 1); + + // A nested program pushes its own richer mode... + k.push(9); + assert_eq!(k.flags(), 9); + + // ...and popping restores what the parent had. + k.pop(1); + assert_eq!(k.flags(), 1); + } + + #[test] + fn sets_adds_and_clears_bits_in_place() { + let mut k = KittyKeyboard::default(); + k.set(1, 1); // set + assert_eq!(k.flags(), 1); + k.set(8, 2); // or + assert_eq!(k.flags(), 9); + k.set(1, 3); // not + assert_eq!(k.flags(), 8); + } + + /// A garbled sequence must not silently change how every key is encoded. + #[test] + fn ignores_malformed_requests_rather_than_guessing() { + let mut k = KittyKeyboard::default(); + k.push(DISAMBIGUATE); + k.set(2, 99); // mode outside 1..3 + assert_eq!(k.flags(), 1); + k.set(999, 1); // flags wider than five bits + assert_eq!(k.flags(), 1); + } + + #[test] + fn masks_unknown_flag_bits_instead_of_dropping_the_push() { + let mut k = KittyKeyboard::default(); + k.push(DISAMBIGUATE); + k.push(999); // wider than five bits + assert_eq!(u16::from(k.flags()), 999 & FLAGS_MAX); + // The pop still finds the level it pushed, so the shell keeps its mode. + k.pop(1); + assert_eq!(k.flags(), 1); + } + + #[test] + fn unwinds_completely_when_a_program_pops_past_the_bottom() { + let mut k = KittyKeyboard::default(); + k.push(DISAMBIGUATE); + k.pop(64); + assert_eq!(k.flags(), 0); + } + + #[test] + fn a_leaked_push_cannot_grow_the_stack_without_bound() { + let mut k = KittyKeyboard::default(); + for _ in 0..100 { + k.push(DISAMBIGUATE); + } + assert_eq!(k.stack().len(), STACK_DEPTH); + } + + /// The bug the per-screen stacks exist to prevent: a TUI enables the + /// protocol on the alternate screen and is killed before it can pop. With + /// one shared stack the shell inherits the mode and every keystroke encodes + /// for a reader that is gone — Ctrl+C arrives as `CSI 99;5u`, so the + /// terminal cannot even be told `reset`. + #[test] + fn an_alt_screen_mode_does_not_follow_the_shell_home() { + let mut k = KittyKeyboard::default(); + k.set_screen(Screen::Alternate); + k.push(DISAMBIGUATE); + assert_eq!(k.flags(), 1); + + // The TUI dies. Nothing pops; the shell simply gets its screen back. + k.set_screen(Screen::Normal); + assert_eq!(k.flags(), 0); + } + + #[test] + fn keeps_a_programs_mode_while_it_visits_the_main_screen() { + let mut k = KittyKeyboard::default(); + k.set_screen(Screen::Alternate); + k.push(9); + // Dropping out to run a child process and coming back is a normal thing + // for a full-screen program to do. + k.set_screen(Screen::Normal); + k.set_screen(Screen::Alternate); + assert_eq!(k.flags(), 9); + } + + #[test] + fn keeps_the_shells_own_mode_across_a_programs_visit() { + let mut k = KittyKeyboard::default(); + // A shell that negotiated the protocol for its own line editor. + k.push(DISAMBIGUATE); + k.set_screen(Screen::Alternate); + k.push(9); + k.set_screen(Screen::Normal); + assert_eq!(k.flags(), 1); + } + + #[test] + fn reset_clears_both_screens() { + let mut k = KittyKeyboard::default(); + k.push(DISAMBIGUATE); + k.set_screen(Screen::Alternate); + k.push(9); + + k.reset(); + + // A reset returns to the main screen, and finds nothing set there... + assert_eq!(k.flags(), 0); + // ...nor waiting on the screen it just left. + k.set_screen(Screen::Alternate); + assert_eq!(k.flags(), 0); + } + + #[test] + fn clear_leaked_zeroes_both_screens_but_keeps_the_active_one() { + let mut k = KittyKeyboard::default(); + k.set_screen(Screen::Alternate); + k.push(25); + + k.clear_leaked(); + + assert_eq!(k.flags(), 0); + k.set_screen(Screen::Normal); + assert_eq!(k.flags(), 0); + } +} diff --git a/core/src/terminal/mod.rs b/core/src/terminal/mod.rs index 0f32aa50..ee3466ed 100644 --- a/core/src/terminal/mod.rs +++ b/core/src/terminal/mod.rs @@ -22,6 +22,8 @@ pub use wire::{Phase, SessionStatus, TerminalId, TerminalSummary}; #[cfg(feature = "terminal")] mod engine_ghostty; #[cfg(feature = "terminal")] +mod kitty; +#[cfg(feature = "terminal")] mod manager; #[cfg(feature = "terminal")] mod poll; diff --git a/core/src/terminal/session.rs b/core/src/terminal/session.rs index abe9b28a..c130723c 100644 --- a/core/src/terminal/session.rs +++ b/core/src/terminal/session.rs @@ -3,7 +3,9 @@ //! Threading model: one dedicated **reader thread** per session does blocking //! reads on the PTY master. For each chunk it runs, in order: append to the //! scrollback ring, call each registered [`OutputSink`] (synchronously, in -//! order), then fan the chunk out to subscribers. +//! order), then fan the chunk out to subscribers. It also feeds the status +//! scanner and writes back whatever answer a device report in that chunk asked +//! for, so a program is answered whether or not a window is attached. //! On EOF it reaps the child's exit code, finalizes status, notifies sinks, and //! emits a final [`TerminalMessage::Exit`]. All other methods //! (`write`/`resize`/`kill`) are called from arbitrary threads and coordinate @@ -52,6 +54,10 @@ struct Shared { status: Mutex, /// The status engine: OSC/bell scanner + phase state machine. scanner: Mutex, + /// Writable side of the PTY master (stdin to the child). Shared rather than + /// owned by the session handle because the reader thread answers device + /// reports (the kitty keyboard mode query) on the thread that parsed them. + writer: Mutex>, exited: AtomicBool, exit_code: Mutex>, /// Owned child handle, used by the reader thread to reap the exit code. @@ -70,6 +76,14 @@ impl Shared { *self.status.lock().unwrap() = status.clone(); fanout(&self.subscribers, &TerminalMessage::Status(status)); } + + /// Write bytes to the child's stdin. + fn write_pty(&self, data: &[u8]) -> Result<()> { + let mut writer = self.writer.lock().unwrap(); + writer.write_all(data).context("Failed to write to pty")?; + writer.flush().context("Failed to flush pty")?; + Ok(()) + } } /// A live terminal session. Stored as `Arc` by the manager. @@ -83,8 +97,6 @@ pub struct Session { size: Mutex<(u16, u16)>, /// Signals the child to terminate from any thread (independent of `wait`). killer: Mutex>, - /// Writable side of the PTY master (stdin to the child). - writer: Mutex>, /// The PTY master; dropped on `kill` to help the reader unblock via EOF. master: Mutex>>, reader_handle: Mutex>>, @@ -182,6 +194,7 @@ impl Session { subscribers: Mutex::new(Vec::new()), status: Mutex::new(status), scanner: Mutex::new(scanner), + writer: Mutex::new(writer), exited: AtomicBool::new(false), exit_code: Mutex::new(None), child: Mutex::new(child), @@ -196,7 +209,6 @@ impl Session { shell_pid, size: Mutex::new((cols, rows)), killer: Mutex::new(killer), - writer: Mutex::new(writer), master: Mutex::new(Some(pair.master)), reader_handle: Mutex::new(Some(reader_handle)), vt: Mutex::new(Some(vt)), @@ -256,11 +268,7 @@ impl Session { if self.has_exited() { return Err(anyhow!("terminal {} has exited", self.shared.id)); } - { - let mut writer = self.writer.lock().unwrap(); - writer.write_all(data).context("Failed to write to pty")?; - writer.flush().context("Failed to flush pty")?; - } + self.shared.write_pty(data)?; if self.shared.scanner.lock().unwrap().on_write() { self.shared.publish_status(); } @@ -406,9 +414,22 @@ fn spawn_reader_thread( seq, }, ); - // Scan for OSC 133 / bell / title / OSC 7 and publish any - // status change to subscribers. - if shared.scanner.lock().unwrap().feed(chunk) { + // Scan for OSC 133 / bell / title / OSC 7 / kitty + // keyboard negotiation, then publish any status change + // to subscribers. + let (changed, reply) = { + let mut scanner = shared.scanner.lock().unwrap(); + (scanner.feed(chunk), scanner.take_reply()) + }; + // A device report is answered here, on the thread that + // parsed it, so a program querying the terminal gets its + // answer whether or not a window is attached. The + // answers are a handful of bytes; a failed write means + // the child is going away and there is nobody to tell. + if let Some(reply) = reply { + let _ = shared.write_pty(&reply); + } + if changed { shared.publish_status(); } } diff --git a/core/src/terminal/status.rs b/core/src/terminal/status.rs index a3a44437..06225a84 100644 --- a/core/src/terminal/status.rs +++ b/core/src/terminal/status.rs @@ -29,9 +29,18 @@ //! Content peek is not part of status: it is pulled on demand through the //! session's VT thread (see [`super::Session::peek`]), never pushed into a //! status frame. +//! +//! ## Kitty keyboard negotiation +//! +//! The same scanner reads the kitty keyboard protocol's push/pop/set/query +//! sequences into a [`KittyKeyboard`] stack (see [`super::kitty`]) and surfaces +//! the flags in force as `kitty_flags`. A mode query (`CSI ? u`) is answered +//! from here: [`StatusScanner::take_reply`] hands the bytes back to the session, +//! which writes them to the PTY on the reader thread. -use vte::{Parser, Perform}; +use vte::{Params, Parser, Perform}; +use super::kitty::{KittyKeyboard, Screen, FLAGS_MAX}; use super::{now_millis, Phase, SessionStatus, TerminalId}; /// The mutable status state plus the `vte::Perform` sink. The [`Perform`] impl @@ -53,6 +62,10 @@ struct Sink { title: Option, entered_state_at: u64, shell_integration_active: bool, + /// The kitty keyboard mode stacks the running program negotiated. + kitty: KittyKeyboard, + /// Bytes owed back to the program (so far only the kitty mode report). + reply: Vec, /// Raised whenever a surfaced field changed since the last emit. dirty: bool, } @@ -71,6 +84,8 @@ impl Sink { title: None, entered_state_at: now_millis(), shell_integration_active: false, + kitty: KittyKeyboard::default(), + reply: Vec::new(), dirty: false, } } @@ -87,6 +102,7 @@ impl Sink { title: self.title.clone(), entered_state_at: self.entered_state_at, shell_integration_active: self.shell_integration_active, + kitty_flags: self.kitty.flags(), } } @@ -165,9 +181,11 @@ impl Sink { self.activate_integration(); let Some(sub) = params.get(1) else { return }; match sub.first() { - // Prompt start: at a prompt, waiting for input; clear the bell. + // Prompt start: at a prompt, waiting for input; clear the bell, and + // any keyboard mode a program leaked by dying without popping. Some(&b'A') => { self.clear_attention(); + self.with_kitty(KittyKeyboard::clear_leaked); self.set_base_phase(Phase::WaitingForInput); } // Prompt end: still waiting for input. @@ -195,6 +213,76 @@ impl Sink { } } + /// Mutate the kitty keyboard stacks, raising `dirty` only when the flags + /// actually in force changed — a push onto the screen the program is not + /// drawing on is not something the frontend has to hear about. + fn with_kitty(&mut self, apply: impl FnOnce(&mut KittyKeyboard)) { + let before = self.kitty.flags(); + apply(&mut self.kitty); + if self.kitty.flags() != before { + self.dirty = true; + } + } + + /// Apply a kitty keyboard negotiation sequence, identified by its private + /// prefix. The final byte is `u` for all four. + fn kitty_csi(&mut self, prefix: u8, params: &Params) { + match prefix { + // CSI > flags u — push. No parameter means "push 0", i.e. disable; + // so does a multi-parameter form, which the protocol does not have. + b'>' => { + let flags = if params.iter().count() == 1 { + param_at(params, 0).unwrap_or(0) + } else { + 0 + }; + self.with_kitty(|kitty| kitty.push(flags)); + } + // CSI < n u — pop n levels, defaulting to one. + b'<' => { + let count = if params.iter().count() == 1 { + param_at(params, 0).unwrap_or(1) + } else { + 1 + }; + self.with_kitty(|kitty| kitty.pop(count)); + } + // CSI = flags ; mode u — set/or/clear in place. + b'=' => { + let flags = param_at(params, 0).unwrap_or(0); + let mode = param_at(params, 1).unwrap_or(1); + if flags <= FLAGS_MAX { + self.with_kitty(|kitty| kitty.set(flags, mode)); + } + } + // CSI ? u — what mode are we in? Answered from the session's own + // stack, so a program gets its answer whether or not a window is + // attached to draw the terminal. + b'?' => { + let flags = self.kitty.flags(); + self.reply + .extend_from_slice(format!("\x1b[?{flags}u").as_bytes()); + } + _ => {} + } + } + + /// Apply a DECSET/DECRST (`CSI ? n h` / `l`). Only the alternate-screen + /// switches matter here: which kitty stack is live follows the buffer the + /// program is drawing on, and 47, 1047 and 1049 are the three ways in. + fn private_mode(&mut self, params: &Params, set: bool) { + let screen = if set { + Screen::Alternate + } else { + Screen::Normal + }; + for sub in params.iter() { + if matches!(sub.first(), Some(&(47 | 1047 | 1049))) { + self.with_kitty(|kitty| kitty.set_screen(screen)); + } + } + } + /// Apply an OSC 9 desktop notification (Codex): the whole remainder is the /// message. ConEmu overloads the same code for machine chatter that is not /// a notification: `9;4;;` progress reports (including @@ -280,6 +368,45 @@ impl Perform for Sink { _ => {} } } + + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) { + if ignore { + return; + } + match (action, intermediates) { + // The kitty keyboard protocol's four negotiation sequences, told + // apart by their private prefix. + ('u', [prefix]) if matches!(*prefix, b'>' | b'<' | b'=' | b'?') => { + self.kitty_csi(*prefix, params); + } + // DECSET / DECRST — the alternate-screen switch the kitty stacks + // are scoped to. + ('h', [b'?']) => self.private_mode(params, true), + ('l', [b'?']) => self.private_mode(params, false), + // DECSTR, a soft reset. Like RIS below, it puts the keyboard back + // the way a program that died mid-negotiation could not. + ('p', [b'!']) => self.with_kitty(KittyKeyboard::reset), + _ => {} + } + } + + fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) { + // ESC c — RIS, a full terminal reset. A program that enabled the kitty + // protocol and then died without popping would otherwise leave every + // later keystroke encoded for a reader that is gone; `reset` is how a + // user fixes that by hand. (`ESC ( c` and friends carry intermediates + // and are charset designators, not a reset.) + if !ignore && byte == b'c' && intermediates.is_empty() { + self.with_kitty(KittyKeyboard::reset); + } + } +} + +/// The `index`th CSI parameter as a plain number, ignoring any subparameters +/// (`1:2`); `None` when the sequence did not carry one. +fn param_at(params: &Params, index: usize) -> Option { + let sub = params.iter().nth(index)?; + sub.first().copied() } /// Rejoin OSC parameters with their `;` separators. `vte` splits on `;`, so a @@ -337,6 +464,13 @@ impl StatusScanner { self.sink.take_dirty() } + /// Take the bytes the program is owed in answer to a device report it sent + /// (today: the kitty keyboard mode query). The caller writes them to the + /// PTY; `None` when nothing asked. + pub fn take_reply(&mut self) -> Option> { + (!self.sink.reply.is_empty()).then(|| std::mem::take(&mut self.sink.reply)) + } + /// Note a user write to the terminal: clears the bell overlay. Returns /// whether the surfaced status changed. pub fn on_write(&mut self) -> bool { @@ -574,6 +708,90 @@ mod tests { ); } + #[test] + fn kitty_push_and_pop_surface_on_the_status() { + let mut s = scanner(); + + let pushed = feed(&mut s, b"\x1b[>1u").expect("a push changed status"); + assert_eq!(pushed.kitty_flags, 1); + + // A nested program's richer mode, and back again. + assert_eq!(feed(&mut s, b"\x1b[>9u").expect("push").kitty_flags, 9); + assert_eq!(feed(&mut s, b"\x1b[9u"); + assert!(s.take_reply().is_none(), "a push asks for no answer"); + + s.feed(b"\x1b[?u"); + assert_eq!(s.take_reply().as_deref(), Some(b"\x1b[?9u".as_slice())); + // Taken once: the caller has written it. + assert!(s.take_reply().is_none()); + } + + #[test] + fn kitty_mode_is_scoped_to_the_screen_it_was_pushed_on() { + let mut s = scanner(); + // A TUI takes the alternate screen and enables the protocol... + s.feed(b"\x1b[?1049h\x1b[>1u"); + assert_eq!(s.build_status().kitty_flags, 1); + + // ...then is killed. Nothing pops; the shell just gets its screen back, + // and must not inherit a keyboard encoded for the dead program. + let home = feed(&mut s, b"\x1b[?1049l").expect("leaving alt changed status"); + assert_eq!(home.kitty_flags, 0); + } + + #[test] + fn prompt_mark_clears_a_mode_leaked_onto_the_alternate_screen() { + let mut s = scanner(); + // TUI up, mode pushed, then killed: `1049l` from its teardown but no + // kitty pop — the leak the next full-screen program would inherit. + s.feed(b"\x1b[?1049h\x1b[>25u\x1b[?1049l"); + s.feed(b"\x1b]133;A\x07"); + + // vim starts on that same alternate screen and never asks for kitty. + s.feed(b"\x1b[?1049h"); + assert_eq!(s.build_status().kitty_flags, 0); + } + + #[test] + fn command_marks_do_not_strip_a_running_programs_mode() { + let mut s = scanner(); + s.feed(b"\x1b[>1u"); + // These arrive *around* a running program; clearing on them would take + // the mode out from under it. + s.feed(b"\x1b]133;C\x07"); + s.feed(b"\x1b]133;D;0\x07"); + assert_eq!(s.build_status().kitty_flags, 1); + } + + #[test] + fn a_terminal_reset_clears_the_keyboard_mode() { + // RIS (`reset`) and DECSTR both put a stuck keyboard back. + for reset in [b"\x1bc".as_slice(), b"\x1b[!p".as_slice()] { + let mut s = scanner(); + s.feed(b"\x1b[>1u"); + let cleared = feed(&mut s, reset).expect("a reset changed status"); + assert_eq!(cleared.kitty_flags, 0); + } + } + #[test] fn poll_drives_phase_only_without_shell_integration() { let mut s = scanner(); diff --git a/core/src/terminal/wire.rs b/core/src/terminal/wire.rs index 81e57338..d695e11f 100644 --- a/core/src/terminal/wire.rs +++ b/core/src/terminal/wire.rs @@ -70,6 +70,14 @@ pub struct SessionStatus { pub entered_state_at: u64, /// Whether shell integration (OSC 133 marks) is active. pub shell_integration_active: bool, + /// Kitty keyboard protocol flags in force on the screen the running program + /// is drawing on, 0 when the protocol is off. The stack that produces them + /// is negotiated in the daemon (see `terminal::kitty`); a window encodes + /// keystrokes against whatever this last said, so a reattaching window + /// inherits the mode instead of re-deriving it from replayed scrollback the + /// push may have already fallen out of. + #[serde(default)] + pub kitty_flags: u8, } /// Summary of a session — the canonical `TerminalSessionInfo` wire shape. diff --git a/desktop/ui/components/Terminal/kitty-keys.test.ts b/desktop/ui/components/Terminal/kitty-keys.test.ts index 4c06c7c3..34eb9e3a 100644 --- a/desktop/ui/components/Terminal/kitty-keys.test.ts +++ b/desktop/ui/components/Terminal/kitty-keys.test.ts @@ -4,7 +4,7 @@ import { encodeKittyKey, forgetKittyState, kittyFlags, - registerKittyHandlers, + setKittyFlags, } from "./kitty-keys"; const DISAMBIGUATE = 1; @@ -16,314 +16,64 @@ function key( return new KeyboardEvent(type, { code: init.key, ...init }); } -/** A stand-in for xterm's parser that records the handlers it is given. */ -function fakeTerm() { - const handlers = new Map< - string, - (params: (number | number[])[]) => boolean - >(); - const escHandlers = new Map boolean>(); - const oscHandlers = new Map boolean>(); - const bufferListeners: (() => void)[] = []; - const buffer = { - active: { type: "normal" as "normal" | "alternate" }, - onBufferChange(cb: () => void) { - bufferListeners.push(cb); - return { dispose: () => {} }; - }, - }; - return { - handlers, - escHandlers, - oscHandlers, - buffer, - /** What xterm does on `CSI ?1049h` / `l`: switch, then announce. */ - switchScreen(type: "normal" | "alternate") { - buffer.active.type = type; - bufferListeners.forEach((cb) => cb()); - }, - parser: { - registerCsiHandler( - id: { prefix?: string; final: string }, - cb: (params: (number | number[])[]) => boolean, - ) { - const slot = `${id.prefix ?? ""}${id.final}`; - handlers.set(slot, cb); - return { - dispose: () => { - handlers.delete(slot); - }, - }; - }, - registerEscHandler(id: { final: string }, cb: () => boolean) { - escHandlers.set(id.final, cb); - return { - dispose: () => { - escHandlers.delete(id.final); - }, - }; - }, - registerOscHandler(ident: number, cb: (data: string) => boolean) { - oscHandlers.set(ident, cb); - return { - dispose: () => { - oscHandlers.delete(ident); - }, - }; - }, - }, - }; -} - afterEach(() => { forgetKittyState("t"); }); -describe("negotiation", () => { - it("is off until a program asks for it", () => { +/** + * The negotiation itself lives in the daemon (`core/src/terminal/kitty.rs`, + * driven by the status scanner) — what a window does is remember the mode each + * status reported and encode against it. + */ +describe("the mode the daemon reports", () => { + it("is off until a status says otherwise", () => { expect(kittyFlags("t")).toBe(0); }); - it("pushes, pops, and reports the mode on query", () => { - const term = fakeTerm(); - const replies: string[] = []; - registerKittyHandlers(term, "t", (d) => replies.push(d)); - - term.handlers.get(">u")!([DISAMBIGUATE]); + it("is whatever the last status carried", () => { + setKittyFlags("t", 1); expect(kittyFlags("t")).toBe(1); - - // A nested program pushes its own richer mode... - term.handlers.get(">u")!([9]); + // A program pushed a richer mode, then popped back to the shell's. + setKittyFlags("t", 9); expect(kittyFlags("t")).toBe(9); - - term.handlers.get("?u")!([]); - expect(replies[replies.length - 1]).toBe("\x1b[?9u"); - - // ...and popping restores what the parent had. - term.handlers.get(" { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - - term.handlers.get("=u")!([1, 1]); // set - expect(kittyFlags("t")).toBe(1); - term.handlers.get("=u")!([8, 2]); // or - expect(kittyFlags("t")).toBe(9); - term.handlers.get("=u")!([1, 3]); // not - expect(kittyFlags("t")).toBe(8); - }); - - /** A garbled sequence must not silently change how every key is encoded. */ - it("ignores malformed requests rather than guessing", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.handlers.get(">u")!([1]); - - term.handlers.get("=u")!([2, 99]); // mode outside 1..3 - expect(kittyFlags("t")).toBe(1); - }); - - // A push carrying bits we do not implement is a valid push, not a garbled - // one — the protocol reserves room above the five bits here. Dropping it - // while still honouring the program's later pop would unwind a level it - // never pushed, taking the shell's mode with it. - it("masks unknown flag bits instead of dropping the push", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.handlers.get(">u")!([1]); - - term.handlers.get(">u")!([999]); // wider than five bits - expect(kittyFlags("t")).toBe(999 & 31); - - term.handlers.get(" { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.handlers.get(">u")!([1]); - term.handlers.get(" { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.handlers.get(">u")!([1]); - expect(kittyFlags("t")).toBe(1); - - // ESC c, what `reset` sends. - expect(term.escHandlers.get("c")!()).toBe(false); // xterm still resets - expect(kittyFlags("t")).toBe(0); - }); -}); - -describe("screen buffers", () => { - /** - * The bug this exists to prevent: a TUI enables the protocol on the alternate - * screen and is killed before it can pop. With one shared stack the shell - * inherits the mode and every keystroke encodes for a reader that is gone — - * Ctrl+C arrives as `CSI 99;5u`, so the terminal cannot even be told `reset`. - */ - it("does not let an alt-screen mode follow the shell home", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - - term.switchScreen("alternate"); - term.handlers.get(">u")!([DISAMBIGUATE]); - expect(kittyFlags("t")).toBe(1); - - // The TUI dies. Nothing pops; the shell simply gets its screen back. - term.switchScreen("normal"); + setKittyFlags("t", 0); expect(kittyFlags("t")).toBe(0); }); - it("keeps a program's mode while it visits the main screen", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - - term.switchScreen("alternate"); - term.handlers.get(">u")!([9]); - // Dropping out to run a child process and coming back is a normal thing - // for a full-screen program to do. - term.switchScreen("normal"); - term.switchScreen("alternate"); - expect(kittyFlags("t")).toBe(9); - }); - - it("keeps the shell's own mode across a program's visit", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - - // A shell that negotiated the protocol for its own line editor. - term.handlers.get(">u")!([DISAMBIGUATE]); - term.switchScreen("alternate"); - term.handlers.get(">u")!([9]); - term.switchScreen("normal"); + it("keeps terminals apart, and forgets one with its session", () => { + setKittyFlags("t", 1); + setKittyFlags("other", 9); expect(kittyFlags("t")).toBe(1); - }); - - it("clears both screens on a terminal reset", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.handlers.get(">u")!([DISAMBIGUATE]); - term.switchScreen("alternate"); - term.handlers.get(">u")!([9]); - - term.escHandlers.get("c")!(); - - // A reset returns to the main screen, and finds nothing set there... - expect(kittyFlags("t")).toBe(0); - // ...nor waiting on the screen it just left. - term.switchScreen("alternate"); - expect(kittyFlags("t")).toBe(0); - }); - - /** - * A TUI that dies without popping leaks its mode onto the alternate screen, - * where the next full-screen program would inherit it as keys it cannot - * parse. The shell integration's prompt mark is the "no program is running" - * signal that clears the leak — from both screens. - */ - it("clears leaked flags on both screens at a shell prompt", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.switchScreen("alternate"); - term.handlers.get(">u")!([25]); - term.switchScreen("normal"); // the TUI is killed; nothing popped - - term.oscHandlers.get(133)!("A"); - - term.switchScreen("alternate"); // vim starts - expect(kittyFlags("t")).toBe(0); - }); - - it("only the prompt-start mark clears; command marks do not", () => { - const term = fakeTerm(); - registerKittyHandlers(term, "t", () => {}); - term.handlers.get(">u")!([DISAMBIGUATE]); - // The command-start and command-end marks arrive *around* a running - // program — clearing on those would strip the mode out from under it. - term.oscHandlers.get(133)!("C"); - term.oscHandlers.get(133)!("D;0"); + forgetKittyState("other"); + expect(kittyFlags("other")).toBe(0); expect(kittyFlags("t")).toBe(1); - - // Prompt marks can carry parameters (`A;k=s`); still a prompt. - term.oscHandlers.get(133)!("A;k=s"); - expect(kittyFlags("t")).toBe(0); }); }); /** - * The fake above tests the logic; this tests the seam. Both fixes here read - * state that xterm owns — `buffer.active.type` and `modes` — off events xterm - * decides when to fire, and a fake that agrees with a wrong assumption about - * either would pass while the terminal stayed broken. + * The one piece of terminal state the encoder still reads out of xterm: DECCKM, + * off `term.modes`, which xterm owns and updates as it parses. A fake that + * agreed with a wrong assumption about it would pass while arrow keys stayed + * broken inside every pager. */ describe("against a real xterm", () => { const write = (term: Terminal, data: string) => new Promise((resolve) => term.write(data, resolve)); - it("tracks the screen buffer and DECCKM as xterm parses them", async () => { + it("tracks DECCKM as xterm parses it", async () => { const term = new Terminal({ allowProposedApi: true }); - registerKittyHandlers(term, "real", () => {}); - - // A TUI starts: alternate screen, protocol on, application cursor keys. - await write(term, "\x1b[?1049h"); - expect(term.buffer.active.type).toBe("alternate"); - await write(term, "\x1b[>1u"); - expect(kittyFlags("real")).toBe(1); - await write(term, "\x1b[?1h"); + // A TUI starts: alternate screen, protocol on (per the daemon), and + // application cursor keys. + setKittyFlags("t", 1); + await write(term, "\x1b[?1049h\x1b[?1h"); expect(term.modes.applicationCursorKeysMode).toBe(true); expect( - encodeKittyKey(key({ key: "ArrowUp" }), kittyFlags("real"), { + encodeKittyKey(key({ key: "ArrowUp" }), kittyFlags("t"), { applicationCursorKeys: term.modes.applicationCursorKeysMode, }), ).toBe("\x1bOA"); - // It exits without popping. The shell must get a clean keyboard back. - await write(term, "\x1b[?1049l"); - expect(term.buffer.active.type).toBe("normal"); - expect(kittyFlags("real")).toBe(0); - - forgetKittyState("real"); - term.dispose(); - }); - - /** - * The full shape of the bug this guards against: a kitty TUI is killed on - * the alternate screen, the shell prompts, and then vim — which never asks - * for the protocol — starts on that same alternate screen. Without the - * prompt-mark reset it inherits the dead program's flags and every - * keystroke reaches it as `CSI …u` sequences it silently drops. - */ - it("hands vim a clean keyboard after a kitty TUI died on the alt screen", async () => { - const term = new Terminal({ allowProposedApi: true }); - registerKittyHandlers(term, "real", () => {}); - - // TUI up, mode pushed, then killed: 1049l from its atexit teardown but no - // kitty pop — the leak. - await write(term, "\x1b[?1049h\x1b[>25u\x1b[?1049l"); - - // The shell integration prompts. - await write(term, "\x1b]133;A\x07"); - - // vim's actual startup negotiation (captured from vim 9.1 under - // TERM=xterm-256color): alt screen, modifyOtherKeys, DECCKM — no kitty. - await write(term, "\x1b[?1049h\x1b[>4;2m\x1b[?1h\x1b=\x1b[?2004h"); - - expect(kittyFlags("real")).toBe(0); - // null = xterm's stock key encoding, which is what vim expects. - expect( - encodeKittyKey(key({ key: "Escape" }), kittyFlags("real")), - ).toBeNull(); - - forgetKittyState("real"); term.dispose(); }); }); diff --git a/desktop/ui/components/Terminal/kitty-keys.ts b/desktop/ui/components/Terminal/kitty-keys.ts index e0993676..3f8e2019 100644 --- a/desktop/ui/components/Terminal/kitty-keys.ts +++ b/desktop/ui/components/Terminal/kitty-keys.ts @@ -12,16 +12,21 @@ * stack-based, so a TUI can enable it, a child process can push its own * setting, and popping restores whatever the parent had. * - * The stack is per screen buffer, which is the protocol's safety net rather - * than a detail: a full-screen program does its work on the alternate screen, - * so whatever it pushes there — and forgets to pop, or never gets the chance to - * pop because it was killed — cannot follow the shell back to the main screen. + * **Only the encoder lives here.** The stack a program pushes and pops is + * negotiated in the daemon (`core/src/terminal/kitty.rs`), which reads every + * PTY byte for the session's whole life, and the flags in force ride along on + * each session status as `kittyFlags` — which is what `setKittyFlags` records + * below. That is the only place the answer can be right: a window that + * re-derived the stack from replayed scrollback would miss a push that had + * scrolled out of the ring, and two windows on one terminal would each hold + * their own answer. The encoder stays here because it needs a DOM + * `KeyboardEvent`. * - * Scope: negotiation is complete. Encoding implements `disambiguate` (1), - * `report_events` (2), `report_all` (8) and `report_associated` (16). For - * `report_alternates` (4) the shifted key is reported but the base-layout key - * is not — deriving it needs `navigator.keyboard.getLayoutMap()`, which macOS - * WKWebView does not implement, and a wrong base key is worse than none. + * Scope: encoding implements `disambiguate` (1), `report_events` (2), + * `report_all` (8) and `report_associated` (16). For `report_alternates` (4) + * the shifted key is reported but the base-layout key is not — deriving it + * needs `navigator.keyboard.getLayoutMap()`, which macOS WKWebView does not + * implement, and a wrong base key is worse than none. * * Reference: the protocol as implemented by Ghostty (`src/input/key_encode.zig`, * `src/terminal/kitty/key.zig`). Independently written from that behaviour. @@ -35,217 +40,34 @@ const FLAG_REPORT_ALTERNATES = 4; const FLAG_REPORT_ALL = 8; const FLAG_REPORT_ASSOCIATED = 16; -/** Flags are five bits; anything wider is a malformed request. */ -const FLAGS_MAX = 31; - /** - * How deep the mode stack goes before the oldest entry is dropped. Programs - * push on entry and pop on exit; a leaked push must not be able to grow this - * without bound, so it wraps rather than allocating. + * The mode the daemon last reported for each terminal, keyed the same way as + * the terminal registry. Absent means the protocol is off, which is also what a + * terminal this window has heard nothing about yet has to be treated as. */ -const STACK_DEPTH = 8; - -/** Which buffer a terminal is showing. Each keeps its own mode stack. */ -type ScreenBuffer = "normal" | "alternate"; - -interface KittyState { - normal: number[]; - alternate: number[]; - screen: ScreenBuffer; -} - -/** Per-terminal mode state, keyed the same way as the terminal registry. */ -const states = new Map(); - -function stateFor(id: string): KittyState { - let state = states.get(id); - if (!state) { - state = { normal: [0], alternate: [0], screen: "normal" }; - states.set(id, state); - } - return state; -} - -function stackFor(id: string): number[] { - const state = stateFor(id); - return state[state.screen]; -} - -/** The flags currently in force, 0 when the protocol is off. */ -export function kittyFlags(id: string): number { - const stack = stackFor(id); - return stack[stack.length - 1] ?? 0; -} - -/** Drop a terminal's state (tab closed, session killed). */ -export function forgetKittyState(id: string): void { - states.delete(id); -} +const flagsById = new Map(); /** - * Follow the terminal onto the other screen buffer. + * Record the mode carried on a session status (`TerminalStatus.kittyFlags`). * - * The stacks do not merge and the one being left is not cleared: a program that - * drops to the main screen to run a child and comes back expects to find its - * own mode still in force. What it cannot do is impose that mode on the shell. - */ -function setScreen(id: string, screen: ScreenBuffer): void { - stateFor(id).screen = screen; -} - -/** - * Reset to "off" on both screens. A full terminal reset (RIS) clears the mode, - * otherwise a program that crashes mid-session leaves every later keystroke - * encoded for a protocol nothing is reading. + * Called for every status the store applies — the live stream and the one that + * comes back with a cold reattach's replay — so a window that has just opened + * on a long-running program encodes for the mode that program negotiated + * however long ago. */ -function resetKittyState(id: string): void { - states.set(id, { normal: [0], alternate: [0], screen: "normal" }); +export function setKittyFlags(id: string, flags: number): void { + if (flags > 0) flagsById.set(id, flags); + else flagsById.delete(id); } -/** - * Zero both stacks without touching which screen is active — the screen keeps - * tracking xterm's buffer, only the flags are declared dead. - * - * Called at a shell prompt (OSC 133;A). An interactive prompt means the shell - * owns the terminal: no full-screen program is alive, so flags still set on - * *either* screen were leaked by a program that died without popping. The - * per-screen stacks stop such a leak reaching the shell, but the alternate - * screen's copy would otherwise wait there for the next vim/less, which - * inherits the dead program's mode as keys it cannot parse. Kitty's own shell - * integration performs the same reset at each prompt. - * - * The accepted cost, same as kitty's: a TUI suspended with Ctrl+Z loses its - * pushed mode when the prompt redraws, and `fg` resumes it un-enhanced until - * it renegotiates. - */ -function clearLeakedFlags(id: string): void { - const state = stateFor(id); - state.normal = [0]; - state.alternate = [0]; -} - -function push(id: string, flags: number): void { - const stack = stackFor(id); - stack.push(flags); - // Wrap rather than grow: drop the oldest entry once we exceed the depth. - if (stack.length > STACK_DEPTH) stack.shift(); -} - -function pop(id: string, count: number): void { - const state = stateFor(id); - // A pop deeper than the stack is a program losing track of its own state; - // treat it as "put everything back" rather than half-unwinding. - if (count >= STACK_DEPTH) { - state[state.screen] = [0]; - return; - } - const stack = state[state.screen]; - for (let i = 0; i < count; i++) { - if (stack.length > 1) stack.pop(); - else stack[0] = 0; - } -} - -function set(id: string, flags: number, mode: number): void { - const stack = stackFor(id); - const top = stack.length - 1; - const current = stack[top] ?? 0; - // Modes outside 1..3 are malformed; leaving the state alone beats guessing, - // since a garbled sequence would otherwise silently change key encoding. - if (mode === 1) stack[top] = flags; - else if (mode === 2) stack[top] = current | flags; - else if (mode === 3) stack[top] = current & ~flags; -} - -/** First param as a plain number, ignoring any subparameters. */ -function param(params: (number | number[])[], index: number): number | null { - const value = params[index]; - if (typeof value === "number") return value; - if (Array.isArray(value) && typeof value[0] === "number") return value[0]; - return null; +/** The flags currently in force, 0 when the protocol is off. */ +export function kittyFlags(id: string): number { + return flagsById.get(id) ?? 0; } -/** - * Wire up the four negotiation sequences on a terminal. - * - * `reply` sends the response to a query back to the PTY. Returns a disposer. - */ -export function registerKittyHandlers( - term: { - parser: { - registerCsiHandler: ( - id: { prefix?: string; final: string }, - cb: (params: (number | number[])[]) => boolean, - ) => { dispose: () => void }; - registerEscHandler: ( - id: { final: string }, - cb: () => boolean, - ) => { dispose: () => void }; - registerOscHandler: ( - ident: number, - cb: (data: string) => boolean, - ) => { dispose: () => void }; - }; - buffer: { - active: { type: ScreenBuffer }; - onBufferChange: (cb: () => void) => { dispose: () => void }; - }; - }, - id: string, - reply: (data: string) => void, -): () => void { - const handlers = [ - // Which stack is live follows the screen buffer. Watching xterm's own event - // rather than parsing `CSI ?1049h` covers every way in — 47, 1047, 1049 and - // a reset all arrive here as one signal. - term.buffer.onBufferChange(() => { - setScreen(id, term.buffer.active.type); - }), - // CSI ? u — what mode are we in? - term.parser.registerCsiHandler({ prefix: "?", final: "u" }, () => { - reply(`\x1b[?${kittyFlags(id)}u`); - return true; - }), - // CSI > flags u — push. No parameter means "push 0", i.e. disable. - term.parser.registerCsiHandler({ prefix: ">", final: "u" }, (params) => { - const flags = params.length === 1 ? (param(params, 0) ?? 0) : 0; - // Mask unknown bits rather than refusing the push. The protocol reserves - // room above the five bits we implement, and dropping the push while - // still honouring the program's later pop unwinds a level it never - // pushed — taking the shell's mode with it. Ghostty masks for the same - // reason. - push(id, flags & FLAGS_MAX); - return true; - }), - // CSI < n u — pop n levels, defaulting to one. - term.parser.registerCsiHandler({ prefix: "<", final: "u" }, (params) => { - pop(id, params.length === 1 ? (param(params, 0) ?? 1) : 1); - return true; - }), - // CSI = flags ; mode u — set/or/clear in place. - term.parser.registerCsiHandler({ prefix: "=", final: "u" }, (params) => { - const flags = param(params, 0) ?? 0; - const mode = params.length >= 2 ? (param(params, 1) ?? 1) : 1; - if (flags <= FLAGS_MAX) set(id, flags, mode); - return true; - }), - // ESC c — a full terminal reset. A program that enabled the protocol and - // then died without popping would otherwise leave every later keystroke - // encoded for a reader that is gone; `reset` is how a user fixes that. - term.parser.registerEscHandler({ final: "c" }, () => { - resetKittyState(id); - // Not handled: xterm still needs to do the actual reset. - return false; - }), - // OSC 133;A — the shell integration marking a prompt. The automatic - // version of the `reset` above: see clearLeakedFlags. - term.parser.registerOscHandler(133, (data) => { - if (data === "A" || data.startsWith("A;")) clearLeakedFlags(id); - // Not ours exclusively — the mark stays visible to any other consumer. - return false; - }), - ]; - return () => handlers.forEach((h) => h.dispose()); +/** Drop a terminal's mode (tab closed, session killed). */ +export function forgetKittyState(id: string): void { + flagsById.delete(id); } // --------------------------------------------------------------------------- diff --git a/desktop/ui/components/Terminal/registry.ts b/desktop/ui/components/Terminal/registry.ts index 03a63b12..77488f39 100644 --- a/desktop/ui/components/Terminal/registry.ts +++ b/desktop/ui/components/Terminal/registry.ts @@ -18,12 +18,7 @@ import { IS_MAC, matchesEvent } from "../../commands/shortcuts"; import { getAllCommands } from "../../commands/registry"; import { getPlatformServices } from "../../platform"; import { buildXtermTheme } from "./xterm-theme"; -import { - encodeKittyKey, - forgetKittyState, - kittyFlags, - registerKittyHandlers, -} from "./kitty-keys"; +import { encodeKittyKey, forgetKittyState, kittyFlags } from "./kitty-keys"; /** * Module-level registry of live xterm instances, keyed by terminal id. This is @@ -45,8 +40,6 @@ interface RegistryEntry { webgl: WebglAddon | null; /** Detaches the output stream; called only from disposeTerminal. */ unsubOutput: (() => void) | null; - /** Removes the kitty keyboard negotiation handlers. */ - disposeKitty: (() => void) | null; /** * Live output held back until a cold reattach's replay has been written, so * historical scrollback lands ahead of new bytes. `null` once flushed. @@ -127,12 +120,6 @@ export function acquireTerminal( // The grid changing shape is the other half of "a row is this tall" — the // pane was resized, or the font reflowed it. term.onResize(() => forgetCellHeight(term)); - // Lets a program negotiate the kitty keyboard protocol, so chords like - // Ctrl+Enter and Shift+Tab arrive distinguishable instead of collapsing onto - // the same bytes as their unmodified forms. - const disposeKitty = registerKittyHandlers(term, id, (data) => - writeToPty(id, data), - ); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon((_event, uri) => openTerminalLink(uri))); @@ -153,7 +140,6 @@ export function acquireTerminal( fit, webgl: null, unsubOutput: null, - disposeKitty, // A brand-new instance has nothing on screen yet, so hold output until the // caller has decided whether it needs a replay first. pending: [], @@ -488,8 +474,6 @@ export function disposeTerminal(id: string): void { if (!entry) return; entry.unsubOutput?.(); entry.unsubOutput = null; - entry.disposeKitty?.(); - entry.disposeKitty = null; // The keyboard mode belongs to the program that negotiated it, so it dies // with the session rather than leaking into whatever reuses this id. forgetKittyState(id); diff --git a/desktop/ui/stores/slices/terminalSlice.ts b/desktop/ui/stores/slices/terminalSlice.ts index a94046dc..12757c93 100644 --- a/desktop/ui/stores/slices/terminalSlice.ts +++ b/desktop/ui/stores/slices/terminalSlice.ts @@ -10,6 +10,7 @@ import type { import { makeReviewKey } from "../../utils/review-key"; import { notifyTerminalAttention } from "../../utils/terminal-notifications"; import type { SliceCreatorWithClientAndStorage } from "../types"; +import { setKittyFlags } from "../../components/Terminal/kitty-keys"; import { type TerminalTab, type PaneNode, @@ -1986,6 +1987,11 @@ export const createTerminalSlice: SliceCreatorWithClientAndStorage< // replaced. A second delivery of the same status finds prev === next // and stays quiet. notifyTerminalAttention(get().terminalStatuses[status.id], status); + // Keystroke encoding reads the negotiated kitty mode straight out of the + // registry, which cannot reach the store (it is imported by + // preferencesSlice), so the mode is handed over here — the one funnel + // every status passes through, live stream and cold-reattach replay both. + setKittyFlags(status.id, status.kittyFlags); set(applyTerminalStatus(get(), status)); }, applyTerminalExit: (exit) => set(applyTerminalExit(get(), exit)), diff --git a/desktop/ui/test/fixtures.ts b/desktop/ui/test/fixtures.ts index 54eb9633..dad4c2ee 100644 --- a/desktop/ui/test/fixtures.ts +++ b/desktop/ui/test/fixtures.ts @@ -1,7 +1,7 @@ /** * Fixture builders shared across tests. * - * A `TerminalStatus` has nine fields and most tests care about one of them, so + * A `TerminalStatus` has ten fields and most tests care about one of them, so * every suite that touched one had grown its own copy of the same literal. One * builder here means a field added to the type is fixed in one place, and each * suite states only the part it is actually about. @@ -27,6 +27,7 @@ export function terminalStatus( title: null, enteredStateAt: 0, shellIntegrationActive: false, + kittyFlags: 0, attentionMessage: null, ...overrides, }; diff --git a/desktop/ui/types/index.ts b/desktop/ui/types/index.ts index bd87d1bc..ebb90b83 100644 --- a/desktop/ui/types/index.ts +++ b/desktop/ui/types/index.ts @@ -852,6 +852,13 @@ export interface TerminalStatus { /** Epoch millis when the session entered its current phase. */ enteredStateAt: number; shellIntegrationActive: boolean; + /** + * Kitty keyboard protocol flags the running program negotiated, 0 when the + * protocol is off. The push/pop stack behind them lives in the daemon, which + * sees every PTY byte for the session's whole life; a window encodes + * keystrokes against whatever this last said (see `Terminal/kitty-keys.ts`). + */ + kittyFlags: number; /** * Text of the desktop-notification escape that raised the attention overlay * (OSC 9 from Codex, OSC 777 from Claude Code). Null when the overlay is