From b52eef68e110e3f6eac3c138ca7fcee90f1da4e4 Mon Sep 17 00:00:00 2001 From: PullApprove Agent Date: Fri, 7 Aug 2026 03:25:27 +0000 Subject: [PATCH] Move the kitty keyboard mode stack into the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The negotiated half of the kitty keyboard protocol lived in a module-level Map in the frontend, derived by re-parsing PTY bytes as xterm saw them. Two things follow from that. A window that reattaches cold rebuilds its xterm from the 256 KiB replay ring, so for any program that has produced more output than that, the push it made at startup has scrolled out and every chord silently reverts to legacy encoding while the program still believes the protocol is on. And a per-realm Map means two windows on one terminal each hold their own answer, at most one of which is right. The stack now lives next to the vte::Parser the status scanner already runs over every PTY byte for the session's whole life: one stack, never truncated, shared by every window. It rides out on the session status as `kittyFlags`, the way phase/cwd/title already do, and `CSI ? u` is answered on the reader thread — so a program gets its answer even with no window attached, instead of round-tripping to the frontend. The encoder stays in the frontend, where it needs a DOM KeyboardEvent; it now reads the mode the daemon reported rather than one it derived. Also handles DECSTR (`CSI ! p`), which was unhandled: like RIS it puts a stuck keyboard back. Co-Authored-By: Claude Opus 5 --- core/src/terminal/kitty.rs | 314 ++++++++++++++++++ core/src/terminal/mod.rs | 2 + core/src/terminal/session.rs | 45 ++- core/src/terminal/status.rs | 222 ++++++++++++- core/src/terminal/wire.rs | 8 + .../ui/components/Terminal/kitty-keys.test.ts | 306 ++--------------- desktop/ui/components/Terminal/kitty-keys.ts | 242 ++------------ desktop/ui/components/Terminal/registry.ts | 18 +- desktop/ui/stores/slices/terminalSlice.ts | 6 + desktop/ui/test/fixtures.ts | 3 +- desktop/ui/types/index.ts | 7 + 11 files changed, 653 insertions(+), 520 deletions(-) create mode 100644 core/src/terminal/kitty.rs 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