diff --git a/crates/embers-cli/src/interactive.rs b/crates/embers-cli/src/interactive.rs index 5361740e..ca4352f4 100644 --- a/crates/embers-cli/src/interactive.rs +++ b/crates/embers-cli/src/interactive.rs @@ -6,8 +6,8 @@ use std::thread; use std::time::Duration; use embers_client::{ - ConfigManager, ConfiguredClient, KeyEvent, MouseButton, MouseEvent, MouseEventKind, - MouseModifiers, MuxClient, RenderGrid, SocketTransport, + ConfigManager, ConfiguredClient, KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent, + MouseEventKind, MouseModifiers, MuxClient, RenderGrid, SocketTransport, }; use embers_core::{CursorShape, MuxError, Result, SessionId, Size}; use embers_protocol::{BufferRequest, ClientMessage, ServerEvent, ServerResponse, SessionRequest}; @@ -84,7 +84,7 @@ pub async fn run( loop { match input_rx.try_recv() { - Ok(TerminalEvent::Key(KeyEvent::Ctrl('q'))) => return Ok(()), + Ok(TerminalEvent::Key(key)) if is_quit_key(&key) => return Ok(()), Ok(TerminalEvent::Key(key)) => { if let Some(active_session_id) = session_id { let viewport = content_viewport(terminal_size); @@ -135,6 +135,7 @@ pub async fn run( dirty = true; } }, + Ok(TerminalEvent::Ignored) => {} Ok(TerminalEvent::InputClosed) => return Ok(()), Ok(TerminalEvent::InputError(message)) => { return Err(MuxError::transport(message)); @@ -434,6 +435,10 @@ enum TerminalEvent { Mouse(MouseEvent), Paste(Vec), Focus(bool), + /// A recognized report that carries no input to act on (e.g. a kitty + /// key-release when report-event-types is negotiated). Distinguished from an + /// unrecognized sequence so it isn't forwarded to the program as raw bytes. + Ignored, ConfigChanged, InputClosed, InputError(String), @@ -561,6 +566,26 @@ fn read_escape_event(fd: libc::c_int) -> Result { } } +/// Match the hard-coded Ctrl+Q quit chord in both encodings the host terminal +/// can deliver: the legacy control byte, and the CSI-u report sent once the +/// kitty keyboard protocol is negotiated. +fn is_quit_key(key: &KeyEvent) -> bool { + match key { + KeyEvent::Ctrl('q') => true, + KeyEvent::Key { + code: KeyCode::Char('q'), + mods, + } => { + *mods + == Modifiers { + ctrl: true, + ..Modifiers::NONE + } + } + _ => false, + } +} + fn read_csi_event(fd: libc::c_int) -> Result { let bytes = read_control_sequence(fd, b'[')?; if bytes == b"\x1b[200~" { @@ -580,6 +605,12 @@ fn read_ss3_event(fd: libc::c_int) -> Result { b'B' => Some(KeyEvent::Down), b'C' => Some(KeyEvent::Right), b'D' => Some(KeyEvent::Left), + // Unmodified F1-F4 arrive as SS3 P..S on typical terminals; only the + // modified forms use the `CSI 1;mods P` shape parsed elsewhere. + b'P'..=b'S' => Some(KeyEvent::Key { + code: KeyCode::Function(final_byte - b'P' + 1), + mods: Modifiers::NONE, + }), _ => None, }; Ok(match key { @@ -615,12 +646,155 @@ fn parse_csi_event(bytes: &[u8]) -> Option { b"\x1b[4~" | b"\x1b[F" => Some(TerminalEvent::Key(KeyEvent::End)), b"\x1b[5~" => Some(TerminalEvent::Key(KeyEvent::PageUp)), b"\x1b[6~" => Some(TerminalEvent::Key(KeyEvent::PageDown)), + // Legacy backtab: the non-kitty encoding of Shift+Tab. + b"\x1b[Z" => Some(TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Tab, + mods: Modifiers { + shift: true, + ..Modifiers::NONE + }, + })), b"\x1b[I" => Some(TerminalEvent::Focus(true)), b"\x1b[O" => Some(TerminalEvent::Focus(false)), - _ => parse_sgr_mouse(bytes).map(TerminalEvent::Mouse), + _ => match parse_extended_key(bytes) { + Some(ExtendedKey::Event(key)) => Some(TerminalEvent::Key(key)), + // Recognized but carries no input (e.g. a key release): consume it so + // it never reaches the raw-byte fallback in `read_csi_event`. + Some(ExtendedKey::Consumed) => Some(TerminalEvent::Ignored), + None => parse_sgr_mouse(bytes).map(TerminalEvent::Mouse), + }, } } +/// The outcome of parsing an extended key report: a real key event, or a +/// recognized report that should produce no input (so callers don't confuse it +/// with an unrecognized sequence and echo raw bytes). +enum ExtendedKey { + Event(KeyEvent), + Consumed, +} + +/// Decode kitty modifier parameters (`1 + bitmask`) into [`Modifiers`]. The +/// caps-lock (64) and num-lock (128) bits are ignored — lock state doesn't +/// change which chord was pressed. Returns `None` for masks carrying modifiers +/// we can't represent (hyper, meta), so the caller falls back to raw bytes +/// rather than silently dropping a modifier. +fn decode_kitty_mods(value: u32) -> Option { + // The parameter is `1 + bitmask`; value 0 is malformed (there's no bitmask), + // so reject it rather than treating it as an empty modifier set. + let mask = value.checked_sub(1)? & !(64 | 128); + if mask & !0b1111 != 0 { + return None; + } + Some(Modifiers { + shift: mask & 1 != 0, + alt: mask & 2 != 0, + ctrl: mask & 4 != 0, + super_: mask & 8 != 0, + }) +} + +/// Parse a CSI-u (`CSI code;mods u`) or modified-legacy (`CSI 1;mods A`, +/// `CSI n;mods ~`) key report into a modifier-carrying key event. Unmodified +/// plain sequences are handled by the exact matches in `parse_csi_event`. +fn parse_extended_key(bytes: &[u8]) -> Option { + // Work on bytes: a CSI parameter/final region is ASCII, and a malformed + // sequence buffered up to the continuation timeout can carry an unterminated + // multibyte character, where splitting a `&str` mid-char would panic. + let body = bytes.strip_prefix(b"\x1b[")?; + let (&final_byte, params_bytes) = body.split_last()?; + let params = std::str::from_utf8(params_bytes).ok()?; + + let mut parts = params.split(';'); + let first: u32 = parts.next()?.parse().ok()?; + // Parse the modifier value and optional event type, but don't act on the + // event type until the key itself is validated below. + let (modifier_value, event_type) = match parts.next() { + Some(modifier) => { + let mut sub = modifier.split(':'); + let value: u32 = sub.next()?.parse().ok()?; + let event_type = sub.next(); + // The modifier field carries at most `mods:event-type`; a report + // with further colon-separated fields is malformed. + if sub.next().is_some() { + return None; + } + (value, event_type) + } + None => (1, None), + }; + if parts.next().is_some() { + return None; + } + + let code = match final_byte { + b'u' => match first { + 9 => KeyCode::Tab, + 13 => KeyCode::Enter, + 27 => KeyCode::Escape, + 127 => KeyCode::Backspace, + other => { + // Kitty names functional keys (keypad, media keys, F13+) with + // Private Use Area code points (57344..=63743); none of them is + // a key we model, so fall back to raw bytes rather than + // reporting a typed PUA character. + if (57344..=63743).contains(&other) { + return None; + } + KeyCode::Char(char::from_u32(other)?) + } + }, + // Modified-legacy letter finals are `CSI 1;mods `: the leading + // parameter must be 1, otherwise it isn't a key report we recognize. + b'A' if first == 1 => KeyCode::Up, + b'B' if first == 1 => KeyCode::Down, + b'C' if first == 1 => KeyCode::Right, + b'D' if first == 1 => KeyCode::Left, + b'H' if first == 1 => KeyCode::Home, + b'F' if first == 1 => KeyCode::End, + b'P' if first == 1 => KeyCode::Function(1), + b'Q' if first == 1 => KeyCode::Function(2), + // No `CSI 1;mods R` arm for F3: that shape is also a cursor-position + // report (`CSI row;col R`), so terminals send modified F3 as its vt220 + // tilde number instead (`CSI 13;mods~`), matched below. + b'S' if first == 1 => KeyCode::Function(4), + b'~' => match first { + 2 => KeyCode::Insert, + 3 => KeyCode::Delete, + 5 => KeyCode::PageUp, + 6 => KeyCode::PageDown, + 13 => KeyCode::Function(3), + 15 => KeyCode::Function(5), + 17 => KeyCode::Function(6), + 18 => KeyCode::Function(7), + 19 => KeyCode::Function(8), + 20 => KeyCode::Function(9), + 21 => KeyCode::Function(10), + 23 => KeyCode::Function(11), + 24 => KeyCode::Function(12), + _ => return None, + }, + _ => return None, + }; + + // Now that the key is known-supported, interpret the event type: consume a + // key-release report (type 3); accept press (1)/repeat (2) or an absent type; + // treat any other event type as unrecognized rather than silently consuming + // it. Prevents a release from being acted on as a press if report-event-types + // is ever negotiated. + match event_type { + None | Some("1") | Some("2") => {} + Some("3") => return Some(ExtendedKey::Consumed), + Some(_) => return None, + } + + // A bare `CSI code u` with no modifiers still round-trips as a Key event, + // but plain arrows/nav without modifiers are matched exactly upstream. An + // unrepresentable modifier mask falls back to raw bytes. + let mods = decode_kitty_mods(modifier_value)?; + Some(ExtendedKey::Event(KeyEvent::Key { code, mods })) +} + fn parse_sgr_mouse(bytes: &[u8]) -> Option { let text = std::str::from_utf8(bytes).ok()?; let body = text.strip_prefix("\x1b[<")?; @@ -904,11 +1078,16 @@ fn terminal_enter_sequence(mouse_capture_enabled: bool) -> String { if mouse_capture_enabled { sequence.push_str(TERMINAL_ENABLE_MOUSE_SEQUENCE); } + // Push the kitty keyboard protocol (disambiguate-only) so the host terminal + // reports modifier combinations like C-Tab / C-S-Tab as CSI-u. Terminals + // that don't support it ignore this harmlessly. + sequence.push_str("\x1b[>1u"); sequence } fn terminal_exit_sequence(mouse_capture_enabled: bool) -> String { - let mut sequence = String::from("\x1b[0m\x1b[2 q\x1b[?25h\x1b[?2004l"); + // Pop the kitty keyboard protocol pushed on enter. + let mut sequence = String::from("\x1b[ u8 { mod tests { use super::{ TERMINAL_DISABLE_MOUSE_SEQUENCE, TERMINAL_ENABLE_MOUSE_SEQUENCE, TerminalEvent, - read_terminal_event, terminal_enter_sequence, terminal_exit_sequence, + is_quit_key, read_terminal_event, terminal_enter_sequence, terminal_exit_sequence, + }; + use embers_client::{ + KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind, MouseModifiers, }; - use embers_client::{KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers}; + + #[test] + fn parses_csi_u_and_modified_legacy_keys() { + let key = |bytes: &[u8]| { + with_pipe(bytes, read_terminal_event) + .expect("read succeeds") + .expect("event produced") + }; + + // Ctrl+I disambiguated from Tab (CSI 105 ; 5 u). + assert_eq!( + key(b"\x1b[105;5u"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Char('i'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }) + ); + // Ctrl+Shift+Tab. + assert_eq!( + key(b"\x1b[9;6u"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Tab, + mods: Modifiers { + shift: true, + ctrl: true, + ..Modifiers::NONE + }, + }) + ); + // Modified legacy arrow: Shift+Left. + assert_eq!( + key(b"\x1b[1;2D"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Left, + mods: Modifiers { + shift: true, + ..Modifiers::NONE + }, + }) + ); + // Modified function key: Alt+F5 (CSI 15 ; 3 ~). + assert_eq!( + key(b"\x1b[15;3~"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Function(5), + mods: Modifiers { + alt: true, + ..Modifiers::NONE + }, + }) + ); + // Unknown CSI falls back to raw bytes. + assert_eq!( + key(b"\x1b[99z"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[99z".to_vec())) + ); + // A CSI carrying an unterminated multibyte char must not panic; it falls + // back to raw bytes (é = 0xc3 0xa9, neither a CSI final byte). + assert_eq!( + key(b"\x1b[\xc3\xa9"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[\xc3\xa9".to_vec())) + ); + // A key-release report (event type 3) is consumed, not acted on as a + // press nor echoed to the program as raw bytes. + assert_eq!(key(b"\x1b[97;5:3u"), TerminalEvent::Ignored); + // A press with an explicit event type (1) still decodes. + assert_eq!( + key(b"\x1b[97;5:1u"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Char('a'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }) + ); + // An unknown event type (not 1/2/3) is not consumed: it's unrecognized + // and falls back to raw bytes rather than being silently swallowed. + assert_eq!( + key(b"\x1b[97;5:4u"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;5:4u".to_vec())) + ); + // The modifier field is at most `mods:event-type`; extra colon fields + // are malformed, not silently ignored. + assert_eq!( + key(b"\x1b[97;5:1:2u"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;5:1:2u".to_vec())) + ); + // A release for an unsupported key is likewise unrecognized (the key is + // validated before the event type), not consumed. + assert_eq!( + key(b"\x1b[97;5:3z"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;5:3z".to_vec())) + ); + // A modifier mask carrying an unrepresentable bit (hyper = mask bit 4, + // value 17) falls back to raw bytes instead of dropping the modifier. + assert_eq!( + key(b"\x1b[97;17u"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;17u".to_vec())) + ); + // A modifier value of 0 is malformed (the encoding is 1 + bitmask), so it + // falls back to raw bytes rather than decoding as an empty modifier set. + assert_eq!( + key(b"\x1b[97;0u"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[97;0u".to_vec())) + ); + // A modified-legacy letter final is only valid with a leading 1; a + // different leading parameter is not a recognized key report. + assert_eq!( + key(b"\x1b[5A"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[5A".to_vec())) + ); + // The valid `CSI 1;mods A` form still decodes as a modified arrow. + assert_eq!( + key(b"\x1b[1;2A"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Up, + mods: Modifiers { + shift: true, + ..Modifiers::NONE + }, + }) + ); + } + + #[test] + fn parses_legacy_forms_of_extended_keys() { + let key = |bytes: &[u8]| { + with_pipe(bytes, read_terminal_event) + .expect("read succeeds") + .expect("event produced") + }; + + // Unmodified F1 arrives as SS3 P on typical terminals. + assert_eq!( + key(b"\x1bOP"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Function(1), + mods: Modifiers::NONE, + }) + ); + // Legacy backtab is Shift+Tab. + assert_eq!( + key(b"\x1b[Z"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Tab, + mods: Modifiers { + shift: true, + ..Modifiers::NONE + }, + }) + ); + // Modified F3 arrives as its vt220 tilde number (CSI 13;mods~). + assert_eq!( + key(b"\x1b[13;2~"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Function(3), + mods: Modifiers { + shift: true, + ..Modifiers::NONE + }, + }) + ); + // `CSI 1;2R` is a cursor-position report, not Shift+F3: it must fall + // back to raw bytes rather than decode as a key. + assert_eq!( + key(b"\x1b[1;2R"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[1;2R".to_vec())) + ); + } + + #[test] + fn unmapped_kitty_reports_degrade_gracefully() { + let key = |bytes: &[u8]| { + with_pipe(bytes, read_terminal_event) + .expect("read succeeds") + .expect("event produced") + }; + + // Kitty functional keys use Private Use Area code points (KP_Enter = + // 57414): they fall back to raw bytes, never to typed PUA characters. + assert_eq!( + key(b"\x1b[57414u"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[57414u".to_vec())) + ); + assert_eq!( + key(b"\x1b[57414;5u"), + TerminalEvent::Key(KeyEvent::Bytes(b"\x1b[57414;5u".to_vec())) + ); + // Lock modifiers are stripped, not treated as unrepresentable: Ctrl+H + // with num-lock active (mask 4|128, value 133) still decodes as Ctrl+H. + assert_eq!( + key(b"\x1b[104;133u"), + TerminalEvent::Key(KeyEvent::Key { + code: KeyCode::Char('h'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }) + ); + } + + #[test] + fn quit_chord_matches_both_encodings() { + assert!(is_quit_key(&KeyEvent::Ctrl('q'))); + // The CSI-u report a kitty-protocol host sends for Ctrl+Q. + assert!(is_quit_key(&KeyEvent::Key { + code: KeyCode::Char('q'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + })); + // Extra modifiers are a different chord. + assert!(!is_quit_key(&KeyEvent::Key { + code: KeyCode::Char('q'), + mods: Modifiers { + ctrl: true, + shift: true, + ..Modifiers::NONE + }, + })); + } fn with_pipe(bytes: &[u8], test: impl FnOnce(libc::c_int) -> T) -> T { let mut fds = [0; 2]; diff --git a/crates/embers-client/src/configured_client.rs b/crates/embers-client/src/configured_client.rs index 948593f9..8a40440e 100644 --- a/crates/embers-client/src/configured_client.rs +++ b/crates/embers-client/src/configured_client.rs @@ -194,6 +194,12 @@ where self.set_active_view(session_id, viewport); let presentation = self.prepare_presentation(session_id, viewport).await?; + // Under report-all-keys the host terminal sends CSI-u for every key, + // including ordinary typed text. Normalise an unmodified text key to its + // legacy form up front so every downstream path — binding tokenization, + // hints label matching, and search input — treats both encodings alike. + let key = normalize_text_key(key); + if self.input_state.current_mode() == SEARCH_MODE { return self .handle_search_key(session_id, viewport, &presentation, key) @@ -230,11 +236,12 @@ where &binding.target, ) { let buffer_id = self.resolve_buffer_id(None, &presentation)?; + let mode = self.buffer_keyboard_mode(buffer_id); return self .send_bytes_to_buffer( buffer_id, session_id, - sequence_to_bytes(&binding.sequence)?, + sequence_to_bytes(&binding.sequence, mode)?, ) .await; } @@ -253,10 +260,11 @@ where } => match fallback_policy { FallbackPolicy::Passthrough => { let buffer_id = self.resolve_buffer_id(None, &presentation)?; + let mode = self.buffer_keyboard_mode(buffer_id); self.send_bytes_to_buffer( buffer_id, session_id, - sequence_to_bytes(&sequence)?, + sequence_to_bytes(&sequence, mode)?, ) .await } @@ -1261,7 +1269,8 @@ where } Action::SendKeys { buffer_id, keys } => { let buffer_id = self.resolve_buffer_id(buffer_id, presentation)?; - self.send_bytes_to_buffer(buffer_id, session_id, sequence_to_bytes(&keys)?) + let mode = self.buffer_keyboard_mode(buffer_id); + self.send_bytes_to_buffer(buffer_id, session_id, sequence_to_bytes(&keys, mode)?) .await } Action::SendBytes { buffer_id, bytes } => { @@ -1943,6 +1952,17 @@ where self.client.resync_all_sessions().await } + /// The kitty keyboard mode negotiated by the given buffer's inner program, + /// used so passthrough keys are re-encoded the way that program expects. + fn buffer_keyboard_mode(&self, buffer_id: BufferId) -> u8 { + self.client + .state() + .snapshots + .get(&buffer_id) + .map(|snapshot| snapshot.keyboard_mode) + .unwrap_or(0) + } + /// The working directory of the focused buffer in the given session, if known. fn focused_buffer_cwd(&self, session_id: Option) -> Option { let session_id = session_id.or(self.active_session_id)?; @@ -2287,7 +2307,8 @@ where | KeyEvent::Insert | KeyEvent::Delete | KeyEvent::PageUp - | KeyEvent::PageDown => Ok(()), + | KeyEvent::PageDown + | KeyEvent::Key { .. } => Ok(()), } } @@ -3256,6 +3277,9 @@ fn default_shell_command() -> Vec { fn key_event_to_token(key: KeyEvent) -> Result { match key { + // A space keypress must match the `` binding token, which the + // grammar produces for a literal space. + KeyEvent::Char(' ') => Ok(KeyToken::Space), KeyEvent::Char(ch) => Ok(KeyToken::Char(ch)), KeyEvent::Enter => Ok(KeyToken::Enter), KeyEvent::Tab => Ok(KeyToken::Tab), @@ -3273,55 +3297,112 @@ fn key_event_to_token(key: KeyEvent) -> Result { KeyEvent::Delete => Ok(KeyToken::Delete), KeyEvent::PageUp => Ok(KeyToken::PageUp), KeyEvent::PageDown => Ok(KeyToken::PageDown), + KeyEvent::Key { code, mods } => Ok(normalize_key_token(code, mods)), KeyEvent::Bytes(_) => Err(MuxError::invalid_input("raw bytes are handled separately")), } } -fn sequence_to_bytes(sequence: &[KeyToken]) -> Result> { +/// Normalise an unmodified CSI-u report for an ordinary text key (the kitty +/// encoding of typed text) to its legacy `KeyEvent`, so text-consuming modes like +/// search treat both encodings identically. Modified keys and non-text keys are +/// returned unchanged. +fn normalize_text_key(key: KeyEvent) -> KeyEvent { + use crate::input::KeyCode; + match key { + KeyEvent::Key { code, mods } if mods.is_empty() => match code { + KeyCode::Char(ch) => KeyEvent::Char(ch), + KeyCode::Tab => KeyEvent::Tab, + KeyCode::Enter => KeyEvent::Enter, + KeyCode::Backspace => KeyEvent::Backspace, + KeyCode::Escape => KeyEvent::Escape, + other => KeyEvent::Key { code: other, mods }, + }, + other => other, + } +} + +/// Collapse a single-modifier character key to the legacy `Ctrl`/`Alt` token so +/// a CSI-u report (e.g. `\x1b[104;5u` for Ctrl+H) matches the same `` +/// binding a legacy control byte would, keeping the two key representations +/// coherent. Everything else stays a `Key` token. +fn normalize_key_token(code: crate::input::KeyCode, mods: crate::input::Modifiers) -> KeyToken { + use crate::input::{KeyCode, Modifiers}; + if let KeyCode::Char(ch) = code { + if mods + == (Modifiers { + ctrl: true, + ..Modifiers::NONE + }) + { + return KeyToken::Ctrl(ch.to_ascii_lowercase()); + } + if mods + == (Modifiers { + alt: true, + ..Modifiers::NONE + }) + { + return KeyToken::Alt(ch.to_ascii_lowercase()); + } + } + // Binding tokens store character keys lowercased (shift is a modifier bit), + // so lowercase here too for hosts that report the shifted code point. + let code = match code { + KeyCode::Char(ch) => KeyCode::Char(ch.to_ascii_lowercase()), + other => other, + }; + KeyToken::Key { code, mods } +} + +fn sequence_to_bytes(sequence: &[KeyToken], mode: u8) -> Result> { + use crate::input::{KeyCode, Modifiers, encode_key}; + + let ctrl = Modifiers { + ctrl: true, + ..Modifiers::NONE + }; + let alt = Modifiers { + alt: true, + ..Modifiers::NONE + }; + let mut bytes = Vec::new(); for token in sequence { - match token { - KeyToken::Char(ch) => { - let mut encoded = [0; 4]; - bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - KeyToken::Space => bytes.push(b' '), - KeyToken::Tab => bytes.push(b'\t'), - KeyToken::Enter => bytes.push(b'\r'), - KeyToken::Backspace => bytes.push(0x7f), - KeyToken::Escape => bytes.push(0x1b), - KeyToken::Ctrl(ch) => bytes.push(ctrl_byte(*ch)?), - KeyToken::Alt(ch) => { - bytes.push(0x1b); - bytes.extend(sequence_to_bytes(&[KeyToken::Char(*ch)])?); - } - KeyToken::Up => bytes.extend_from_slice(b"\x1b[A"), - KeyToken::Down => bytes.extend_from_slice(b"\x1b[B"), - KeyToken::Left => bytes.extend_from_slice(b"\x1b[D"), - KeyToken::Right => bytes.extend_from_slice(b"\x1b[C"), - KeyToken::Home => bytes.extend_from_slice(b"\x1b[H"), - KeyToken::End => bytes.extend_from_slice(b"\x1b[F"), - KeyToken::Insert => bytes.extend_from_slice(b"\x1b[2~"), - KeyToken::Delete => bytes.extend_from_slice(b"\x1b[3~"), - KeyToken::PageUp => bytes.extend_from_slice(b"\x1b[5~"), - KeyToken::PageDown => bytes.extend_from_slice(b"\x1b[6~"), + // Route every supported token through the mode-aware encoder. In legacy + // or disambiguate-only mode it still emits the plain legacy bytes for + // unmodified keys; under report-all-keys every key (ordinary characters + // and special keys like Tab) becomes a CSI-u escape sequence. + let (code, mods) = match token { + KeyToken::Char(ch) => (KeyCode::Char(*ch), Modifiers::NONE), + KeyToken::Space => (KeyCode::Char(' '), Modifiers::NONE), + KeyToken::Tab => (KeyCode::Tab, Modifiers::NONE), + KeyToken::Enter => (KeyCode::Enter, Modifiers::NONE), + KeyToken::Backspace => (KeyCode::Backspace, Modifiers::NONE), + KeyToken::Escape => (KeyCode::Escape, Modifiers::NONE), + KeyToken::Ctrl(ch) => (KeyCode::Char(*ch), ctrl), + KeyToken::Alt(ch) => (KeyCode::Char(*ch), alt), + KeyToken::Up => (KeyCode::Up, Modifiers::NONE), + KeyToken::Down => (KeyCode::Down, Modifiers::NONE), + KeyToken::Left => (KeyCode::Left, Modifiers::NONE), + KeyToken::Right => (KeyCode::Right, Modifiers::NONE), + KeyToken::Home => (KeyCode::Home, Modifiers::NONE), + KeyToken::End => (KeyCode::End, Modifiers::NONE), + KeyToken::Insert => (KeyCode::Insert, Modifiers::NONE), + KeyToken::Delete => (KeyCode::Delete, Modifiers::NONE), + KeyToken::PageUp => (KeyCode::PageUp, Modifiers::NONE), + KeyToken::PageDown => (KeyCode::PageDown, Modifiers::NONE), + KeyToken::Key { code, mods } => (*code, *mods), KeyToken::Leader => { return Err(MuxError::invalid_input( "leader placeholders cannot be sent directly", )); } - } + }; + bytes.extend(encode_key(code, mods, mode)); } Ok(bytes) } -fn ctrl_byte(ch: char) -> Result { - if !ch.is_ascii() { - return Err(MuxError::invalid_input("control keys must be ASCII")); - } - Ok((ch.to_ascii_lowercase() as u8) & 0x1f) -} - async fn rollback_created_buffer_on_error( configured: &mut ConfiguredClient, buffer_id: Option, @@ -3427,12 +3508,192 @@ mod tests { }; use tempfile::tempdir; - use super::{ConfiguredClient, SearchPrompt, event_info}; + use super::{ConfiguredClient, SearchPrompt, event_info, key_event_to_token}; use crate::client::MuxClient; use crate::config::{ConfigDiscoveryOptions, ConfigManager}; - use crate::input::NORMAL_MODE; + use crate::controller::KeyEvent; + use crate::input::{KeyToken, NORMAL_MODE}; use crate::testing::FakeTransport; + #[test] + fn report_all_mode_routes_plain_keys_through_csi_u() { + use super::sequence_to_bytes; + + // Compact keyboard-mode bitfield: bit 0 = disambiguate, bit 3 = report-all. + const DISAMBIGUATE_ONLY: u8 = 0b0000_0001; + const REPORT_ALL: u8 = 0b0000_1000; + + // Disambiguate-only mode keeps unmodified plain keys as their legacy bytes. + assert_eq!( + sequence_to_bytes(&[KeyToken::Char('a')], DISAMBIGUATE_ONLY).unwrap(), + b"a".to_vec() + ); + assert_eq!( + sequence_to_bytes(&[KeyToken::Tab], DISAMBIGUATE_ONLY).unwrap(), + b"\t".to_vec() + ); + + // Under report-all-keys, even ordinary characters and Tab become CSI-u. + assert_eq!( + sequence_to_bytes(&[KeyToken::Char('a')], REPORT_ALL).unwrap(), + b"\x1b[97u".to_vec() + ); + assert_eq!( + sequence_to_bytes(&[KeyToken::Tab], REPORT_ALL).unwrap(), + b"\x1b[9u".to_vec() + ); + } + + #[test] + fn space_keypress_maps_to_the_space_binding_token() { + // A `` binding token is produced by the grammar for a literal + // space, so a space keypress must resolve to it (not Char(' ')). + assert_eq!( + key_event_to_token(KeyEvent::Char(' ')).unwrap(), + KeyToken::Space + ); + assert_eq!( + key_event_to_token(KeyEvent::Char('a')).unwrap(), + KeyToken::Char('a') + ); + } + + #[test] + fn csi_u_single_modifier_char_normalizes_to_legacy_token() { + use crate::input::{KeyCode, Modifiers}; + // A CSI-u Ctrl+H report must resolve to the same token as legacy `` + // so a single binding matches both encodings. + assert_eq!( + key_event_to_token(KeyEvent::Key { + code: KeyCode::Char('h'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }) + .unwrap(), + KeyToken::Ctrl('h') + ); + // Multi-modifier / special keys stay as Key tokens. + assert_eq!( + key_event_to_token(KeyEvent::Key { + code: KeyCode::Tab, + mods: Modifiers { + shift: true, + ctrl: true, + ..Modifiers::NONE + }, + }) + .unwrap(), + KeyToken::Key { + code: KeyCode::Tab, + mods: Modifiers { + shift: true, + ctrl: true, + ..Modifiers::NONE + }, + } + ); + } + + #[test] + fn normalize_text_key_maps_unmodified_csi_u_to_legacy() { + use super::normalize_text_key; + use crate::input::{KeyCode, Modifiers}; + + // Unmodified CSI-u text keys collapse to their legacy form so search + // (and other text-consuming modes) treat both encodings identically. + assert_eq!( + normalize_text_key(KeyEvent::Key { + code: KeyCode::Char('a'), + mods: Modifiers::NONE, + }), + KeyEvent::Char('a') + ); + assert_eq!( + normalize_text_key(KeyEvent::Key { + code: KeyCode::Enter, + mods: Modifiers::NONE, + }), + KeyEvent::Enter + ); + // A modified key stays a Key (not text input)... + let ctrl_a = KeyEvent::Key { + code: KeyCode::Char('a'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }; + assert_eq!(normalize_text_key(ctrl_a.clone()), ctrl_a); + // ...as does a non-text key like PageDown. + let page_down = KeyEvent::Key { + code: KeyCode::PageDown, + mods: Modifiers::NONE, + }; + assert_eq!(normalize_text_key(page_down.clone()), page_down); + } + + #[test] + fn report_all_csi_u_keys_tokenize_as_legacy() { + use super::{key_event_to_token, normalize_text_key}; + use crate::input::{KeyCode, Modifiers}; + + // Under report-all-keys, ordinary chars, Space, and Tab arrive as CSI-u + // Key events; after the up-front normalization in handle_key they tokenize + // like their legacy forms, so `a`, ``, and `` bindings (and + // hints labels, which match on the same Char events) still resolve. + let token = |code| { + key_event_to_token(normalize_text_key(KeyEvent::Key { + code, + mods: Modifiers::NONE, + })) + .unwrap() + }; + assert_eq!(token(KeyCode::Char('a')), KeyToken::Char('a')); + assert_eq!(token(KeyCode::Char(' ')), KeyToken::Space); + assert_eq!(token(KeyCode::Tab), KeyToken::Tab); + + // A modified CSI-u key is not text input; it keeps its modifier token. + let ctrl_a = KeyEvent::Key { + code: KeyCode::Char('a'), + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }; + assert_eq!( + key_event_to_token(normalize_text_key(ctrl_a)).unwrap(), + KeyToken::Ctrl('a') + ); + } + + #[test] + fn shifted_char_reports_tokenize_lowercase() { + use super::key_event_to_token; + use crate::input::{KeyCode, Modifiers}; + + // A host reporting the shifted code point (Ctrl+Shift+T as 'T') must + // produce the same token as the kitty-style lowercase report, which is + // also how the binding grammar stores . + let ctrl_shift = Modifiers { + ctrl: true, + shift: true, + ..Modifiers::NONE + }; + assert_eq!( + key_event_to_token(KeyEvent::Key { + code: KeyCode::Char('T'), + mods: ctrl_shift, + }) + .unwrap(), + KeyToken::Key { + code: KeyCode::Char('t'), + mods: ctrl_shift, + } + ); + } + #[test] fn attached_buffer_location_accepts_session_and_floating_locations() { assert_eq!( diff --git a/crates/embers-client/src/controller.rs b/crates/embers-client/src/controller.rs index ba0a6a5d..14976b44 100644 --- a/crates/embers-client/src/controller.rs +++ b/crates/embers-client/src/controller.rs @@ -1,7 +1,4 @@ -use embers_core::RequestId; -use embers_protocol::{ClientMessage, FloatingRequest, InputRequest, NodeRequest}; - -use crate::presentation::{NavigationDirection, PresentationModel}; +use crate::input::{KeyCode, Modifiers}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum KeyEvent { @@ -23,6 +20,12 @@ pub enum KeyEvent { Delete, PageUp, PageDown, + /// A key carrying an explicit modifier set (shift/multi-modifier combos and + /// function keys), produced by CSI-u aware host-terminal parsing. + Key { + code: KeyCode, + mods: Modifiers, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,114 +58,3 @@ pub struct MouseEvent { pub modifiers: MouseModifiers, pub kind: MouseEventKind, } - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Controller; - -impl Controller { - pub fn map_key( - &self, - presentation: &PresentationModel, - request_id: RequestId, - key: KeyEvent, - ) -> Option { - match key { - KeyEvent::Ctrl(ch) => { - let ch = ch.to_ascii_lowercase(); - match ch { - 'h' | 'j' | 'k' | 'l' => { - let direction = match ch { - 'h' => NavigationDirection::Left, - 'j' => NavigationDirection::Down, - 'k' => NavigationDirection::Up, - 'l' => NavigationDirection::Right, - _ => unreachable!(), - }; - - Some(ClientMessage::Node(NodeRequest::Focus { - request_id, - session_id: presentation.session_id, - node_id: presentation.focus_target(direction)?, - })) - } - _ => input_request(presentation, request_id, vec![ctrl_byte(ch)?]), - } - } - KeyEvent::Alt(ch) if ('1'..='9').contains(&ch) => { - let index = ch.to_digit(10)?.saturating_sub(1); - let Some(index_usize) = usize::try_from(index).ok() else { - return alt_bytes_request(presentation, request_id, ch); - }; - if let Some(tabs) = presentation.focused_tabs() - && index_usize < tabs.tabs.len() - { - return Some(ClientMessage::Node(NodeRequest::SelectTab { - request_id, - tabs_node_id: tabs.node_id, - index, - })); - } - alt_bytes_request(presentation, request_id, ch) - } - KeyEvent::Alt(ch) => alt_bytes_request(presentation, request_id, ch), - KeyEvent::Escape => { - if let Some(floating_id) = presentation.focused_floating_id() { - Some(ClientMessage::Floating(FloatingRequest::Close { - request_id, - floating_id, - })) - } else { - input_request(presentation, request_id, vec![0x1b]) - } - } - KeyEvent::Char(ch) => { - input_request(presentation, request_id, ch.to_string().into_bytes()) - } - KeyEvent::Bytes(bytes) if !bytes.is_empty() => { - input_request(presentation, request_id, bytes) - } - KeyEvent::Tab => input_request(presentation, request_id, b"\t".to_vec()), - KeyEvent::Enter => input_request(presentation, request_id, b"\r".to_vec()), - KeyEvent::Backspace => input_request(presentation, request_id, vec![0x7f]), - KeyEvent::Up => input_request(presentation, request_id, b"\x1b[A".to_vec()), - KeyEvent::Down => input_request(presentation, request_id, b"\x1b[B".to_vec()), - KeyEvent::Right => input_request(presentation, request_id, b"\x1b[C".to_vec()), - KeyEvent::Left => input_request(presentation, request_id, b"\x1b[D".to_vec()), - KeyEvent::Home => input_request(presentation, request_id, b"\x1b[H".to_vec()), - KeyEvent::End => input_request(presentation, request_id, b"\x1b[F".to_vec()), - KeyEvent::Insert => input_request(presentation, request_id, b"\x1b[2~".to_vec()), - KeyEvent::Delete => input_request(presentation, request_id, b"\x1b[3~".to_vec()), - KeyEvent::PageUp => input_request(presentation, request_id, b"\x1b[5~".to_vec()), - KeyEvent::PageDown => input_request(presentation, request_id, b"\x1b[6~".to_vec()), - KeyEvent::Bytes(_) => None, - } - } -} - -fn ctrl_byte(ch: char) -> Option { - ch.is_ascii() - .then_some((ch.to_ascii_lowercase() as u8) & 0x1f) -} - -fn alt_bytes_request( - presentation: &PresentationModel, - request_id: RequestId, - ch: char, -) -> Option { - let mut encoded = [0; 4]; - let mut bytes = vec![0x1b]; - bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - input_request(presentation, request_id, bytes) -} - -fn input_request( - presentation: &PresentationModel, - request_id: RequestId, - bytes: Vec, -) -> Option { - Some(ClientMessage::Input(InputRequest::Send { - request_id, - buffer_id: presentation.focused_buffer_id()?, - bytes, - })) -} diff --git a/crates/embers-client/src/input/encoding.rs b/crates/embers-client/src/input/encoding.rs new file mode 100644 index 00000000..795b0ec7 --- /dev/null +++ b/crates/embers-client/src/input/encoding.rs @@ -0,0 +1,481 @@ +//! Shared key encoding for both the host-terminal input controller and the +//! scripted `send_keys` path. Encodes a logical key (a [`KeyCode`] plus +//! [`Modifiers`]) either with legacy VT sequences or, when the target buffer has +//! negotiated the kitty keyboard protocol, with disambiguated CSI-u sequences. + +/// Modifier flags carried by a key event or binding token. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Modifiers { + pub shift: bool, + pub alt: bool, + pub ctrl: bool, + pub super_: bool, +} + +impl Modifiers { + pub const NONE: Self = Self { + shift: false, + alt: false, + ctrl: false, + super_: false, + }; + + pub const fn is_empty(self) -> bool { + !self.shift && !self.alt && !self.ctrl && !self.super_ + } + + /// The kitty modifier encoding: 1 + a bitmask (shift=1, alt=2, ctrl=4, + /// super=8). + pub const fn kitty_code(self) -> u32 { + let mut mask = 0; + if self.shift { + mask |= 1; + } + if self.alt { + mask |= 2; + } + if self.ctrl { + mask |= 4; + } + if self.super_ { + mask |= 8; + } + mask + 1 + } +} + +/// A logical key, independent of its wire encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum KeyCode { + Char(char), + Enter, + Tab, + Backspace, + Escape, + Up, + Down, + Left, + Right, + Home, + End, + Insert, + Delete, + PageUp, + PageDown, + /// Function keys F1..=F12. + Function(u8), +} + +/// Kitty keyboard mode bits (a subset of the alacritty `TermMode` kitty flags). +/// `DISAMBIGUATE_ESC_CODES` is the flag that turns on CSI-u encoding. The bit +/// positions mirror the kitty protocol's own flag numbering (bit 0 disambiguate, +/// bit 3 report-all-keys) so the compact `keyboard_mode` byte never reuses a spec +/// bit position for a different meaning. +pub const KITTY_DISAMBIGUATE_ESC_CODES: u8 = 0b0000_0001; +pub const KITTY_REPORT_ALL_KEYS_AS_ESC: u8 = 0b0000_1000; + +/// True when the mode requests disambiguated (CSI-u) encoding. +pub const fn mode_disambiguates(mode: u8) -> bool { + mode & KITTY_DISAMBIGUATE_ESC_CODES != 0 +} + +const fn mode_reports_all_keys(mode: u8) -> bool { + mode & KITTY_REPORT_ALL_KEYS_AS_ESC != 0 +} + +/// Encode a key press to the bytes a program expects, honoring the target's +/// keyboard mode. +pub fn encode_key(code: KeyCode, mods: Modifiers, mode: u8) -> Vec { + // Either flag puts the program in CSI-u territory: disambiguate turns it on + // for modified keys, and report-all-keys asks for every key as an escape + // sequence. Route both through the kitty encoder; use legacy only when + // neither bit is set. + if mode_disambiguates(mode) || mode_reports_all_keys(mode) { + encode_kitty(code, mods, mode) + } else { + encode_legacy(code, mods) + } +} + +/// The Unicode code point kitty uses to name a key in CSI-u encodings. +fn kitty_key_number(code: KeyCode) -> Option { + Some(match code { + KeyCode::Char(ch) => u32::from(ch), + KeyCode::Enter => 13, + KeyCode::Tab => 9, + KeyCode::Backspace => 127, + KeyCode::Escape => 27, + // Functional keys are encoded as CSI ... letter/tilde forms instead. + _ => return None, + }) +} + +fn encode_kitty(code: KeyCode, mods: Modifiers, mode: u8) -> Vec { + // Functional keys use the modified legacy CSI forms even under disambiguate. + if let Some(sequence) = functional_csi(code, mods) { + return sequence; + } + + let Some(number) = kitty_key_number(code) else { + return encode_legacy(code, mods); + }; + + // Disambiguation only kicks in for keys that would otherwise be ambiguous: + // plain Tab/Enter/Esc/chars keep their literal bytes, and a shift-only + // character still produces text (the kitty spec keeps shifted text-producing + // keys as plain text under disambiguate), unless the program asked for every + // key as an escape sequence. + let shift_only_text = matches!(code, KeyCode::Char(_)) + && mods + == (Modifiers { + shift: true, + ..Modifiers::NONE + }); + if (mods.is_empty() || shift_only_text) && !mode_reports_all_keys(mode) { + return encode_legacy(code, mods); + } + + let modifiers = mods.kitty_code(); + if modifiers == 1 { + format!("\x1b[{number}u").into_bytes() + } else { + format!("\x1b[{number};{modifiers}u").into_bytes() + } +} + +/// Legacy VT encoding, used when the kitty protocol is not active. +fn encode_legacy(code: KeyCode, mods: Modifiers) -> Vec { + if let Some(sequence) = functional_csi(code, mods) { + return sequence; + } + match code { + KeyCode::Char(ch) => { + // Fold shift into the character (Shift+a -> 'A'); the ctrl and alt + // modifiers are applied on top so combinations survive. + let ch = if mods.shift { + ch.to_ascii_uppercase() + } else { + ch + }; + let mut bytes = Vec::new(); + // Alt/Meta prefixes the whole sequence with ESC, ahead of a control + // byte too (Ctrl+Alt+x -> ESC, then Ctrl-x). + if mods.alt { + bytes.push(0x1b); + } + if mods.ctrl + && let Some(byte) = ctrl_byte(ch) + { + bytes.push(byte); + } else { + let mut buffer = [0; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut buffer).as_bytes()); + } + bytes + } + KeyCode::Enter | KeyCode::Tab | KeyCode::Backspace | KeyCode::Escape => { + // Portable modified forms only: Shift+Tab is backtab, and Alt prefixes + // ESC (matching the character handling above). Other modifier combos on + // these keys (e.g. Ctrl+Backspace) have no portable legacy encoding, so + // they fall back to the unmodified byte rather than an invented one. + let base: &[u8] = if mods.shift && code == KeyCode::Tab { + b"\x1b[Z" + } else { + match code { + KeyCode::Enter => b"\r", + KeyCode::Backspace => b"\x7f", + KeyCode::Escape => b"\x1b", + _ => b"\t", + } + }; + let mut bytes = Vec::new(); + if mods.alt { + bytes.push(0x1b); + } + bytes.extend_from_slice(base); + bytes + } + _ => Vec::new(), + } +} + +/// CSI sequences for functional keys (arrows, navigation, function keys), +/// inserting a modifier parameter when any modifier is held. +fn functional_csi(code: KeyCode, mods: Modifiers) -> Option> { + let modifier = mods.kitty_code(); + let with_modifier = |suffix_letter: char| -> Vec { + if modifier == 1 { + format!("\x1b[{suffix_letter}").into_bytes() + } else { + format!("\x1b[1;{modifier}{suffix_letter}").into_bytes() + } + }; + let tilde = |number: u32| -> Vec { + if modifier == 1 { + format!("\x1b[{number}~").into_bytes() + } else { + format!("\x1b[{number};{modifier}~").into_bytes() + } + }; + Some(match code { + KeyCode::Up => with_modifier('A'), + KeyCode::Down => with_modifier('B'), + KeyCode::Right => with_modifier('C'), + KeyCode::Left => with_modifier('D'), + KeyCode::Home => with_modifier('H'), + KeyCode::End => with_modifier('F'), + KeyCode::Insert => tilde(2), + KeyCode::Delete => tilde(3), + KeyCode::PageUp => tilde(5), + KeyCode::PageDown => tilde(6), + KeyCode::Function(n) => return function_key_csi(n, modifier), + _ => return None, + }) +} + +/// Encode F1..=F12 using the conventional xterm sequences. +fn function_key_csi(n: u8, modifier: u32) -> Option> { + // F1-F4 use SS3-style letters (with CSI when modified); F5-F12 use tilde + // numbers. This matches xterm / kitty's default functional encodings. + let sequence = match n { + 1..=4 => { + let letter = b"PQRS"[usize::from(n - 1)] as char; + if modifier == 1 { + format!("\x1bO{letter}") + } else if n == 3 { + // `CSI 1;mods R` is also a cursor-position report, so modified + // F3 uses its vt220 tilde number instead (as kitty does). + format!("\x1b[13;{modifier}~") + } else { + format!("\x1b[1;{modifier}{letter}") + } + } + 5..=12 => { + let number = match n { + 5 => 15, + 6 => 17, + 7 => 18, + 8 => 19, + 9 => 20, + 10 => 21, + 11 => 23, + 12 => 24, + _ => unreachable!(), + }; + if modifier == 1 { + format!("\x1b[{number}~") + } else { + format!("\x1b[{number};{modifier}~") + } + } + _ => return None, + }; + Some(sequence.into_bytes()) +} + +/// Map a character to the control byte a terminal sends for Ctrl+. +/// +/// Letters and the `@A-Z[\]^_` block mask off the low five bits; the number row +/// and a few symbols aren't in that block and follow xterm's fixed conventions +/// (e.g. Ctrl+Space -> NUL, Ctrl+3 -> ESC). Characters with no control mapping +/// return `None`, so the caller emits the character itself instead of a blanket +/// (and wrong, e.g. Ctrl+3 -> 0x13) bitmask result. +fn ctrl_byte(ch: char) -> Option { + let byte = match ch { + ' ' | '2' | '@' => 0x00, + '3' => 0x1b, + '4' => 0x1c, + '5' => 0x1d, + '6' => 0x1e, + '7' | '/' | '-' => 0x1f, + '8' | '?' => 0x7f, + 'a'..='z' | 'A'..='Z' | '[' | '\\' | ']' | '^' | '_' => { + (ch.to_ascii_uppercase() as u8) & 0x1f + } + _ => return None, + }; + Some(byte) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CTRL: Modifiers = Modifiers { + shift: false, + alt: false, + ctrl: true, + super_: false, + }; + const CTRL_SHIFT: Modifiers = Modifiers { + shift: true, + alt: false, + ctrl: true, + super_: false, + }; + const CTRL_ALT: Modifiers = Modifiers { + shift: false, + alt: true, + ctrl: true, + super_: false, + }; + const SHIFT: Modifiers = Modifiers { + shift: true, + alt: false, + ctrl: false, + super_: false, + }; + const ALT: Modifiers = Modifiers { + shift: false, + alt: true, + ctrl: false, + super_: false, + }; + + #[test] + fn legacy_modified_c0_keys() { + // Shift+Tab is backtab. + assert_eq!(encode_key(KeyCode::Tab, SHIFT, 0), b"\x1b[Z".to_vec()); + // Alt prefixes ESC on the C0 keys, matching character handling. + assert_eq!(encode_key(KeyCode::Enter, ALT, 0), b"\x1b\r".to_vec()); + // Unmodified C0 keys keep their plain legacy byte. + assert_eq!(encode_key(KeyCode::Tab, Modifiers::NONE, 0), b"\t".to_vec()); + assert_eq!( + encode_key(KeyCode::Backspace, Modifiers::NONE, 0), + vec![0x7f] + ); + // A modifier with no portable form on these keys falls back to the plain + // byte rather than an invented sequence. + assert_eq!(encode_key(KeyCode::Backspace, CTRL, 0), vec![0x7f]); + } + + #[test] + fn legacy_ctrl_char_uses_control_byte() { + assert_eq!(encode_key(KeyCode::Char('i'), CTRL, 0), vec![0x09]); + assert_eq!(encode_key(KeyCode::Tab, Modifiers::NONE, 0), vec![b'\t']); + } + + #[test] + fn disambiguate_ctrl_i_differs_from_tab() { + let mode = KITTY_DISAMBIGUATE_ESC_CODES; + // Ctrl+I becomes CSI 105 ; 5 u, distinct from a real Tab. + assert_eq!( + encode_key(KeyCode::Char('i'), CTRL, mode), + b"\x1b[105;5u".to_vec() + ); + assert_eq!(encode_key(KeyCode::Tab, Modifiers::NONE, mode), vec![b'\t']); + } + + #[test] + fn disambiguate_leaves_plain_chars_literal() { + let mode = KITTY_DISAMBIGUATE_ESC_CODES; + assert_eq!(encode_key(KeyCode::Char('a'), Modifiers::NONE, mode), b"a"); + } + + #[test] + fn disambiguate_keeps_shifted_text_keys_as_text() { + // A shift-only character still produces text under disambiguate-only + // mode; only report-all-keys turns it into a CSI-u report. + let mode = KITTY_DISAMBIGUATE_ESC_CODES; + assert_eq!(encode_key(KeyCode::Char('a'), SHIFT, mode), b"A".to_vec()); + let all = KITTY_DISAMBIGUATE_ESC_CODES | KITTY_REPORT_ALL_KEYS_AS_ESC; + assert_eq!( + encode_key(KeyCode::Char('a'), SHIFT, all), + b"\x1b[97;2u".to_vec() + ); + } + + #[test] + fn report_all_keys_escapes_plain_chars() { + let mode = KITTY_DISAMBIGUATE_ESC_CODES | KITTY_REPORT_ALL_KEYS_AS_ESC; + assert_eq!( + encode_key(KeyCode::Char('a'), Modifiers::NONE, mode), + b"\x1b[97u".to_vec() + ); + } + + #[test] + fn report_all_only_still_routes_through_kitty() { + // report-all-keys without the disambiguate bit must still produce CSI-u, + // not fall back to legacy encoding. + let mode = KITTY_REPORT_ALL_KEYS_AS_ESC; + assert_eq!( + encode_key(KeyCode::Char('a'), Modifiers::NONE, mode), + b"\x1b[97u".to_vec() + ); + } + + #[test] + fn modified_arrows_carry_modifier_parameter() { + assert_eq!(encode_key(KeyCode::Left, Modifiers::NONE, 0), b"\x1b[D"); + assert_eq!( + encode_key( + KeyCode::Left, + Modifiers { + shift: true, + ..Modifiers::NONE + }, + 0 + ), + b"\x1b[1;2D".to_vec() + ); + } + + #[test] + fn ctrl_shift_tab_disambiguates() { + let mode = KITTY_DISAMBIGUATE_ESC_CODES; + assert_eq!( + encode_key(KeyCode::Tab, CTRL_SHIFT, mode), + b"\x1b[9;6u".to_vec() + ); + } + + #[test] + fn legacy_control_and_modifier_combinations() { + // Ctrl+Space -> NUL. + assert_eq!(encode_key(KeyCode::Char(' '), CTRL, 0), vec![0x00]); + // Ctrl+3 -> ESC (xterm convention), not the blanket-masked 0x13. + assert_eq!(encode_key(KeyCode::Char('3'), CTRL, 0), vec![0x1b]); + // Ctrl+- -> US (0x1f), the readline undo chord. + assert_eq!(encode_key(KeyCode::Char('-'), CTRL, 0), vec![0x1f]); + // Ctrl+Alt+x -> ESC then Ctrl-x (alt prefix ahead of the control byte). + assert_eq!( + encode_key(KeyCode::Char('x'), CTRL_ALT, 0), + vec![0x1b, 0x18] + ); + // Shift folds into the character. + assert_eq!(encode_key(KeyCode::Char('a'), SHIFT, 0), b"A".to_vec()); + // Ctrl+Shift+a stays the Ctrl-a control byte (case-insensitive). + assert_eq!(encode_key(KeyCode::Char('a'), CTRL_SHIFT, 0), vec![0x01]); + } + + #[test] + fn function_keys_encode() { + assert_eq!( + encode_key(KeyCode::Function(1), Modifiers::NONE, 0), + b"\x1bOP" + ); + // Unmodified F3 is SS3 R, but the modified form avoids `CSI 1;mods R` + // (a cursor-position report) in favor of the vt220 tilde number. + assert_eq!( + encode_key(KeyCode::Function(3), Modifiers::NONE, 0), + b"\x1bOR" + ); + assert_eq!( + encode_key(KeyCode::Function(3), SHIFT, 0), + b"\x1b[13;2~".to_vec() + ); + assert_eq!( + encode_key(KeyCode::Function(1), SHIFT, 0), + b"\x1b[1;2P".to_vec() + ); + assert_eq!( + encode_key(KeyCode::Function(5), Modifiers::NONE, 0), + b"\x1b[15~".to_vec() + ); + assert_eq!( + encode_key(KeyCode::Function(12), Modifiers::NONE, 0), + b"\x1b[24~".to_vec() + ); + } +} diff --git a/crates/embers-client/src/input/keyparse.rs b/crates/embers-client/src/input/keyparse.rs index 4517b645..a2f2a0e0 100644 --- a/crates/embers-client/src/input/keyparse.rs +++ b/crates/embers-client/src/input/keyparse.rs @@ -1,5 +1,7 @@ use thiserror::Error; +use super::encoding::{KeyCode, Modifiers}; + pub type KeySequence = Vec; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -23,6 +25,13 @@ pub enum KeyToken { Delete, PageUp, PageDown, + /// A key carrying an explicit modifier set — used for shift/multi-modifier + /// combinations and function keys that the legacy variants above cannot + /// express (e.g. ``, ``, ``). + Key { + code: KeyCode, + mods: Modifiers, + }, } #[derive(Clone, Debug, Error, PartialEq, Eq)] @@ -35,6 +44,10 @@ pub enum KeyParseError { InvalidModifier { token: String }, #[error("key token '<{token}>' must contain exactly one character after the modifier")] InvalidModifiedKey { token: String }, + #[error( + "key token '<{token}>' combines ctrl with a non-ASCII key, which has no control encoding" + )] + NonAsciiControl { token: String }, #[error("key sequence '{notation}' has an unterminated token")] UnterminatedToken { notation: String }, #[error("'' cannot be used before a leader is configured")] @@ -122,24 +135,123 @@ fn parse_token(token: &str) -> Result { } fn parse_modified_token(token: &str) -> Result { - let Some((modifier, key)) = token.split_once('-') else { - return single_char_token(token).map(KeyToken::Char).ok_or_else(|| { + // Consume leading `X-` modifier prefixes; whatever remains is the base key. + // A trailing `-` (e.g. `C--`, `C-S--`) is a literal hyphen base key rather + // than an empty modifier segment, so stop once only the base remains. + let mut mods = Modifiers::NONE; + let mut had_modifier = false; + let mut key = token; + while let Some((head, tail)) = key.split_once('-') { + // An empty head means the remainder starts with `-`: the hyphen base key. + if head.is_empty() { + break; + } + match head.to_ascii_lowercase().as_str() { + "c" | "ctrl" => mods.ctrl = true, + "a" | "alt" | "m" => mods.alt = true, + "s" | "shift" => mods.shift = true, + "d" | "super" | "cmd" | "win" => mods.super_ = true, + _ => { + return Err(KeyParseError::InvalidModifier { + token: token.to_owned(), + }); + } + } + had_modifier = true; + key = tail; + } + + let error = || { + if had_modifier { + KeyParseError::InvalidModifiedKey { + token: token.to_owned(), + } + } else { KeyParseError::InvalidToken { token: token.to_owned(), } - }); + } }; + let code = parse_base_key(key).ok_or_else(error)?; - let ch = single_char_token(key).ok_or_else(|| KeyParseError::InvalidModifiedKey { - token: token.to_owned(), - })?; - - match modifier.to_ascii_lowercase().as_str() { - "c" | "ctrl" => Ok(KeyToken::Ctrl(ch.to_ascii_lowercase())), - "a" | "alt" | "m" => Ok(KeyToken::Alt(ch.to_ascii_lowercase())), - _ => Err(KeyParseError::InvalidModifier { + // A ctrl chord needs a control encoding, which only exists for ASCII keys. + // Reject non-ASCII combinations loudly rather than silently sending the + // bare character when the target hasn't negotiated CSI-u. + if mods.ctrl && matches!(code, KeyCode::Char(ch) if !ch.is_ascii()) { + return Err(KeyParseError::NonAsciiControl { token: token.to_owned(), - }), + }); + } + + // No modifiers: preserve the legacy plain-character token. + if !had_modifier { + return Ok(match code { + KeyCode::Char(ch) => KeyToken::Char(ch), + code => KeyToken::Key { + code, + mods: Modifiers::NONE, + }, + }); + } + + // Preserve the legacy single-modifier character tokens so existing bindings + // and encodings are unchanged. + if let KeyCode::Char(ch) = code { + if mods + == (Modifiers { + ctrl: true, + ..Modifiers::NONE + }) + { + return Ok(KeyToken::Ctrl(ch.to_ascii_lowercase())); + } + if mods + == (Modifiers { + alt: true, + ..Modifiers::NONE + }) + { + return Ok(KeyToken::Alt(ch.to_ascii_lowercase())); + } + } + + // Kitty CSI-u reports carry the lowercase code point with shift as a + // modifier bit, so a binding spelled must store 't' to compare + // equal to the incoming host event. + let code = match code { + KeyCode::Char(ch) => KeyCode::Char(ch.to_ascii_lowercase()), + other => other, + }; + Ok(KeyToken::Key { code, mods }) +} + +/// Resolve the textual name of a key to a [`KeyCode`]. +fn parse_base_key(key: &str) -> Option { + match key.to_ascii_lowercase().as_str() { + "enter" | "return" | "cr" => Some(KeyCode::Enter), + "esc" | "escape" => Some(KeyCode::Escape), + "bs" | "backspace" => Some(KeyCode::Backspace), + "tab" => Some(KeyCode::Tab), + "space" => Some(KeyCode::Char(' ')), + "up" => Some(KeyCode::Up), + "down" => Some(KeyCode::Down), + "left" => Some(KeyCode::Left), + "right" => Some(KeyCode::Right), + "home" => Some(KeyCode::Home), + "end" => Some(KeyCode::End), + "ins" | "insert" => Some(KeyCode::Insert), + "del" | "delete" => Some(KeyCode::Delete), + "pageup" | "pgup" => Some(KeyCode::PageUp), + "pagedown" | "pgdown" | "pgdn" => Some(KeyCode::PageDown), + other => { + if let Some(stripped) = other.strip_prefix('f') + && let Ok(number) = stripped.parse::() + && (1..=12).contains(&number) + { + return Some(KeyCode::Function(number)); + } + single_char_token(key).map(KeyCode::Char) + } } } @@ -151,8 +263,131 @@ fn single_char_token(token: &str) -> Option { #[cfg(test)] mod tests { + use super::super::encoding::{KeyCode, Modifiers}; use super::{KeyParseError, KeyToken, expand_leader, parse_key_sequence}; + #[test] + fn parses_shift_multi_modifier_and_function_keys() { + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Key { + code: KeyCode::Tab, + mods: Modifiers { + shift: true, + ctrl: true, + ..Modifiers::NONE + }, + }] + ); + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Key { + code: KeyCode::Left, + mods: Modifiers { + shift: true, + ..Modifiers::NONE + }, + }] + ); + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Key { + code: KeyCode::Function(5), + mods: Modifiers::NONE, + }] + ); + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Key { + code: KeyCode::Tab, + mods: Modifiers { + ctrl: true, + ..Modifiers::NONE + }, + }] + ); + } + + #[test] + fn hyphen_base_key_parses_with_modifiers() { + // `-` as the base key: the trailing hyphen must not be mistaken for an + // empty modifier segment (which previously returned InvalidModifier). + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Ctrl('-')] + ); + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Key { + code: KeyCode::Char('-'), + mods: Modifiers { + ctrl: true, + shift: true, + ..Modifiers::NONE + }, + }] + ); + } + + #[test] + fn multi_modifier_char_keys_store_lowercase() { + // Kitty reports the lowercase code point with the shift bit, so the + // natural uppercase spelling must produce the same token. + assert_eq!( + parse_key_sequence("").unwrap(), + parse_key_sequence("").unwrap() + ); + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Key { + code: KeyCode::Char('t'), + mods: Modifiers { + ctrl: true, + shift: true, + ..Modifiers::NONE + }, + }] + ); + } + + #[test] + fn rejects_non_ascii_control_chords() { + assert_eq!( + parse_key_sequence("").unwrap_err(), + KeyParseError::NonAsciiControl { + token: "C-é".to_owned(), + } + ); + } + + #[test] + fn single_modifier_char_keys_stay_legacy() { + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Ctrl('x')] + ); + assert_eq!( + parse_key_sequence("").unwrap(), + vec![KeyToken::Alt('z')] + ); + } + + #[test] + fn rejects_invalid_modifier_and_function_combinations() { + assert_eq!( + parse_key_sequence("").unwrap_err(), + KeyParseError::InvalidToken { + token: "F13".to_owned(), + } + ); + assert_eq!( + parse_key_sequence("").unwrap_err(), + KeyParseError::InvalidModifier { + token: "Hyper-Tab".to_owned(), + } + ); + } + #[test] fn parses_plain_and_modified_keys() { assert_eq!( diff --git a/crates/embers-client/src/input/mod.rs b/crates/embers-client/src/input/mod.rs index d327af67..a2163872 100644 --- a/crates/embers-client/src/input/mod.rs +++ b/crates/embers-client/src/input/mod.rs @@ -1,7 +1,9 @@ +mod encoding; mod keymap; mod keyparse; mod modes; +pub use encoding::{KeyCode, Modifiers, encode_key, mode_disambiguates}; pub use keymap::{BindingMatch, BindingSpec, InputResolution, resolve_key}; pub use keyparse::{KeyParseError, KeySequence, KeyToken, expand_leader, parse_key_sequence}; pub use modes::{ diff --git a/crates/embers-client/src/lib.rs b/crates/embers-client/src/lib.rs index ab4b1ac6..2dcc359e 100644 --- a/crates/embers-client/src/lib.rs +++ b/crates/embers-client/src/lib.rs @@ -20,14 +20,12 @@ pub use config::{ default_config_path, discover_config, load_config_source, }; pub use configured_client::ConfiguredClient; -pub use controller::{ - Controller, KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers, -}; +pub use controller::{KeyEvent, MouseButton, MouseEvent, MouseEventKind, MouseModifiers}; pub use grid::{BorderStyle, CellStyle, Color, GridCursor, RenderGrid, TerminalColor}; pub use input::{ - BindingMatch, BindingSpec, COPY_MODE, FallbackPolicy, InputResolution, InputState, - KeyParseError, KeySequence, KeyToken, ModeSpec, NORMAL_MODE, SEARCH_MODE, SELECT_MODE, - expand_leader, parse_key_sequence, resolve_key, + BindingMatch, BindingSpec, COPY_MODE, FallbackPolicy, HINTS_MODE, InputResolution, InputState, + KeyCode, KeyParseError, KeySequence, KeyToken, ModeSpec, Modifiers, NORMAL_MODE, SEARCH_MODE, + SELECT_MODE, encode_key, expand_leader, mode_disambiguates, parse_key_sequence, resolve_key, }; pub use presentation::{ DividerFrame, FloatingFrame, LeafFrame, NavigationDirection, PresentationModel, TabItem, diff --git a/crates/embers-client/tests/configured_client.rs b/crates/embers-client/tests/configured_client.rs index 0f0d545e..f348151e 100644 --- a/crates/embers-client/tests/configured_client.rs +++ b/crates/embers-client/tests/configured_client.rs @@ -243,6 +243,7 @@ fn second_session_state() -> embers_client::ClientState { focus_reporting: false, bracketed_paste: false, cursor: None, + keyboard_mode: 0, }, ); state diff --git a/crates/embers-client/tests/controller.rs b/crates/embers-client/tests/controller.rs deleted file mode 100644 index f9cff104..00000000 --- a/crates/embers-client/tests/controller.rs +++ /dev/null @@ -1,186 +0,0 @@ -use embers_client::{Controller, KeyEvent, PresentationModel}; -use embers_core::{RequestId, Size}; -use embers_protocol::{ClientMessage, FloatingRequest, InputRequest, NodeRequest}; - -use crate::support::{ - FLOATING_ID, FOCUSED_BUFFER_ID, LEFT_LEAF_ID, NESTED_TABS_ID, ROOT_TABS_ID, SESSION_ID, - demo_state, floating_focused_state, root_focus_state, root_split_state, -}; - -const TEST_SIZE: Size = Size { - width: 40, - height: 14, -}; - -#[test] -fn ctrl_h_focuses_neighboring_leaf() { - let state = demo_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(7), KeyEvent::Ctrl('h')) - .expect("focus request"); - - assert_eq!( - request, - ClientMessage::Node(NodeRequest::Focus { - request_id: RequestId(7), - session_id: SESSION_ID, - node_id: LEFT_LEAF_ID, - }) - ); -} - -#[test] -fn alt_digit_targets_deepest_visible_tabs_group() { - let state = demo_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(8), KeyEvent::Alt('1')) - .expect("tab request"); - - assert_eq!( - request, - ClientMessage::Node(NodeRequest::SelectTab { - request_id: RequestId(8), - tabs_node_id: NESTED_TABS_ID, - index: 0, - }) - ); -} - -#[test] -fn alt_digit_targets_root_tabs_when_focus_is_not_nested() { - let state = root_focus_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(9), KeyEvent::Alt('2')) - .expect("root tab request"); - - assert_eq!( - request, - ClientMessage::Node(NodeRequest::SelectTab { - request_id: RequestId(9), - tabs_node_id: ROOT_TABS_ID, - index: 1, - }) - ); -} - -#[test] -fn escape_closes_focused_popup() { - let state = floating_focused_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(10), KeyEvent::Escape) - .expect("popup close request"); - - assert_eq!( - request, - ClientMessage::Floating(FloatingRequest::Close { - request_id: RequestId(10), - floating_id: FLOATING_ID, - }) - ); -} - -#[test] -fn plain_input_routes_to_focused_buffer() { - let state = demo_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(11), KeyEvent::Char('x')) - .expect("input request"); - - assert_eq!( - request, - ClientMessage::Input(InputRequest::Send { - request_id: RequestId(11), - buffer_id: FOCUSED_BUFFER_ID, - bytes: vec![b'x'], - }) - ); -} - -#[test] -fn alt_digit_is_ignored_without_focused_tabs_context() { - let state = root_split_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - let buffer_id = presentation.focused_buffer_id().expect("focused buffer"); - - assert_eq!( - Controller.map_key(&presentation, RequestId(12), KeyEvent::Alt('1')), - Some(ClientMessage::Input(InputRequest::Send { - request_id: RequestId(12), - buffer_id, - bytes: vec![0x1b, b'1'], - })) - ); -} - -#[test] -fn alt_digit_falls_back_to_esc_prefixed_bytes_when_out_of_range() { - let state = root_focus_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - let buffer_id = presentation.focused_buffer_id().expect("focused buffer"); - - assert_eq!( - Controller.map_key(&presentation, RequestId(15), KeyEvent::Alt('9')), - Some(ClientMessage::Input(InputRequest::Send { - request_id: RequestId(15), - buffer_id, - bytes: vec![0x1b, b'9'], - })) - ); -} - -#[test] -fn unbound_ctrl_key_is_forwarded_to_the_focused_buffer() { - let state = demo_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(13), KeyEvent::Ctrl('z')) - .expect("input request"); - - assert_eq!( - request, - ClientMessage::Input(InputRequest::Send { - request_id: RequestId(13), - buffer_id: FOCUSED_BUFFER_ID, - bytes: vec![0x1a], - }) - ); -} - -#[test] -fn unbound_alt_key_is_forwarded_to_the_focused_buffer() { - let state = demo_state(); - let presentation = - PresentationModel::project(&state, SESSION_ID, TEST_SIZE).expect("projection succeeds"); - - let request = Controller - .map_key(&presentation, RequestId(14), KeyEvent::Alt('x')) - .expect("input request"); - - assert_eq!( - request, - ClientMessage::Input(InputRequest::Send { - request_id: RequestId(14), - buffer_id: FOCUSED_BUFFER_ID, - bytes: vec![0x1b, b'x'], - }) - ); -} diff --git a/crates/embers-client/tests/fixtures/repository_config.rhai b/crates/embers-client/tests/fixtures/repository_config.rhai index 1eb41e7e..fc193b0e 100644 --- a/crates/embers-client/tests/fixtures/repository_config.rhai +++ b/crates/embers-client/tests/fixtures/repository_config.rhai @@ -326,13 +326,25 @@ fn enter_hints_action(ctx) { action.enter_hints() } +fn next_tab_action(ctx) { + action.next_current_tabs() +} + +fn prev_tab_action(ctx) { + action.prev_current_tabs() +} + define_action("last-session", last_session); define_action("wisp-popup", wisp_popup); define_action("hints", enter_hints_action); +define_action("next-tab", next_tab_action); +define_action("prev-tab", prev_tab_action); bind("normal", "'", "last-session"); bind("normal", "w", "wisp-popup"); bind("normal", "", "hints"); +bind("normal", "", "next-tab"); +bind("normal", "", "prev-tab"); bind("normal", "", "smart-nav-left"); bind("normal", "", "smart-nav-down"); bind("normal", "", "smart-nav-up"); diff --git a/crates/embers-client/tests/integration.rs b/crates/embers-client/tests/integration.rs index 5816d289..dda5eb41 100644 --- a/crates/embers-client/tests/integration.rs +++ b/crates/embers-client/tests/integration.rs @@ -2,7 +2,6 @@ mod config_api_docs; mod config_loading; mod configured_client; mod context; -mod controller; mod e2e; mod presentation; mod reducer; diff --git a/crates/embers-client/tests/reducer.rs b/crates/embers-client/tests/reducer.rs index f3d5c93a..14b845b7 100644 --- a/crates/embers-client/tests/reducer.rs +++ b/crates/embers-client/tests/reducer.rs @@ -176,6 +176,7 @@ fn visible_snapshot( focus_reporting: false, bracketed_paste: false, cursor: None, + keyboard_mode: 0, } } diff --git a/crates/embers-client/tests/support/mod.rs b/crates/embers-client/tests/support/mod.rs index 2dc82a4b..d5f1be86 100644 --- a/crates/embers-client/tests/support/mod.rs +++ b/crates/embers-client/tests/support/mod.rs @@ -355,5 +355,6 @@ fn snapshot(buffer_id: u64, lines: [&str; N]) -> VisibleSnapshot focus_reporting: false, bracketed_paste: false, cursor: None, + keyboard_mode: 0, } } diff --git a/crates/embers-core/src/snapshot.rs b/crates/embers-core/src/snapshot.rs index ee04a1f9..ee447e6d 100644 --- a/crates/embers-core/src/snapshot.rs +++ b/crates/embers-core/src/snapshot.rs @@ -30,6 +30,11 @@ pub struct TerminalModes { pub mouse_reporting: bool, pub focus_reporting: bool, pub bracketed_paste: bool, + /// Kitty keyboard protocol flags active on the focused screen (bit 0 = + /// disambiguate-esc-codes, bit 3 = report-all-keys-as-esc, matching the kitty + /// protocol's own flag numbering). Zero when the program has not enabled the + /// protocol. + pub keyboard_mode: u8, } /// Semantic terminal color. diff --git a/crates/embers-protocol/schema/embers.fbs b/crates/embers-protocol/schema/embers.fbs index 7dec62fd..a841df09 100644 --- a/crates/embers-protocol/schema/embers.fbs +++ b/crates/embers-protocol/schema/embers.fbs @@ -528,6 +528,10 @@ table VisibleSnapshotResponse { // Appended (append-only evolution): parallel per-line styling. Absent for // plain buffers, so plain frames stay byte-identical to pre-styling frames. styles:[StyledLine]; + // Appended: kitty keyboard protocol flags, using the protocol's own flag + // numbering (bit 0 disambiguate, bit 3 report-all-keys; bit 4 would be + // report-associated-text, unused here). + keyboard_mode:ubyte = 0; } table ScrollbackSliceResponse { diff --git a/crates/embers-protocol/src/codec.rs b/crates/embers-protocol/src/codec.rs index d788dd0b..7b2e6231 100644 --- a/crates/embers-protocol/src/codec.rs +++ b/crates/embers-protocol/src/codec.rs @@ -2921,6 +2921,7 @@ fn encode_server_response<'a>( bracketed_paste: r.bracketed_paste, cursor, styles, + keyboard_mode: r.keyboard_mode, }, ); fb::Envelope::create( @@ -4308,6 +4309,7 @@ pub fn decode_server_envelope(bytes: &[u8]) -> Result, } diff --git a/crates/embers-protocol/tests/family_round_trip.rs b/crates/embers-protocol/tests/family_round_trip.rs index 2c4efa16..5e7fb7bb 100644 --- a/crates/embers-protocol/tests/family_round_trip.rs +++ b/crates/embers-protocol/tests/family_round_trip.rs @@ -503,6 +503,7 @@ fn server_envelope_families_round_trip() { position: CursorPosition { row: 1, col: 2 }, shape: CursorShape::Beam, }), + keyboard_mode: 1, })), ServerEnvelope::Response(ServerResponse::ScrollbackSlice(ScrollbackSliceResponse { request_id: RequestId(402), diff --git a/crates/embers-server/src/server.rs b/crates/embers-server/src/server.rs index 0ff54308..bfbe4201 100644 --- a/crates/embers-server/src/server.rs +++ b/crates/embers-server/src/server.rs @@ -2811,6 +2811,7 @@ impl Runtime { mouse_reporting: false, focus_reporting: false, bracketed_paste: false, + keyboard_mode: 0, cursor: None, }); } @@ -2847,6 +2848,7 @@ impl Runtime { mouse_reporting: snapshot.modes.mouse_reporting, focus_reporting: snapshot.modes.focus_reporting, bracketed_paste: snapshot.modes.bracketed_paste, + keyboard_mode: snapshot.modes.keyboard_mode, cursor: snapshot.cursor, }) } diff --git a/crates/embers-server/src/terminal_backend.rs b/crates/embers-server/src/terminal_backend.rs index 949a3f9c..e8764d4f 100644 --- a/crates/embers-server/src/terminal_backend.rs +++ b/crates/embers-server/src/terminal_backend.rs @@ -23,6 +23,7 @@ pub struct BackendMetadata { pub mouse_reporting: bool, pub focus_reporting: bool, pub bracketed_paste: bool, + pub keyboard_mode: u8, pub cursor: Option, } @@ -232,6 +233,21 @@ fn percent_decode(bytes: &[u8]) -> Vec { out } +/// Map the active kitty keyboard `TermMode` bits to the compact bitfield the +/// client encoder consumes. Bit positions follow the kitty protocol's own flag +/// numbering (bit 0 = disambiguate, bit 3 = report-all-keys) so the byte never +/// reuses a spec bit position for a different flag. +fn kitty_keyboard_mode(mode: TermMode) -> u8 { + let mut bits = 0; + if mode.contains(TermMode::DISAMBIGUATE_ESC_CODES) { + bits |= 0b0000_0001; + } + if mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) { + bits |= 0b0000_1000; + } + bits +} + pub struct AlacrittyTerminalBackend { term: Term, parser: ansi::Processor, @@ -317,6 +333,9 @@ impl AlacrittyTerminalBackend { }; let config = Config { scrolling_history: max_scrollback_lines, + // Enable the kitty keyboard protocol so inner apps (e.g. nvim) can + // push/pop/query disambiguation flags; the reply rides Event::PtyWrite. + kitty_keyboard: true, ..Config::default() }; @@ -477,6 +496,7 @@ impl AlacrittyTerminalBackend { ), focus_reporting: mode.contains(TermMode::FOCUS_IN_OUT), bracketed_paste: mode.contains(TermMode::BRACKETED_PASTE), + keyboard_mode: kitty_keyboard_mode(mode), } } @@ -485,6 +505,8 @@ impl AlacrittyTerminalBackend { grid.history_size().saturating_sub(grid.display_offset()) as u64 } + // (kitty keyboard bitfield extracted below as a free function) + fn total_lines(&self) -> u64 { let grid = self.term.grid(); (grid.history_size() + grid.screen_lines()) as u64 @@ -524,6 +546,7 @@ impl TerminalBackend for AlacrittyTerminalBackend { mouse_reporting: metadata.mouse_reporting, focus_reporting: metadata.focus_reporting, bracketed_paste: metadata.bracketed_paste, + keyboard_mode: metadata.keyboard_mode, }, } } @@ -581,6 +604,7 @@ impl TerminalBackend for AlacrittyTerminalBackend { mouse_reporting: modes.mouse_reporting, focus_reporting: modes.focus_reporting, bracketed_paste: modes.bracketed_paste, + keyboard_mode: modes.keyboard_mode, cursor: self.cursor_state(), } } @@ -1137,6 +1161,20 @@ mod tests { assert!(!disabled.bracketed_paste); } + #[test] + fn kitty_keyboard_mode_is_pushed_and_popped() { + let mut backend = backend(PtySize::new(10, 2)); + assert_eq!(backend.metadata().keyboard_mode, 0); + + // Push disambiguate-esc-codes (flags = 1). + backend.ingest_bytes(b"\x1b[>1u"); + assert_eq!(backend.metadata().keyboard_mode & 0b0000_0001, 0b0000_0001); + + // Pop restores the previous (empty) mode. + backend.ingest_bytes(b"\x1b[