From e12cb949961da77ec52d6c14ea61fc4d7d9908db Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 12 Jun 2026 22:36:39 +0000 Subject: [PATCH 1/3] fix: mirror kitty keyboard state onto the real terminal in boo ui boo ui composites sessions through a local ghostty-vt terminal, so an application's kitty keyboard enable never reached the user's real terminal (unlike boo attach, whose repaint replays it). The terminal kept legacy encodings: Shift+Enter was indistinguishable from Enter, and a kitty-mode application sat on a bare Esc waiting for the rest of a sequence that never came, so Esc only registered on the second press. The UI now mirrors the focused view's kitty flags (and modifyOtherKeys) onto the real terminal, the way an attach repaint does, and drops them while a UI prompt or key-driven mode reads byte-oriented input. The input parser learns the CSI-u encodings the mirrored terminal then produces, reusing the keys.zig kitty helpers: Ctrl+A arms the prefix, the command key after it decodes, the Esc key becomes a canonical esc event that drives the existing cancel behaviors (prompts, browse, resize, scroll snap-back) and otherwise forwards verbatim, and every other CSI-u key passes through to the session untouched. --- src/keys.zig | 6 +- src/ui.zig | 431 +++++++++++++++++++++++++++++++++++++++---- test/integration.zig | 121 ++++++++++++ 3 files changed, 523 insertions(+), 35 deletions(-) diff --git a/src/keys.zig b/src/keys.zig index 2455965..15999fb 100644 --- a/src/keys.zig +++ b/src/keys.zig @@ -226,7 +226,7 @@ pub const Parser = struct { } }; -const KittyKey = struct { +pub const KittyKey = struct { cp: u32, mods: u32, event: u32, @@ -235,14 +235,14 @@ const KittyKey = struct { /// Kitty functional codepoints for keys that never act as command /// keys: CAPS_LOCK, NUM_LOCK, and LEFT_SHIFT through /// ISO_LEVEL5_SHIFT (modifiers). -fn isModifierKey(cp: u32) bool { +pub fn isModifierKey(cp: u32) bool { return cp == 57358 or cp == 57360 or (cp >= 57441 and cp <= 57454); } /// Parse the parameter body of a kitty CSI-u key: sections separated /// by ';' (codepoint, modifiers, text), subfields by ':'. Returns null /// when the body is not a well-formed key encoding. -fn parseKitty(body: []const u8) ?KittyKey { +pub fn parseKitty(body: []const u8) ?KittyKey { var key: KittyKey = .{ .cp = 0, .mods = 1, .event = 1 }; var sections = std.mem.splitScalar(u8, body, ';'); diff --git a/src/ui.zig b/src/ui.zig index f7f07d4..7f8fa03 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -165,6 +165,10 @@ pub const InputEvent = union(enum) { paste: bool, /// Focus in (true) / out (false). focus: bool, + /// The Esc key: a lone 0x1b delivered after the flush timeout, or + /// its kitty CSI-u encoding. Carries the original bytes so an + /// unconsumed Esc forwards to the session exactly as typed. + esc: []const u8, pub const Arrow = struct { dir: Dir, @@ -179,22 +183,34 @@ pub const InputEvent = union(enum) { /// markers. Everything else passes through untouched. While a paste /// is open the prefix byte is NOT special, so pasted 0x01 bytes reach /// the application (unlike a plain attach). +/// +/// When the focused application runs the kitty keyboard protocol the +/// real terminal mirrors that state, so the parser also recognizes +/// the CSI-u encodings of the prefix key, of the command key that +/// follows it, and of the Esc key; every other CSI-u sequence passes +/// through to the session unchanged. pub const InputParser = struct { /// A C-a was seen; the next byte is a command key. pending_prefix: bool = false, /// Held bytes of a possible CSI sequence that may need to be - /// intercepted (arrows and mouse/focus/paste reports). Replayed - /// verbatim the moment the sequence diverges. + /// intercepted (arrows, mouse/focus/paste reports, and kitty + /// CSI-u keys). Replayed verbatim the moment the sequence + /// diverges. held: [hold_max]u8 = undefined, held_len: u8 = 0, /// The held sequence followed an armed prefix: an arrow binds to - /// it (C-a Up/Down), anything else cancels the prefix as before. + /// it (C-a Up/Down) and a CSI-u key decodes to the command key; + /// anything else cancels the prefix as before. prefix_held: bool = false, in_paste: bool = false, + /// Kitty CSI-u recognition is active: the real terminal mirrors + /// the focused application's kitty keyboard flags. + kitty: bool = false, const hold_max = 40; - pub fn feed(self: *InputParser, input: []const u8, handler: anytype) !void { + pub fn feed(self: *InputParser, input: []const u8, kitty: bool, handler: anytype) !void { + self.kitty = kitty; var start: usize = 0; var i: usize = 0; while (i < input.len) { @@ -223,8 +239,11 @@ pub const InputParser = struct { // mouse sequence, which must be reprocessed so // its tail is not typed into the application. An // arrow sequence binds back to the prefix - // (C-a Up/Down) via prefix_held. - if (i + 1 == input.len) { + // (C-a Up/Down) via prefix_held. In kitty mode + // the cancel key itself arrives CSI-u encoded, + // so a bare ESC ending the read is a split + // sequence and is held instead. + if (!self.kitty and i + 1 == input.len) { i += 1; } else { self.prefix_held = true; @@ -263,13 +282,15 @@ pub const InputParser = struct { /// Whether `byte` keeps the held bytes a candidate for a sequence /// this parser intercepts: plain arrows (ESC [ A/B/C/D), CSI - /// mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), or paste - /// markers (ESC [ 200~, ESC [ 201~). + /// mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), paste + /// markers (ESC [ 200~, ESC [ 201~), and, in kitty mode, CSI-u + /// keys (ESC [ [;:] ... u). fn heldAccepts(self: *const InputParser, byte: u8) bool { const len = self.held_len; if (len == 1) return byte == '['; if (len == 2) return switch (byte) { '<', 'I', 'O', '2', 'A', 'B', 'C', 'D' => true, + '0', '1', '3'...'9' => self.kitty, else => false, }; return switch (self.held[2]) { @@ -279,6 +300,11 @@ pub const InputParser = struct { }, '2' => switch (byte) { '0'...'9', '~' => true, + ';', ':', 'u' => self.kitty, + else => false, + }, + '0', '1', '3'...'9' => switch (byte) { + '0'...'9', ';', ':', '~', 'u' => self.kitty, else => false, }, else => false, @@ -287,7 +313,7 @@ pub const InputParser = struct { fn isCsiFinal(byte: u8) bool { return switch (byte) { - 'M', 'm', '~', 'I', 'O', 'A', 'B', 'C', 'D' => true, + 'M', 'm', '~', 'I', 'O', 'A', 'B', 'C', 'D', 'u' => true, else => false, }; } @@ -318,6 +344,13 @@ pub const InputParser = struct { else => {}, } + // Kitty CSI-u keys. Pasted content is the application's + // verbatim, like the raw prefix byte. + if (final == 'u') { + if (!self.kitty or self.in_paste) return self.flushHeld(handler); + return self.finishCsiU(prefixed, handler); + } + // Focus reports arrive as a bare final byte. if (final == 'I' or final == 'O') { if (body.len != 0) return self.flushHeld(handler); @@ -355,6 +388,75 @@ pub const InputParser = struct { } }); } + /// A complete `ESC [ ... u` sequence is in the hold buffer: a + /// kitty CSI-u key. Intercepts the prefix key, the command key + /// that follows an armed prefix, and the Esc key, mirroring + /// keys.Parser; every other key is the application's input. + fn finishCsiU(self: *InputParser, prefixed: bool, handler: anytype) !void { + const seq = self.held[0..self.held_len]; + const key = keys.parseKitty(seq[2 .. seq.len - 1]) orelse + return self.flushHeld(handler); + + // Modifier bitmask with the lock bits ignored: caps lock or + // num lock must not hide the prefix. + const mods = (key.mods -| 1) & 0x3f; + const ctrl_only = mods == 0x4; + const plain = mods == 0; + const release = key.event == 3; + + if (prefixed) { + self.held_len = 0; + // A release while the command key is awaited is the + // prefix key itself being let go; stay armed. + if (release) { + self.pending_prefix = true; + return; + } + // The prefix key repeating while held (or pressed again) + // is not a command key; stay armed. + if (key.cp == 'a' and ctrl_only) { + self.pending_prefix = true; + return; + } + // Modifier and lock keys are reported as keys of their + // own under the kitty "report all keys" flag; holding or + // tapping one while armed must not eat the command key. + if (keys.isModifierKey(key.cp)) { + self.pending_prefix = true; + return; + } + // Esc backs out of the armed prefix, like the raw byte. + if (key.cp == 27 and plain) return; + if (ctrl_only and key.cp >= 'a' and key.cp <= 'z') { + return handler.event(.{ .prefix = @intCast(key.cp & 0x1f) }); + } + if (plain and key.cp >= 0x20 and key.cp <= 0x7f) { + return handler.event(.{ .prefix = @intCast(key.cp) }); + } + return handler.event(.{ + .prefix = if (key.cp <= 0x7f) @as(u8, @intCast(key.cp)) else '?', + }); + } + + if (key.cp == 'a' and ctrl_only) { + self.held_len = 0; + // Releases are swallowed: the session never saw the press. + if (!release) self.pending_prefix = true; + return; + } + + if (key.cp == 27 and plain and !release) { + // The Esc key, unambiguously encoded: deliver it as the + // cancel key, with the original bytes for forwarding. + self.held_len = 0; + return handler.event(.{ .esc = seq }); + } + + // Some other key (Shift+Enter, Ctrl+C, ...): the session's + // input, exactly as the terminal encoded it. + try self.flushHeld(handler); + } + fn parseField(field: ?[]const u8) ?u16 { const text = field orelse return null; return std.fmt.parseInt(u16, text, 10) catch null; @@ -369,6 +471,22 @@ pub const InputParser = struct { self.prefix_held = false; if (held.len > 0) try handler.event(.{ .forward = held }); } + + /// Deliver a held lone ESC as the Esc key: the flush timeout + /// passed without follow-up bytes, so the user pressed the key + /// itself. After an armed prefix it is the cancel key and is + /// consumed. Any other hold replays as plain input. + pub fn flushEsc(self: *InputParser, handler: anytype) !void { + if (self.held_len != 1 or self.held[0] != 0x1b) { + return self.flushHeld(handler); + } + self.held_len = 0; + if (self.prefix_held) { + self.prefix_held = false; + return; + } + try handler.event(.{ .esc = &.{0x1b} }); + } }; // -- Focused session view ---------------------------------------------------- @@ -748,6 +866,7 @@ const enter_sequence = "\x1b[?1002h\x1b[?1006h" ++ // mouse: button events, SGR encoding "\x1b[?1004h" ++ // focus reporting "\x1b[?2004h" ++ // bracketed paste + "\x1b[=0;1u\x1b[>4;0m" ++ // keyboard protocols off until a view sets them "\x1b]2;boo ui\x07"; // window title /// reset_state_sequence turns every mode above back off. @@ -825,6 +944,14 @@ const Ui = struct { /// When nonzero, the parser holds a lone ESC that is flushed as /// input once this deadline passes without follow-up bytes. esc_deadline: i64 = 0, + /// Kitty keyboard flags currently applied to the user's real + /// terminal, mirroring the focused view. Nonzero only while a + /// kitty-protocol application is focused and no UI prompt owns + /// the keyboard. + kitty_flags: u5 = 0, + /// modifyOtherKeys=2 state currently applied to the user's real + /// terminal, mirroring the focused view. + modify_keys: bool = false, /// Pending kill confirmation: index into sessions. confirm_kill: ?usize = null, /// Rename input buffer; non-null while the rename prompt is open. @@ -949,6 +1076,7 @@ const Ui = struct { self.refreshSessions() catch {}; } } + self.syncKeyboard(); } } @@ -1007,7 +1135,7 @@ const Ui = struct { // The status bar shows the keybind list while the prefix is // armed, so arming and disarming both need a repaint. const was_pending = self.parser.pending_prefix; - try self.parser.feed(buf[0..n], Handler{ .ui = self }); + try self.parser.feed(buf[0..n], self.kitty_flags != 0, Handler{ .ui = self }); if (self.parser.pending_prefix != was_pending) self.need_render = true; // A read that ends in a bare ESC is ambiguous: the ESC key, // or a split escape sequence. Deliver it on a short timeout @@ -1030,7 +1158,44 @@ const Ui = struct { try h.ui.handleEvent(ev); } }; - try self.parser.flushHeld(Handler{ .ui = self }); + try self.parser.flushEsc(Handler{ .ui = self }); + } + + /// Whether a UI prompt or key-driven mode is reading keyboard + /// input byte-wise (prompts, kill confirm, browse, resize). + /// Mirrored keyboard protocols are suspended for its duration so + /// keys keep their legacy encodings. + fn uiOwnsKeyboard(self: *Ui) bool { + return self.rename_input != null or self.goto_input != null or + self.confirm_kill != null or self.browsing or self.resizing; + } + + /// Mirror the focused application's keyboard protocol state + /// (kitty flags and modifyOtherKeys) onto the real terminal, the + /// same state the repaint of a plain attach replays. Without the + /// mirror the terminal keeps legacy encodings: Shift+Enter is + /// indistinguishable from Enter, and a kitty-mode application + /// sits on a bare Esc waiting for a sequence that never comes. + fn syncKeyboard(self: *Ui) void { + var kitty: u5 = 0; + var modify = false; + if (!self.uiOwnsKeyboard()) { + if (self.liveView()) |v| { + kitty = v.term.screens.active.kitty_keyboard.current().int(); + modify = v.term.flags.modify_other_keys_2; + } + } + if (kitty != self.kitty_flags) { + self.kitty_flags = kitty; + var buf: [12]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[={d};1u", .{kitty}) catch unreachable; + protocol.writeAll(1, seq) catch {}; + } + if (modify != self.modify_keys) { + self.modify_keys = modify; + const seq: []const u8 = if (modify) "\x1b[>4;2m" else "\x1b[>4;0m"; + protocol.writeAll(1, seq) catch {}; + } } fn handleEvent(self: *Ui, ev: InputEvent) !void { @@ -1056,7 +1221,7 @@ const Ui = struct { } return; }, - .prefix, .arrow => { + .prefix, .arrow, .esc => { self.confirm_kill = null; self.setMessage("kill cancelled", .{}); return; @@ -1137,6 +1302,25 @@ const Ui = struct { const marker: []const u8 = if (in) "\x1b[I" else "\x1b[O"; v.sendInput(marker) catch self.markViewLost(); }, + .esc => |bytes| { + // The Esc key cancels transient UI state the same way + // the lone byte does, and otherwise belongs to the + // application in whatever encoding the terminal used. + if (self.resizing) { + self.cancelResize(); + return; + } + if (self.browsing) { + self.cancelBrowse(); + return; + } + if (self.viewScrolled()) { + self.snapViewBottom(); + return; + } + const v = self.liveView() orelse return; + v.sendInput(bytes) catch self.markViewLost(); + }, } } @@ -1177,6 +1361,10 @@ const Ui = struct { self.cancelRename(); return true; }, + .esc => { + self.cancelRename(); + return true; + }, .mouse => |m| { if (!m.release and !m.isMotion() and !m.isWheel()) { self.cancelRename(); @@ -1229,6 +1417,10 @@ const Ui = struct { self.cancelGoto(); return true; }, + .esc => { + self.cancelGoto(); + return true; + }, .mouse => |m| { if (!m.release and !m.isMotion() and !m.isWheel()) { self.cancelGoto(); @@ -2688,15 +2880,20 @@ const TestHandler = struct { alloc: std.mem.Allocator, events: std.ArrayList(InputEvent) = .empty, forwarded: std.ArrayList(u8) = .empty, + /// Esc-event payload bytes, copied out (they alias the parser's + /// hold buffer). + escs: std.ArrayList(u8) = .empty, fn deinit(self: *TestHandler) void { self.events.deinit(self.alloc); self.forwarded.deinit(self.alloc); + self.escs.deinit(self.alloc); } fn event(self: *TestHandler, ev: InputEvent) !void { switch (ev) { .forward => |bytes| try self.forwarded.appendSlice(self.alloc, bytes), + .esc => |bytes| try self.escs.appendSlice(self.alloc, bytes), else => try self.events.append(self.alloc, ev), } } @@ -2706,7 +2903,7 @@ test "parser: plain bytes pass through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("hello", &h); + try p.feed("hello", false, &h); try std.testing.expectEqualStrings("hello", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -2715,7 +2912,7 @@ test "parser: prefix commands" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("ab\x01cde", &h); + try p.feed("ab\x01cde", false, &h); try std.testing.expectEqualStrings("abde", h.forwarded.items); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 'c' }, h.events.items[0]); @@ -2725,9 +2922,9 @@ test "parser: prefix split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x01", &h); + try p.feed("\x01", false, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); - try p.feed("k", &h); + try p.feed("k", false, &h); try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); } @@ -2735,12 +2932,12 @@ test "parser: esc backs out of an armed prefix" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x01\x1b", &h); + try p.feed("\x01\x1b", false, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); try std.testing.expect(!p.pending_prefix); // The prefix is disarmed: the next byte is plain input again. - try p.feed("x", &h); + try p.feed("x", false, &h); try std.testing.expectEqualStrings("x", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -2751,7 +2948,7 @@ test "parser: a mouse click while the prefix is armed cancels it cleanly" { var p: InputParser = .{}; // Esc with trailing bytes is the start of a sequence, not a lone // cancel: the sequence must parse instead of leaking into the pty. - try p.feed("\x01\x1b[<0;5;7M", &h); + try p.feed("\x01\x1b[<0;5;7M", false, &h); try std.testing.expect(!p.pending_prefix); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); const m = h.events.items[0].mouse; @@ -2766,7 +2963,7 @@ test "parser: sgr mouse press and release" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[<0;5;7M\x1b[<0;5;7m", &h); + try p.feed("\x1b[<0;5;7M\x1b[<0;5;7m", false, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); const press = h.events.items[0].mouse; try std.testing.expectEqual(@as(u16, 0), press.code); @@ -2781,8 +2978,8 @@ test "parser: mouse sequence split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[<6", &h); - try p.feed("5;10;2M", &h); + try p.feed("\x1b[<6", false, &h); + try p.feed("5;10;2M", false, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); const m = h.events.items[0].mouse; try std.testing.expectEqual(@as(u16, 65), m.code); @@ -2794,7 +2991,7 @@ test "parser: non-intercepted CSI passes through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[1;5A\x1b[1;5C", &h); + try p.feed("\x1b[1;5A\x1b[1;5C", false, &h); try std.testing.expectEqualStrings("\x1b[1;5A\x1b[1;5C", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -2803,7 +3000,7 @@ test "parser: plain arrows become arrow events" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[A\x1b[B", &h); + try p.feed("\x1b[A\x1b[B", false, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, @@ -2820,7 +3017,7 @@ test "parser: side arrows become arrow events" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[D\x1b[C", &h); + try p.feed("\x1b[D\x1b[C", false, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .left, .prefixed = false } }, @@ -2832,7 +3029,7 @@ test "parser: side arrows become arrow events" { ); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // A side arrow binds to an armed prefix like up/down do. - try p.feed("\x01\x1b[C", &h); + try p.feed("\x01\x1b[C", false, &h); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .right, .prefixed = true } }, h.events.items[2], @@ -2844,7 +3041,7 @@ test "parser: arrows bind to an armed prefix" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x01\x1b[B", &h); + try p.feed("\x01\x1b[B", false, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .down, .prefixed = true } }, @@ -2853,7 +3050,7 @@ test "parser: arrows bind to an armed prefix" { try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // The prefix was consumed: the next bytes are plain input, and a // later bare arrow is not marked prefixed. - try p.feed("x\x1b[A", &h); + try p.feed("x\x1b[A", false, &h); try std.testing.expectEqualStrings("x", h.forwarded.items); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, @@ -2865,9 +3062,9 @@ test "parser: arrow split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[", &h); + try p.feed("\x1b[", false, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); - try p.feed("A", &h); + try p.feed("A", false, &h); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, h.events.items[0], @@ -2879,7 +3076,7 @@ test "parser: bracketed paste protects the prefix byte" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[200~a\x01b\x1b[201~", &h); + try p.feed("\x1b[200~a\x01b\x1b[201~", false, &h); try std.testing.expectEqualStrings("a\x01b", h.forwarded.items); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[0]); @@ -2890,12 +3087,182 @@ test "parser: focus reports" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[I\x1b[O", &h); + try p.feed("\x1b[I\x1b[O", false, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual(InputEvent{ .focus = true }, h.events.items[0]); try std.testing.expectEqual(InputEvent{ .focus = false }, h.events.items[1]); } +test "parser: a held lone esc flushes as the esc key" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b", false, &h); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); + try p.flushEsc(&h); + try std.testing.expectEqualStrings("\x1b", h.escs.items); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + +test "parser: kitty Ctrl+A arms the prefix, plain command key follows" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[97;5ud", true, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 'd' }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: kitty Ctrl+A then encoded command key" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Encoded Ctrl+D, then (after re-arming) report-all-keys plain g. + try p.feed("\x1b[97;5u\x1b[100;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[103u", true, &h); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 0x04 }, h.events.items[0]); + try std.testing.expectEqual(InputEvent{ .prefix = 'g' }, h.events.items[1]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: kitty Ctrl+A split across feeds" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[97;5", true, &h); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + try p.feed("uk", true, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); +} + +test "parser: kitty prefix release and repeat stay armed" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Press, repeat, release of Ctrl+A, then the command key: one + // command, nothing typed into the session. + try p.feed("\x1b[97;5u\x1b[97;5:2u\x1b[97;5:3ud", true, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 'd' }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + // A stray prefix release outside a pending sequence is swallowed. + try p.feed("\x1b[97;5:3u", true, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: kitty modifier key events while armed do not eat the command" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // A reported left-ctrl press between the prefix and the command + // (kitty report-all-keys flag) is not the command key. + try p.feed("\x1b[97;5u\x1b[57442;5u\x1b[100;5u", true, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 0x04 }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: kitty esc cancels an armed prefix" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[97;5u\x1b[27u", true, &h); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + // The prefix is disarmed: the next byte is plain input again. + try p.feed("x", true, &h); + try std.testing.expectEqualStrings("x", h.forwarded.items); +} + +test "parser: kitty esc press becomes the esc key, release passes through" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[27u", true, &h); + try std.testing.expectEqualStrings("\x1b[27u", h.escs.items); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + h.escs.clearRetainingCapacity(); + // The press-event form is the esc key as well. + try p.feed("\x1b[27;1:1u", true, &h); + try std.testing.expectEqualStrings("\x1b[27;1:1u", h.escs.items); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + h.escs.clearRetainingCapacity(); + // Releases and modified Esc belong to the application. + try p.feed("\x1b[27;1:3u\x1b[27;2u", true, &h); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); + try std.testing.expectEqualStrings("\x1b[27;1:3u\x1b[27;2u", h.forwarded.items); +} + +test "parser: kitty other CSI-u keys pass through verbatim" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Shift+Enter, Ctrl+B, and a bare 97 with no ctrl: session input. + try p.feed("\x1b[13;2u", true, &h); + try std.testing.expectEqualStrings("\x1b[13;2u", h.forwarded.items); + h.forwarded.clearRetainingCapacity(); + try p.feed("\x1b[98;5u", true, &h); + try std.testing.expectEqualStrings("\x1b[98;5u", h.forwarded.items); + h.forwarded.clearRetainingCapacity(); + try p.feed("\x1b[97u", true, &h); + try std.testing.expectEqualStrings("\x1b[97u", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); +} + +test "parser: kitty CSI-u encodings pass through when kitty mode is off" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[97;5ud\x1b[27u", false, &h); + try std.testing.expectEqualStrings("\x1b[97;5ud\x1b[27u", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); +} + +test "parser: kitty mode keeps mouse, paste, and arrow interception" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[<0;5;7M\x1b[200~a\x1b[201~\x1b[A", true, &h); + try std.testing.expectEqual(@as(usize, 4), h.events.items.len); + try std.testing.expectEqual(@as(u16, 5), h.events.items[0].mouse.x); + try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[1]); + try std.testing.expectEqual(InputEvent{ .paste = false }, h.events.items[2]); + try std.testing.expectEqual( + InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, + h.events.items[3], + ); + try std.testing.expectEqualStrings("a", h.forwarded.items); +} + +test "parser: kitty CSI-u inside a paste is application input" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[200~\x1b[27u\x1b[201~", true, &h); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); + try std.testing.expectEqualStrings("\x1b[27u", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); +} + +test "parser: legacy escape sequences pass through in kitty mode" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // A modified arrow and an F-key: held longer than in legacy mode + // (the digits stay candidates) but replayed verbatim. + try p.feed("\x1b[1;5A\x1b[15;5~", true, &h); + try std.testing.expectEqualStrings("\x1b[1;5A\x1b[15;5~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + test "ui: automatic focus skips attached sessions and prefers recent ones" { const alloc = std.testing.allocator; var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; diff --git a/test/integration.zig b/test/integration.zig index 99a0a77..5240b37 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1880,6 +1880,127 @@ test "ui: a single esc cancels the rename prompt" { try ui.waitFor("rename cancelled"); } +test "ui: kitty keyboard state mirrors to the client terminal" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // The app enables kitty disambiguation, pops it after one byte of + // input, and echoes everything else visibly via cat -v. + try h.startDetached("kty", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; echo KITTY-ON; " ++ + "head -c 1 >/dev/null; printf '\\033[1u'; echo KITTY-ON; exec cat -v", + }); + const seeded = try h.waitPeekContains("ktf", "KITTY-ON"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("\x1b[=1;1u"); + + // Shift+Enter, as a kitty-mode terminal encodes it, is the + // application's input and must arrive untouched (the original + // failure mode downgraded it to a plain Enter). + try ui.send("\x1b[13;2u"); + const enter = try h.waitPeekContains("ktf", "^[[13;2u"); + alloc.free(enter); + + // The Esc key, kitty-encoded, arrives immediately; nothing eats + // it waiting for a second press. + try ui.send("\x1b[27u"); + const esc = try h.waitPeekContains("ktf", "^[[27u"); + alloc.free(esc); +} + +test "ui: kitty-encoded C-a is the prefix, not session input" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("ktp", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; echo KITTY-ON; exec cat -v", + }); + const seeded = try h.waitPeekContains("ktp", "KITTY-ON"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("\x1b[=1;1u"); + + // C-a d the way a kitty-mode terminal sends it quits the UI. + try ui.send("\x1b[97;5u"); + try ui.send("d"); + try ui.waitFor("[boo ui closed]"); + try std.testing.expectEqual(@as(u32, 0), try ui.waitExit()); + + // The keys were intercepted, not leaked into the session. + const peek = try h.run(&.{ "peek", "ktp" }); + defer alloc.free(peek.stdout); + defer alloc.free(peek.stderr); + try std.testing.expect(peek.term.Exited == 0); + try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "97;5u") == null); +} + +test "ui: prompts suspend the mirrored kitty flags" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("ktg", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; echo KITTY-ON; exec cat -v", + }); + const seeded = try h.waitPeekContains("ktg", "KITTY-ON"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("\x1b[=1;1u"); + + // Opening the goto prompt drops the mirrored flags, so prompt + // input keeps its legacy byte encodings. + ui.clearOutput(); + try ui.send("\x1b[97;5u"); + try ui.send("g"); + try ui.waitFor(" goto: "); + try ui.waitFor("\x1b[=0;1u"); + + // A lone ESC cancels the prompt and the mirror returns. + try ui.send("\x1b"); + try ui.waitFor("goto cancelled"); + try ui.waitFor("\x1b[=1;1u"); +} + test "ui: C-a g goes to a session by name" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); From bbbbb35db25ed2dc81f9e11caacce747c3c41236 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 12 Jun 2026 23:33:31 +0000 Subject: [PATCH 2/3] fix(ui): dispatch kitty C-a C-a, drop modifyOtherKeys mirror, drain on quit Review follow-ups: - A second discrete kitty-encoded C-a press now dispatches the C-a C-a focus-last binding instead of staying armed; only key repeats (event 2) and releases keep the prefix armed, matching the raw-byte path. - modifyOtherKeys is no longer mirrored onto the real terminal: under it the terminal encodes C-a as CSI 27;5;97~, which the UI parser does not decode, so the prefix would stop working whenever vim and friends were focused. CSI-u decode support for that encoding is a possible follow-up. - The CSI hold grammar no longer depends on the kitty state: a CSI-u key split across reads while the mirror flips (prompt opening) replays whole instead of being mangled, and legacy F-keys (ESC [ 15 ~) replay as one chunk instead of leaking "15~" into prompts. Decoding still only happens while kitty is active. - The keyboard mirror re-syncs right after input handling, shrinking the window where a just-opened prompt still receives kitty-encoded keys. - Quitting the UI drains pending terminal input via the same restore helper a plain attach uses, so a still-held C-a C-d cannot leak an EOF into the shell underneath. - Scroll_Lock joins the kitty modifier-key set in keys.zig. - Merged duplicate prompt cancel arms, doc comments for KittyKey and InputParser.feed, and integration-test formatting cleanups. --- src/client.zig | 22 +++-- src/keys.zig | 8 +- src/ui.zig | 194 +++++++++++++++++++++++++++++++------------ test/integration.zig | 15 ++-- 4 files changed, 169 insertions(+), 70 deletions(-) diff --git a/src/client.zig b/src/client.zig index 37338dd..e69f440 100644 --- a/src/client.zig +++ b/src/client.zig @@ -77,8 +77,8 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { try posix.tcsetattr(tty, .FLUSH, raw); // Set by the outcome paths below when a held C-d may still be // repeating; read by the deferred restore. - var drain_guard_ms: i64 = drain_guard_short_ms; - defer restoreTty(tty, saved, drain_guard_ms); + var eof_guard = false; + defer restoreTty(tty, saved, restore_sequence, eof_guard); try protocol.writeAll(1, enter_sequence); // Handshake with our current size. @@ -156,7 +156,7 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { .detached => { if (std.mem.eql(u8, msg.payload, "stolen")) return .stolen; if (std.mem.eql(u8, msg.payload, "detached-eof")) { - drain_guard_ms = drain_guard_eof_ms; + eof_guard = true; } return .detached; }, @@ -164,7 +164,7 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome { // Sessions often end because the user typed // C-d at the session's shell; treat the tail // as EOF-dangerous. - drain_guard_ms = drain_guard_eof_ms; + eof_guard = true; return .ended; }, else => {}, @@ -245,15 +245,23 @@ const drain_guard_eof_ms = 800; const drain_tail_ms = 100; const drain_cap_ms = 1500; -fn restoreTty(tty: posix.fd_t, saved: posix.termios, guard_ms: i64) void { +/// Restore a raw client terminal: screen restore, input drain, then +/// the mode switch. Shared with boo ui, whose quit has the same held +/// command key and kitty release tail to absorb. +pub fn restoreTty( + tty: posix.fd_t, + saved: posix.termios, + restore: []const u8, + eof_guard: bool, +) void { // Screen restore first: the user sees the detach immediately, and // a kitty-mode terminal stops CSI-u key reporting as soon as the // reset reaches it, so a still-held key repeats in legacy bytes // that the drain below absorbs. Only then hand the tty back; the // FLUSH discards anything that slips in between the last drained // read and the mode switch. - protocol.writeAll(1, restore_sequence) catch {}; - drainInput(tty, guard_ms); + protocol.writeAll(1, restore) catch {}; + drainInput(tty, if (eof_guard) drain_guard_eof_ms else drain_guard_short_ms); posix.tcsetattr(tty, .FLUSH, saved) catch {}; } diff --git a/src/keys.zig b/src/keys.zig index 15999fb..4dc8db2 100644 --- a/src/keys.zig +++ b/src/keys.zig @@ -226,6 +226,10 @@ pub const Parser = struct { } }; +/// A decoded kitty CSI-u key. `cp` is the unshifted unicode +/// codepoint, `mods` the raw kitty modifier value (1 means none; the +/// bitmask is `mods - 1`), and `event` the kind of event: 1 press, +/// 2 repeat, 3 release. pub const KittyKey = struct { cp: u32, mods: u32, @@ -233,10 +237,10 @@ pub const KittyKey = struct { }; /// Kitty functional codepoints for keys that never act as command -/// keys: CAPS_LOCK, NUM_LOCK, and LEFT_SHIFT through +/// keys: CAPS_LOCK, SCROLL_LOCK, NUM_LOCK, and LEFT_SHIFT through /// ISO_LEVEL5_SHIFT (modifiers). pub fn isModifierKey(cp: u32) bool { - return cp == 57358 or cp == 57360 or (cp >= 57441 and cp <= 57454); + return (cp >= 57358 and cp <= 57360) or (cp >= 57441 and cp <= 57454); } /// Parse the parameter body of a kitty CSI-u key: sections separated diff --git a/src/ui.zig b/src/ui.zig index 7f8fa03..f51169d 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -193,9 +193,9 @@ pub const InputParser = struct { /// A C-a was seen; the next byte is a command key. pending_prefix: bool = false, /// Held bytes of a possible CSI sequence that may need to be - /// intercepted (arrows, mouse/focus/paste reports, and kitty - /// CSI-u keys). Replayed verbatim the moment the sequence - /// diverges. + /// intercepted (arrows, mouse/focus/paste reports, and + /// parameterized keys such as kitty CSI-u). Replayed verbatim the + /// moment the sequence diverges. held: [hold_max]u8 = undefined, held_len: u8 = 0, /// The held sequence followed an armed prefix: an arrow binds to @@ -203,12 +203,22 @@ pub const InputParser = struct { /// anything else cancels the prefix as before. prefix_held: bool = false, in_paste: bool = false, - /// Kitty CSI-u recognition is active: the real terminal mirrors - /// the focused application's kitty keyboard flags. + /// Kitty CSI-u decoding is active: the real terminal mirrors the + /// focused application's kitty keyboard flags. The hold grammar + /// itself is always on, so a sequence in flight while the mirror + /// flips replays whole instead of splitting. kitty: bool = false, const hold_max = 40; + /// Process a chunk of input. Calls handler.event for every parsed + /// event, including .forward runs of passthrough bytes. The + /// handler must consume event payloads immediately (they alias + /// `input` or the parser's internal hold buffer). + /// + /// `kitty` enables decoding of kitty-keyboard CSI-u encodings; + /// pass whether the real terminal currently mirrors kitty flags. + /// The raw prefix byte is always recognized. pub fn feed(self: *InputParser, input: []const u8, kitty: bool, handler: anytype) !void { self.kitty = kitty; var start: usize = 0; @@ -281,16 +291,18 @@ pub const InputParser = struct { } /// Whether `byte` keeps the held bytes a candidate for a sequence - /// this parser intercepts: plain arrows (ESC [ A/B/C/D), CSI - /// mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), paste - /// markers (ESC [ 200~, ESC [ 201~), and, in kitty mode, CSI-u - /// keys (ESC [ [;:] ... u). + /// this parser handles as a unit: plain arrows (ESC [ A/B/C/D), + /// CSI mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), and + /// parameterized keys (ESC [ [;:] ... ~/u): paste + /// markers, kitty CSI-u keys, and legacy keys that share the + /// grammar (F5 is ESC [ 15 ~). Holding never depends on the kitty + /// state; finishCsi decodes CSI-u keys only when kitty is active + /// and replays them verbatim otherwise. fn heldAccepts(self: *const InputParser, byte: u8) bool { const len = self.held_len; if (len == 1) return byte == '['; if (len == 2) return switch (byte) { - '<', 'I', 'O', '2', 'A', 'B', 'C', 'D' => true, - '0', '1', '3'...'9' => self.kitty, + '<', 'I', 'O', '0'...'9', 'A', 'B', 'C', 'D' => true, else => false, }; return switch (self.held[2]) { @@ -298,13 +310,8 @@ pub const InputParser = struct { '0'...'9', ';', 'M', 'm' => true, else => false, }, - '2' => switch (byte) { - '0'...'9', '~' => true, - ';', ':', 'u' => self.kitty, - else => false, - }, - '0', '1', '3'...'9' => switch (byte) { - '0'...'9', ';', ':', '~', 'u' => self.kitty, + '0'...'9' => switch (byte) { + '0'...'9', ';', ':', '~', 'u' => true, else => false, }, else => false, @@ -412,9 +419,11 @@ pub const InputParser = struct { self.pending_prefix = true; return; } - // The prefix key repeating while held (or pressed again) - // is not a command key; stay armed. - if (key.cp == 'a' and ctrl_only) { + // The prefix key repeating while still held is not a + // command key; stay armed. A discrete second press is + // the C-a C-a binding (focus last), exactly like a + // second raw 0x01, and dispatches below. + if (key.cp == 'a' and ctrl_only and key.event == 2) { self.pending_prefix = true; return; } @@ -453,7 +462,11 @@ pub const InputParser = struct { } // Some other key (Shift+Enter, Ctrl+C, ...): the session's - // input, exactly as the terminal encoded it. + // input, exactly as the terminal encoded it. This includes + // the release of a key whose press the UI consumed (Esc + // after cancelling a prompt): kitty applications must + // tolerate unmatched releases, e.g. after a focus change, so + // no swallow state is kept. try self.flushHeld(handler); } @@ -866,7 +879,7 @@ const enter_sequence = "\x1b[?1002h\x1b[?1006h" ++ // mouse: button events, SGR encoding "\x1b[?1004h" ++ // focus reporting "\x1b[?2004h" ++ // bracketed paste - "\x1b[=0;1u\x1b[>4;0m" ++ // keyboard protocols off until a view sets them + "\x1b[=0;1u" ++ // kitty keyboard off until a view sets it "\x1b]2;boo ui\x07"; // window title /// reset_state_sequence turns every mode above back off. @@ -904,9 +917,13 @@ pub fn run(alloc: std.mem.Allocator, dir: []const u8) !void { var raw = saved; client.rawMode(&raw); try posix.tcsetattr(tty, .FLUSH, raw); - defer posix.tcsetattr(tty, .FLUSH, saved) catch {}; + // Shared restore with a plain attach: screen restore (which also + // resets the mirrored keyboard protocols), input drain, then the + // mode switch. The drain absorbs a still-held quit key, whose + // repeats would otherwise reach the shell; a C-d tail gets the + // longer EOF guard. + defer client.restoreTty(tty, saved, restore_sequence, ui.eof_guard); try protocol.writeAll(1, enter_sequence); - defer protocol.writeAll(1, restore_sequence) catch {}; const ws = ptypkg.getSize(tty) catch ptypkg.makeWinsize(24, 80); ui.layout = .init(ws.row, ws.col); @@ -949,9 +966,6 @@ const Ui = struct { /// kitty-protocol application is focused and no UI prompt owns /// the keyboard. kitty_flags: u5 = 0, - /// modifyOtherKeys=2 state currently applied to the user's real - /// terminal, mirroring the focused view. - modify_keys: bool = false, /// Pending kill confirmation: index into sessions. confirm_kill: ?usize = null, /// Rename input buffer; non-null while the rename prompt is open. @@ -1004,6 +1018,10 @@ const Ui = struct { view_gen: u64 = 0, quitting: bool = false, + /// The quit command key was C-d; the deferred terminal restore + /// uses the longer EOF drain guard, since a still-held C-d + /// repeating into the shell would log the user out. + eof_guard: bool = false, const CellPos = struct { x: u16, y: u16 }; @@ -1045,6 +1063,11 @@ const Ui = struct { if (fds[0].revents != 0) try self.readTty(&buf); if (self.quitting) break; try self.flushPendingEsc(); + // Input may have opened or closed a prompt; re-sync the + // keyboard mirror before the terminal encodes more keys. + // (Idempotent; also called at the end of the iteration + // for view-driven changes.) + self.syncKeyboard(); // Input handling may have switched the focused session; // the poll result then describes the old socket, and @@ -1170,19 +1193,24 @@ const Ui = struct { self.confirm_kill != null or self.browsing or self.resizing; } - /// Mirror the focused application's keyboard protocol state - /// (kitty flags and modifyOtherKeys) onto the real terminal, the - /// same state the repaint of a plain attach replays. Without the - /// mirror the terminal keeps legacy encodings: Shift+Enter is - /// indistinguishable from Enter, and a kitty-mode application - /// sits on a bare Esc waiting for a sequence that never comes. + /// Mirror the focused application's kitty keyboard flags onto + /// the real terminal, the same state the repaint of a plain + /// attach replays. Without the mirror the terminal keeps legacy + /// encodings: Shift+Enter is indistinguishable from Enter, and a + /// kitty-mode application sits on a bare Esc waiting for a + /// sequence that never comes. + /// + /// modifyOtherKeys is deliberately not mirrored: under it the + /// terminal encodes C-a as CSI 27;5;97~, which this parser does + /// not decode, so the prefix key would stop working whenever a + /// modifyOtherKeys application (vim) is focused. Those + /// applications lose only the extra modifier combinations the + /// protocol adds; plain control keys keep their legacy bytes. fn syncKeyboard(self: *Ui) void { var kitty: u5 = 0; - var modify = false; if (!self.uiOwnsKeyboard()) { if (self.liveView()) |v| { kitty = v.term.screens.active.kitty_keyboard.current().int(); - modify = v.term.flags.modify_other_keys_2; } } if (kitty != self.kitty_flags) { @@ -1191,11 +1219,6 @@ const Ui = struct { const seq = std.fmt.bufPrint(&buf, "\x1b[={d};1u", .{kitty}) catch unreachable; protocol.writeAll(1, seq) catch {}; } - if (modify != self.modify_keys) { - self.modify_keys = modify; - const seq: []const u8 = if (modify) "\x1b[>4;2m" else "\x1b[>4;0m"; - protocol.writeAll(1, seq) catch {}; - } } fn handleEvent(self: *Ui, ev: InputEvent) !void { @@ -1357,11 +1380,7 @@ const Ui = struct { self.need_render = true; return true; }, - .prefix => { - self.cancelRename(); - return true; - }, - .esc => { + .prefix, .esc => { self.cancelRename(); return true; }, @@ -1413,11 +1432,7 @@ const Ui = struct { self.need_render = true; return true; }, - .prefix => { - self.cancelGoto(); - return true; - }, - .esc => { + .prefix, .esc => { self.cancelGoto(); return true; }, @@ -1437,7 +1452,14 @@ const Ui = struct { 'k', 0x0b => self.confirmKill(), 'r', 0x12 => self.startRename(), 'g', 0x07 => self.startGoto(), - 'd', 0x04, 'q' => self.quitting = true, + 'd', 'q' => self.quitting = true, + 0x04 => { + // A held C-a C-d may still be repeating C-d when the + // terminal is handed back; mark the restore drain + // EOF-dangerous, like a detach from a plain attach. + self.eof_guard = true; + self.quitting = true; + }, 'n', 0x0e => self.focusOffset(1), 'p', 0x10 => self.focusOffset(-1), 's', 0x13 => self.toggleSidebar(), @@ -2880,6 +2902,9 @@ const TestHandler = struct { alloc: std.mem.Allocator, events: std.ArrayList(InputEvent) = .empty, forwarded: std.ArrayList(u8) = .empty, + /// Number of discrete .forward events; prompts read byte-wise, + /// so chunk boundaries are observable behavior. + forward_chunks: usize = 0, /// Esc-event payload bytes, copied out (they alias the parser's /// hold buffer). escs: std.ArrayList(u8) = .empty, @@ -2892,7 +2917,10 @@ const TestHandler = struct { fn event(self: *TestHandler, ev: InputEvent) !void { switch (ev) { - .forward => |bytes| try self.forwarded.appendSlice(self.alloc, bytes), + .forward => |bytes| { + self.forward_chunks += 1; + try self.forwarded.appendSlice(self.alloc, bytes); + }, .esc => |bytes| try self.escs.appendSlice(self.alloc, bytes), else => try self.events.append(self.alloc, ev), } @@ -3155,6 +3183,18 @@ test "parser: kitty prefix release and repeat stay armed" { try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); } +test "parser: kitty double Ctrl+A press is the focus-last command" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Two discrete presses (not a held-key repeat): the C-a C-a + // focus-last binding, exactly like two raw 0x01 bytes. + try p.feed("\x1b[97;5u\x1b[97;5u", true, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 0x01 }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + test "parser: kitty modifier key events while armed do not eat the command" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); @@ -3180,6 +3220,23 @@ test "parser: kitty esc cancels an armed prefix" { try std.testing.expectEqualStrings("x", h.forwarded.items); } +test "parser: a bare esc after an armed kitty prefix disarms on flush" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // A read ending in a bare ESC while armed could be a split CSI-u + // Esc, so it is held; the flush timeout resolves it as the + // cancel key, consumed silently. + try p.feed("\x1b[97;5u\x1b", true, &h); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + try p.flushEsc(&h); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); + // The prefix is disarmed: the next byte is plain input again. + try p.feed("x", true, &h); + try std.testing.expectEqualStrings("x", h.forwarded.items); +} + test "parser: kitty esc press becomes the esc key, release passes through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); @@ -3226,6 +3283,20 @@ test "parser: kitty CSI-u encodings pass through when kitty mode is off" { try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); } +test "parser: kitty turning off mid-hold replays the sequence whole" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // The mirror dropped (say, a prompt opened) while a CSI-u key + // was split across reads: the bytes replay verbatim instead of + // decoding or splitting. + try p.feed("\x1b[97", true, &h); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + try p.feed(";5ud", false, &h); + try std.testing.expectEqualStrings("\x1b[97;5ud", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + test "parser: kitty mode keeps mouse, paste, and arrow interception" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); @@ -3256,13 +3327,26 @@ test "parser: legacy escape sequences pass through in kitty mode" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - // A modified arrow and an F-key: held longer than in legacy mode - // (the digits stay candidates) but replayed verbatim. + // A modified arrow and an F-key: the digits stay hold candidates + // but the sequences replay verbatim. try p.feed("\x1b[1;5A\x1b[15;5~", true, &h); try std.testing.expectEqualStrings("\x1b[1;5A\x1b[15;5~", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } +test "parser: legacy function keys replay as one chunk" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // F5 with kitty off: held to its final and replayed whole, so a + // prompt's byte-wise reader sees one escape sequence instead of + // "\x1b[" plus literal "15~" typed as text. + try p.feed("\x1b[15~", false, &h); + try std.testing.expectEqualStrings("\x1b[15~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 1), h.forward_chunks); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + test "ui: automatic focus skips attached sessions and prefers recent ones" { const alloc = std.testing.allocator; var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; diff --git a/test/integration.zig b/test/integration.zig index 5240b37..3eff3c1 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1917,8 +1917,9 @@ test "ui: kitty-encoded keys reach the application verbatim" { defer h.deinit(); try h.startDetached("ktf", &.{ - "sh", "-c", - "stty -echo -icanon; printf '\\033[>1u'; echo KITTY-ON; exec cat -v", + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; " ++ + "echo KITTY-ON; exec cat -v", }); const seeded = try h.waitPeekContains("ktf", "KITTY-ON"); alloc.free(seeded); @@ -1947,8 +1948,9 @@ test "ui: kitty-encoded C-a is the prefix, not session input" { defer h.deinit(); try h.startDetached("ktp", &.{ - "sh", "-c", - "stty -echo -icanon; printf '\\033[>1u'; echo KITTY-ON; exec cat -v", + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; " ++ + "echo KITTY-ON; exec cat -v", }); const seeded = try h.waitPeekContains("ktp", "KITTY-ON"); alloc.free(seeded); @@ -1977,8 +1979,9 @@ test "ui: prompts suspend the mirrored kitty flags" { defer h.deinit(); try h.startDetached("ktg", &.{ - "sh", "-c", - "stty -echo -icanon; printf '\\033[>1u'; echo KITTY-ON; exec cat -v", + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; " ++ + "echo KITTY-ON; exec cat -v", }); const seeded = try h.waitPeekContains("ktg", "KITTY-ON"); alloc.free(seeded); From 6ef097d40e27cbac90071692708115b657bb1f10 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 13 Jun 2026 00:50:09 +0000 Subject: [PATCH 3/3] feat: decode modifyOtherKeys and non-Latin layouts in both prefix parsers The boo at repaint has always replayed an application's modifyOtherKeys mode 2 onto the client's real terminal (the formatter's keyboard extra emits CSI > 4;2m), but neither parser decoded the resulting CSI 27;5;97~ encoding of Ctrl+A, so the prefix silently stopped working with vim-class apps on xterm-faithful terminals. Both deferred follow-ups land: - keys.zig grows a Protocols struct ({kitty, modify}) replacing the bool feed parameter in both parsers; the daemon feeds the window's modify_other_keys_2 alongside the kitty flags, and boo ui mirrors modifyOtherKeys onto the real terminal again (suspended during prompts like the kitty flags) now that the parser understands it. - parseModify decodes CSI 27;mods;cp~ (no event types, no release handling; a repeated prefix stays armed in boo at and dispatches C-a C-a in boo ui, matching each parser's raw-byte semantics). - parseKitty reads the base-layout-key subfield (cp:shifted:base) and effectiveCp matches the prefix and command keys through it when the primary codepoint is non-ASCII, so C-a works on Cyrillic and other non-Latin layouts under the kitty alternate-keys flag. ASCII primaries always win, keeping AZERTY-style Latin layouts on their legacy typed-character semantics. - keys.zig's hold grammar loosens to any-digit codepoints (required for base matching) with finals gated per protocol; divergence still replays held bytes verbatim. 16 new unit tests across both parsers (modify prefix/command/repeat/ passthrough/off/split, dual-protocol decode, Cyrillic prefix+command, AZERTY non-match, paste protection) and 3 integration tests: boo at detach via CSI 27;5;97~ with repaint replay, boo ui modify mirror with prompt suspension, and boo ui modify-encoded quit without leak. --- src/daemon.zig | 13 +- src/keys.zig | 383 ++++++++++++++++++++++++++++++++++--------- src/ui.zig | 340 +++++++++++++++++++++++++++----------- src/window.zig | 8 + test/integration.zig | 115 +++++++++++++ 5 files changed, 677 insertions(+), 182 deletions(-) diff --git a/src/daemon.zig b/src/daemon.zig index 6f0d22e..191d35b 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -269,11 +269,14 @@ pub const Daemon = struct { try h.daemon.handleKeyCommand(h.conn, cmd); } }; - // When the window runs the kitty keyboard protocol, - // the client's terminal mirrors it and sends the - // prefix key CSI-u encoded. - const kitty = if (self.liveWindow()) |w| w.kittyKeysActive() else false; - try self.key_parser.feed(msg.payload, kitty, Handler{ .daemon = self, .conn = conn }); + // When the window runs the kitty keyboard protocol + // or modifyOtherKeys, the client's terminal mirrors + // it and sends the prefix key encoded. + const prot: keys.Protocols = if (self.liveWindow()) |w| .{ + .kitty = w.kittyKeysActive(), + .modify = w.modifyKeysActive(), + } else .{}; + try self.key_parser.feed(msg.payload, prot, Handler{ .daemon = self, .conn = conn }); }, .resize => { diff --git a/src/keys.zig b/src/keys.zig index 4dc8db2..4bda121 100644 --- a/src/keys.zig +++ b/src/keys.zig @@ -3,17 +3,35 @@ //! Raw client input is scanned for the escape byte. Bytes that are not //! part of a command pass through to the active window unchanged. //! -//! When the active window has the kitty keyboard protocol enabled, the -//! attached client's real terminal mirrors that state (the repaint -//! replays it), so the terminal sends Ctrl+A as `ESC [ 97 ; mods u` -//! instead of 0x01. In that mode the parser also recognizes the CSI-u -//! encodings of the prefix and of the command key that follows it; -//! every other CSI-u sequence passes through to the window unchanged. +//! When the active window has the kitty keyboard protocol or xterm +//! modifyOtherKeys (mode 2) enabled, the attached client's real +//! terminal mirrors that state (the repaint replays it), so the +//! terminal may send Ctrl+A as `ESC [ 97 ; mods u` or +//! `ESC [ 27 ; mods ; 97 ~` instead of 0x01. With the matching +//! protocol active the parser also recognizes those encodings of the +//! prefix and of the command key that follows it; every other encoded +//! key passes through to the window unchanged. const std = @import("std"); pub const escape_byte: u8 = 0x01; // C-a +/// Which keyboard protocols the client's real terminal currently has +/// active, mirrored from the focused window or view. Gates which +/// input encodings the parsers decode; everything else is held only +/// long enough to know it is not an encoded prefix, then replayed +/// verbatim. +pub const Protocols = struct { + /// Kitty keyboard protocol: CSI-u keys decode. + kitty: bool = false, + /// xterm modifyOtherKeys mode 2: CSI 27;mods;cp~ keys decode. + modify: bool = false, + + pub fn any(self: Protocols) bool { + return self.kitty or self.modify; + } +}; + pub const Command = union(enum) { /// Bytes to forward to the window. forward: []const u8, @@ -27,10 +45,13 @@ pub const Command = union(enum) { pub const Parser = struct { /// Whether the previous byte ended inside a prefix sequence. pending: bool = false, - /// CSI-u candidate bytes held until the sequence either completes - /// or diverges (it can split across reads). + /// Encoded-key candidate bytes held until the sequence either + /// completes or diverges (it can split across reads). held: [held_max]u8 = undefined, held_len: u8 = 0, + /// Protocol state of the most recent feed; gates which finals the + /// hold grammar accepts and which sequences decode. + prot: Protocols = .{}, const held_max = 48; @@ -39,16 +60,17 @@ pub const Parser = struct { /// must consume forwarded slices immediately (they alias `input` or /// the parser's internal hold buffer). /// - /// `kitty` enables recognition of kitty-keyboard CSI-u encodings; - /// pass the active window's protocol state. The raw escape byte is - /// always recognized in either mode. + /// `prot` enables recognition of kitty-keyboard CSI-u and + /// modifyOtherKeys encodings; pass the active window's protocol + /// state. The raw escape byte is always recognized in any mode. /// /// Bindings mirror GNU screen's defaults, including the C-x /// variants (`C-a C-d` detaches like `C-a d`). - pub fn feed(self: *Parser, input: []const u8, kitty: bool, handler: anytype) !void { - // The terminal left kitty mode while a candidate was held: - // the bytes belong to the window after all. - if (!kitty and self.held_len > 0) try self.flushHeld(handler); + pub fn feed(self: *Parser, input: []const u8, prot: Protocols, handler: anytype) !void { + self.prot = prot; + // The terminal left every keyboard protocol while a candidate + // was held: the bytes belong to the window after all. + if (!prot.any() and self.held_len > 0) try self.flushHeld(handler); var start: usize = 0; var i: usize = 0; @@ -63,6 +85,8 @@ pub const Parser = struct { start = i; if (byte == 'u') { try self.finishCsiU(handler); + } else if (byte == '~') { + try self.finishModify(handler); } else if (self.held_len == held_max) { try self.flushHeld(handler); } @@ -75,8 +99,8 @@ pub const Parser = struct { } if (self.pending) { - if (kitty and byte == 0x1b) { - // The command key may arrive kitty-encoded. + if (prot.any() and byte == 0x1b) { + // The command key may arrive encoded. self.held[0] = byte; self.held_len = 1; i += 1; @@ -107,7 +131,7 @@ pub const Parser = struct { continue; } - if (kitty and byte == 0x1b) { + if (prot.any() and byte == 0x1b) { if (i > start) try handler.command(.{ .forward = input[start..i] }); self.held[0] = byte; self.held_len = 1; @@ -124,33 +148,24 @@ pub const Parser = struct { } } - /// Whether `byte` keeps the held bytes a viable candidate for a - /// CSI-u sequence this parser may intercept. While not pending, - /// only the prefix key (codepoint 97) is interceptable, so the - /// codepoint digits are matched strictly; while pending, any - /// codepoint may decode to a command key. + /// Whether `byte` keeps the held bytes a viable candidate for an + /// encoded key this parser may intercept: + /// `ESC [ [;:] ... u` (kitty CSI-u) or + /// `ESC [ 27 ; mods ; cp ~` (modifyOtherKeys). The finals are + /// gated on the matching protocol being active; the digit body is + /// shared. Codepoints are not matched structurally here because a + /// non-Latin layout's prefix key reports the layout codepoint + /// first and the standard-layout key in a subfield, so any + /// complete key may turn out to be the prefix. fn heldAccepts(self: *const Parser, byte: u8) bool { const len = self.held_len; if (len == 1) return byte == '['; - if (self.pending) { - // ESC [ [;:] ... u - if (len == 2) return byte >= '0' and byte <= '9'; - return switch (byte) { - '0'...'9', ';', ':' => true, - 'u' => true, - else => false, - }; - } - // ESC [ 9 7 then a section terminator: anything else is some - // other key or a different control sequence entirely. - return switch (len) { - 2 => byte == '9', - 3 => byte == '7', - else => switch (byte) { - '0'...'9', ';', ':' => true, - 'u' => true, - else => false, - }, + if (len == 2) return byte >= '0' and byte <= '9'; + return switch (byte) { + '0'...'9', ';', ':' => true, + 'u' => self.prot.kitty, + '~' => self.prot.modify, + else => false, }; } @@ -165,6 +180,7 @@ pub const Parser = struct { const ctrl_only = mods == 0x4; const plain = mods == 0; const release = key.event == 3; + const cp = effectiveCp(key); if (self.pending) { self.held_len = 0; @@ -173,12 +189,52 @@ pub const Parser = struct { if (release) return; // The prefix key repeating while held (or pressed again) // is not a command key; stay armed. - if (key.cp == 'a' and ctrl_only) return; + if (cp == 'a' and ctrl_only) return; // Modifier and lock keys are reported as keys of their // own under the kitty "report all keys" flag; holding or // tapping one while armed must not eat the command key. if (isModifierKey(key.cp)) return; self.pending = false; + if (ctrl_only and cp >= 'a' and cp <= 'z') { + return dispatch(@intCast(cp & 0x1f), handler); + } + if (plain and cp >= 0x20 and cp <= 0x7f) { + return dispatch(@intCast(cp), handler); + } + return handler.command(.{ + .unknown = if (cp <= 0x7f) @intCast(cp) else '?', + }); + } + + if (cp == 'a' and ctrl_only) { + self.held_len = 0; + // Releases are swallowed: the window never saw the press. + if (!release) self.pending = true; + return; + } + + // Some other key (e.g. Ctrl+Shift+A): the window's input. + try self.flushHeld(handler); + } + + /// A complete `ESC [ ... ~` sequence is in the hold buffer: an + /// xterm modifyOtherKeys (mode 2) candidate. The protocol has no + /// event types (every sequence is a press or auto-repeat) and no + /// alternate keys, so the prefix logic is simpler than CSI-u. + fn finishModify(self: *Parser, handler: anytype) !void { + const seq = self.held[2 .. self.held_len - 1]; + const key = parseModify(seq) orelse return self.flushHeld(handler); + + const mods = (key.mods -| 1) & 0x3f; + const ctrl_only = mods == 0x4; + const plain = mods == 0; + + if (self.pending) { + self.held_len = 0; + // The prefix key repeating (or pressed again) is not a + // command key; stay armed, like the raw byte. + if (key.cp == 'a' and ctrl_only) return; + self.pending = false; if (ctrl_only and key.cp >= 'a' and key.cp <= 'z') { return dispatch(@intCast(key.cp & 0x1f), handler); } @@ -192,12 +248,11 @@ pub const Parser = struct { if (key.cp == 'a' and ctrl_only) { self.held_len = 0; - // Releases are swallowed: the window never saw the press. - if (!release) self.pending = true; + self.pending = true; return; } - // Some other key (e.g. Ctrl+Shift+A): the window's input. + // Some other key (e.g. Ctrl+Shift+H): the window's input. try self.flushHeld(handler); } @@ -228,14 +283,36 @@ pub const Parser = struct { /// A decoded kitty CSI-u key. `cp` is the unshifted unicode /// codepoint, `mods` the raw kitty modifier value (1 means none; the -/// bitmask is `mods - 1`), and `event` the kind of event: 1 press, -/// 2 repeat, 3 release. +/// bitmask is `mods - 1`), `event` the kind of event (1 press, +/// 2 repeat, 3 release), and `base` the key at the same position in +/// the standard PC-101 layout, reported (under the alternate-keys +/// flag) when the active layout differs. pub const KittyKey = struct { cp: u32, mods: u32, event: u32, + base: ?u32 = null, }; +/// A decoded xterm modifyOtherKeys (mode 2) key: `CSI 27;mods;cp~`. +/// `mods` uses the same encoding as kitty (1 means none). +pub const ModifyKey = struct { + cp: u32, + mods: u32, +}; + +/// The codepoint used for prefix and command-key matching. A +/// non-Latin layout (Cyrillic and friends) reports its own codepoint +/// first and the standard-layout key in the base subfield; matching +/// the base makes C-a layout-independent, the way kitty recommends +/// shortcuts match. ASCII primaries always win, so Latin layouts +/// that merely move keys around (AZERTY) keep their typed-character +/// semantics, exactly like the legacy byte encodings. +pub fn effectiveCp(key: KittyKey) u32 { + if (key.cp < 0x80) return key.cp; + return key.base orelse key.cp; +} + /// Kitty functional codepoints for keys that never act as command /// keys: CAPS_LOCK, SCROLL_LOCK, NUM_LOCK, and LEFT_SHIFT through /// ISO_LEVEL5_SHIFT (modifiers). @@ -244,8 +321,9 @@ pub fn isModifierKey(cp: u32) bool { } /// Parse the parameter body of a kitty CSI-u key: sections separated -/// by ';' (codepoint, modifiers, text), subfields by ':'. Returns null -/// when the body is not a well-formed key encoding. +/// by ';' (codepoint, modifiers, text), subfields by ':' (codepoint, +/// shifted key, base layout key). Returns null when the body is not +/// a well-formed key encoding. pub fn parseKitty(body: []const u8) ?KittyKey { var key: KittyKey = .{ .cp = 0, .mods = 1, .event = 1 }; var sections = std.mem.splitScalar(u8, body, ';'); @@ -254,6 +332,18 @@ pub fn parseKitty(body: []const u8) ?KittyKey { var cp_fields = std.mem.splitScalar(u8, cp_section, ':'); const cp_text = cp_fields.next() orelse return null; key.cp = std.fmt.parseInt(u32, cp_text, 10) catch return null; + // The shifted key (irrelevant here: the prefix and command keys + // are unshifted) may be empty when only the base key is reported. + if (cp_fields.next()) |shifted_text| { + if (shifted_text.len > 0) { + _ = std.fmt.parseInt(u32, shifted_text, 10) catch return null; + } + } + if (cp_fields.next()) |base_text| { + if (base_text.len > 0) { + key.base = std.fmt.parseInt(u32, base_text, 10) catch return null; + } + } if (sections.next()) |mods_section| { var mod_fields = std.mem.splitScalar(u8, mods_section, ':'); @@ -270,6 +360,23 @@ pub fn parseKitty(body: []const u8) ?KittyKey { return key; } +/// Parse the parameter body of an xterm modifyOtherKeys (mode 2) +/// sequence: `27 ; modifiers ; codepoint`. Returns null when the +/// body is not exactly that shape (paste markers and function keys +/// share the '~' final but never the 27 marker). +pub fn parseModify(body: []const u8) ?ModifyKey { + var sections = std.mem.splitScalar(u8, body, ';'); + const marker = sections.next() orelse return null; + if (!std.mem.eql(u8, marker, "27")) return null; + const mods_text = sections.next() orelse return null; + const cp_text = sections.next() orelse return null; + if (sections.next() != null) return null; + return .{ + .cp = std.fmt.parseInt(u32, cp_text, 10) catch return null, + .mods = std.fmt.parseInt(u32, mods_text, 10) catch return null, + }; +} + const TestHandler = struct { alloc: std.mem.Allocator, cmds: std.ArrayList(Command) = .empty, @@ -292,7 +399,7 @@ test "plain bytes pass through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("hello world", false, &h); + try p.feed("hello world", .{}, &h); try std.testing.expectEqualStrings("hello world", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } @@ -301,7 +408,7 @@ test "prefix commands" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("ab\x01lde\x01df", false, &h); + try p.feed("ab\x01lde\x01df", .{}, &h); try std.testing.expectEqualStrings("abdef", h.forwarded.items); try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len); try std.testing.expectEqual(Command.redraw, h.cmds.items[0]); @@ -312,7 +419,7 @@ test "literal escape via C-a a" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x01a", false, &h); + try p.feed("\x01a", .{}, &h); try std.testing.expectEqualStrings("\x01", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } @@ -321,10 +428,10 @@ test "prefix split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("xy\x01", false, &h); + try p.feed("xy\x01", .{}, &h); try std.testing.expectEqualStrings("xy", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); - try p.feed("d", false, &h); + try p.feed("d", .{}, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); } @@ -333,7 +440,7 @@ test "control variants match screen defaults" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x01\x04\x01\x0c", false, &h); + try p.feed("\x01\x04\x01\x0c", .{}, &h); try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); try std.testing.expectEqual(Command.redraw, h.cmds.items[1]); @@ -346,7 +453,7 @@ test "holding the prefix key stays armed until a command key" { var p: Parser = .{}; // Auto-repeat of C-a then C-d: one detach, nothing typed into // the window (an unconsumed 0x04 would EOF a shell). - try p.feed("\x01\x01\x01\x04", false, &h); + try p.feed("\x01\x01\x01\x04", .{}, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -356,7 +463,7 @@ test "unknown command reported" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x01z", false, &h); + try p.feed("\x01z", .{}, &h); try std.testing.expectEqual(Command{ .unknown = 'z' }, h.cmds.items[0]); } @@ -364,7 +471,7 @@ test "kitty: encoded Ctrl+A starts the prefix, plain d detaches" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97;5ud", true, &h); + try p.feed("\x1b[97;5ud", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -374,7 +481,7 @@ test "kitty: encoded Ctrl+A then encoded Ctrl+D detaches" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97;5u\x1b[100;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[100;5u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -384,7 +491,7 @@ test "kitty: report-all plain d arrives as CSI-u and detaches" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97;5u\x1b[100u", true, &h); + try p.feed("\x1b[97;5u\x1b[100u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); } @@ -393,9 +500,9 @@ test "kitty: sequence split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97;5", true, &h); + try p.feed("\x1b[97;5", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); - try p.feed("ud", true, &h); + try p.feed("ud", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); } @@ -405,12 +512,12 @@ test "kitty: press and release events" { defer h.deinit(); var p: Parser = .{}; // Press (explicit event), release while pending, then the command. - try p.feed("\x1b[97;5:1u\x1b[97;5:3ud", true, &h); + try p.feed("\x1b[97;5:1u\x1b[97;5:3ud", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // A stray prefix release outside a pending sequence is swallowed. - try p.feed("\x1b[97;5:3u", true, &h); + try p.feed("\x1b[97;5:3u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); } @@ -420,7 +527,7 @@ test "kitty: prefix auto-repeat stays armed" { defer h.deinit(); var p: Parser = .{}; // With event types: press, repeat, then encoded Ctrl+D. - try p.feed("\x1b[97;5u\x1b[97;5:2u\x1b[100;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[97;5:2u\x1b[100;5u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -431,7 +538,7 @@ test "kitty: prefix repeat without event types stays armed" { defer h.deinit(); var p: Parser = .{}; // Without the event-types flag a repeat looks like a second press. - try p.feed("\x1b[97;5u\x1b[97;5u\x1b[100;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[97;5u\x1b[100;5u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -443,7 +550,7 @@ test "kitty: modifier key events while armed do not eat the command" { var p: Parser = .{}; // A reported left-ctrl press between the prefix and the command // (kitty report-all-keys flag) is not the command key. - try p.feed("\x1b[97;5u\x1b[57442;5u\x1b[100;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[57442;5u\x1b[100;5u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -454,7 +561,7 @@ test "kitty: lock modifiers do not hide the prefix" { defer h.deinit(); var p: Parser = .{}; // mods 69 = 1 + ctrl(4) + caps lock(64). - try p.feed("\x1b[97;69ud", true, &h); + try p.feed("\x1b[97;69ud", .{ .kitty = true }, &h); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); } @@ -463,13 +570,13 @@ test "kitty: other CSI-u keys pass through verbatim" { defer h.deinit(); var p: Parser = .{}; // Ctrl+B, Ctrl+Shift+A, and a bare 97 with no ctrl: all window input. - try p.feed("\x1b[98;5u", true, &h); + try p.feed("\x1b[98;5u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[98;5u", h.forwarded.items); h.forwarded.clearRetainingCapacity(); - try p.feed("\x1b[97;6u", true, &h); + try p.feed("\x1b[97;6u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[97;6u", h.forwarded.items); h.forwarded.clearRetainingCapacity(); - try p.feed("\x1b[97u", true, &h); + try p.feed("\x1b[97u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[97u", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } @@ -480,10 +587,10 @@ test "kitty: diverging sequences replay their held bytes" { var p: Parser = .{}; // An arrow key (CSI A) and a longer codepoint (979) are not held // hostage; raw ESC alone in kitty mode is held until disambiguated. - try p.feed("\x1b[Ax", true, &h); + try p.feed("\x1b[Ax", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[Ax", h.forwarded.items); h.forwarded.clearRetainingCapacity(); - try p.feed("\x1b[979u", true, &h); + try p.feed("\x1b[979u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[979u", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } @@ -492,7 +599,7 @@ test "kitty: raw 0x01 still works" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x01d", true, &h); + try p.feed("\x01d", .{ .kitty = true }, &h); try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); } @@ -500,7 +607,7 @@ test "kitty: CSI-u encodings pass through when kitty mode is off" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97;5ud", false, &h); + try p.feed("\x1b[97;5ud", .{}, &h); try std.testing.expectEqualStrings("\x1b[97;5ud", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } @@ -509,9 +616,9 @@ test "kitty: hold flushed when kitty mode turns off mid-sequence" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97", true, &h); + try p.feed("\x1b[97", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); - try p.feed(";5ud", false, &h); + try p.feed(";5ud", .{}, &h); try std.testing.expectEqualStrings("\x1b[97;5ud", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); } @@ -520,8 +627,124 @@ test "kitty: pending command key in CSI-u form split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: Parser = .{}; - try p.feed("\x1b[97;5u\x1b[100;", true, &h); + try p.feed("\x1b[97;5u\x1b[100;", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); + try p.feed("5u", .{ .kitty = true }, &h); + try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); +} + +test "kitty: non-latin layout prefix and command match the base key" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + // Russian layout: Ctrl+(A position) reports cyrillic ef (1092), + // shifted EF (1060), base 'a'; Ctrl+(D position) reports cyrillic + // de (1076), no shifted subfield, base 'd'. The alternate-keys + // flag is what makes the base subfield available. + try p.feed("\x1b[1092:1060:97;5u\x1b[1076::100;5u", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); + try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + // A plain command key matches through its base as well: C-a then + // the unmodified D-position key still detaches. + try p.feed("\x1b[1092:1060:97;5u\x1b[1076::100u", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len); + try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[1]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "kitty: ascii primaries win over the base key" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + // AZERTY: the key at the PC-101 'a' position types 'q' (cp 113, + // base 97). The typed character wins, exactly like the legacy + // 0x11 byte it sends without the protocol: not the prefix. + try p.feed("\x1b[113:81:97;5u", .{ .kitty = true }, &h); + try std.testing.expectEqualStrings("\x1b[113:81:97;5u", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); - try p.feed("5u", true, &h); +} + +test "modify: encoded Ctrl+A starts the prefix, plain d detaches" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + try p.feed("\x1b[27;5;97~d", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); + try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "modify: encoded Ctrl+A then encoded Ctrl+D detaches" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + try p.feed("\x1b[27;5;97~\x1b[27;5;100~", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "modify: prefix auto-repeat stays armed" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + // modifyOtherKeys has no event types: a held prefix repeats the + // same press sequence, which must not eat the command key. + try p.feed("\x1b[27;5;97~\x1b[27;5;97~\x1b[27;5;100~", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); + try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "modify: other encoded keys pass through verbatim" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + // Ctrl+Shift+H (mods 6) and an F-key sharing the '~' final: all + // window input, replayed whole. + try p.feed("\x1b[27;6;72~", .{ .modify = true }, &h); + try std.testing.expectEqualStrings("\x1b[27;6;72~", h.forwarded.items); + h.forwarded.clearRetainingCapacity(); + try p.feed("\x1b[15;5~", .{ .modify = true }, &h); + try std.testing.expectEqualStrings("\x1b[15;5~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); +} + +test "modify: encodings pass through when the mode is off" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + try p.feed("\x1b[27;5;97~d", .{}, &h); + try std.testing.expectEqualStrings("\x1b[27;5;97~d", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); + // With only kitty active the '~' final diverges and replays. + try p.feed("\x1b[27;5;97~", .{ .kitty = true }, &h); + try std.testing.expectEqualStrings("\x1b[27;5;97~d\x1b[27;5;97~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len); +} + +test "modify: sequence split across feeds" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + try p.feed("\x1b[27;5", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + try p.feed(";97~d", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len); + try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]); +} + +test "modify: kitty and modify decode side by side" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: Parser = .{}; + // Both protocols active: either prefix encoding arms, either + // command encoding dispatches. + const both: Protocols = .{ .kitty = true, .modify = true }; + try p.feed("\x1b[27;5;97~\x1b[100;5u", both, &h); + try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]); + try p.feed("\x1b[97;5u\x1b[27;5;100~", both, &h); + try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[1]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); } diff --git a/src/ui.zig b/src/ui.zig index f51169d..d22991a 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -184,11 +184,11 @@ pub const InputEvent = union(enum) { /// is open the prefix byte is NOT special, so pasted 0x01 bytes reach /// the application (unlike a plain attach). /// -/// When the focused application runs the kitty keyboard protocol the -/// real terminal mirrors that state, so the parser also recognizes -/// the CSI-u encodings of the prefix key, of the command key that -/// follows it, and of the Esc key; every other CSI-u sequence passes -/// through to the session unchanged. +/// When the focused application runs the kitty keyboard protocol or +/// xterm modifyOtherKeys the real terminal mirrors that state, so the +/// parser also recognizes the encodings of the prefix key, of the +/// command key that follows it, and (kitty only) of the Esc key; +/// every other encoded key passes through to the session unchanged. pub const InputParser = struct { /// A C-a was seen; the next byte is a command key. pending_prefix: bool = false, @@ -199,15 +199,16 @@ pub const InputParser = struct { held: [hold_max]u8 = undefined, held_len: u8 = 0, /// The held sequence followed an armed prefix: an arrow binds to - /// it (C-a Up/Down) and a CSI-u key decodes to the command key; - /// anything else cancels the prefix as before. + /// it (C-a Up/Down) and an encoded key decodes to the command + /// key; anything else cancels the prefix as before. prefix_held: bool = false, in_paste: bool = false, - /// Kitty CSI-u decoding is active: the real terminal mirrors the - /// focused application's kitty keyboard flags. The hold grammar - /// itself is always on, so a sequence in flight while the mirror - /// flips replays whole instead of splitting. - kitty: bool = false, + /// Which encoded-key decodes are active: the real terminal + /// mirrors the focused application's kitty flags and + /// modifyOtherKeys state. The hold grammar itself is always on, + /// so a sequence in flight while the mirror flips replays whole + /// instead of splitting. + prot: keys.Protocols = .{}, const hold_max = 40; @@ -216,11 +217,11 @@ pub const InputParser = struct { /// handler must consume event payloads immediately (they alias /// `input` or the parser's internal hold buffer). /// - /// `kitty` enables decoding of kitty-keyboard CSI-u encodings; - /// pass whether the real terminal currently mirrors kitty flags. - /// The raw prefix byte is always recognized. - pub fn feed(self: *InputParser, input: []const u8, kitty: bool, handler: anytype) !void { - self.kitty = kitty; + /// `prot` enables decoding of kitty-keyboard CSI-u and + /// modifyOtherKeys encodings; pass what the real terminal + /// currently mirrors. The raw prefix byte is always recognized. + pub fn feed(self: *InputParser, input: []const u8, prot: keys.Protocols, handler: anytype) !void { + self.prot = prot; var start: usize = 0; var i: usize = 0; while (i < input.len) { @@ -249,11 +250,11 @@ pub const InputParser = struct { // mouse sequence, which must be reprocessed so // its tail is not typed into the application. An // arrow sequence binds back to the prefix - // (C-a Up/Down) via prefix_held. In kitty mode - // the cancel key itself arrives CSI-u encoded, - // so a bare ESC ending the read is a split - // sequence and is held instead. - if (!self.kitty and i + 1 == input.len) { + // (C-a Up/Down) via prefix_held. Under a mirrored + // keyboard protocol the command key itself + // arrives ESC-encoded, so a bare ESC ending the + // read is a split sequence and is held instead. + if (!self.prot.any() and i + 1 == input.len) { i += 1; } else { self.prefix_held = true; @@ -294,10 +295,11 @@ pub const InputParser = struct { /// this parser handles as a unit: plain arrows (ESC [ A/B/C/D), /// CSI mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), and /// parameterized keys (ESC [ [;:] ... ~/u): paste - /// markers, kitty CSI-u keys, and legacy keys that share the - /// grammar (F5 is ESC [ 15 ~). Holding never depends on the kitty - /// state; finishCsi decodes CSI-u keys only when kitty is active - /// and replays them verbatim otherwise. + /// markers, kitty CSI-u keys, modifyOtherKeys, and legacy keys + /// that share the grammar (F5 is ESC [ 15 ~). Holding never + /// depends on the protocol state; finishCsi decodes encoded keys + /// only while the matching protocol is active and replays them + /// verbatim otherwise. fn heldAccepts(self: *const InputParser, byte: u8) bool { const len = self.held_len; if (len == 1) return byte == '['; @@ -354,7 +356,7 @@ pub const InputParser = struct { // Kitty CSI-u keys. Pasted content is the application's // verbatim, like the raw prefix byte. if (final == 'u') { - if (!self.kitty or self.in_paste) return self.flushHeld(handler); + if (!self.prot.kitty or self.in_paste) return self.flushHeld(handler); return self.finishCsiU(prefixed, handler); } @@ -376,6 +378,11 @@ pub const InputParser = struct { self.in_paste = false; return handler.event(.{ .paste = false }); } + // xterm modifyOtherKeys keys, under the same mirror-and- + // paste rules as CSI-u. + if (self.prot.modify and !self.in_paste) { + return self.finishModify(prefixed, handler); + } return self.flushHeld(handler); } @@ -398,7 +405,8 @@ pub const InputParser = struct { /// A complete `ESC [ ... u` sequence is in the hold buffer: a /// kitty CSI-u key. Intercepts the prefix key, the command key /// that follows an armed prefix, and the Esc key, mirroring - /// keys.Parser; every other key is the application's input. + /// keys.Parser (including base-layout-key matching for non-Latin + /// layouts); every other key is the application's input. fn finishCsiU(self: *InputParser, prefixed: bool, handler: anytype) !void { const seq = self.held[0..self.held_len]; const key = keys.parseKitty(seq[2 .. seq.len - 1]) orelse @@ -410,6 +418,7 @@ pub const InputParser = struct { const ctrl_only = mods == 0x4; const plain = mods == 0; const release = key.event == 3; + const cp = keys.effectiveCp(key); if (prefixed) { self.held_len = 0; @@ -423,7 +432,7 @@ pub const InputParser = struct { // command key; stay armed. A discrete second press is // the C-a C-a binding (focus last), exactly like a // second raw 0x01, and dispatches below. - if (key.cp == 'a' and ctrl_only and key.event == 2) { + if (cp == 'a' and ctrl_only and key.event == 2) { self.pending_prefix = true; return; } @@ -436,18 +445,18 @@ pub const InputParser = struct { } // Esc backs out of the armed prefix, like the raw byte. if (key.cp == 27 and plain) return; - if (ctrl_only and key.cp >= 'a' and key.cp <= 'z') { - return handler.event(.{ .prefix = @intCast(key.cp & 0x1f) }); + if (ctrl_only and cp >= 'a' and cp <= 'z') { + return handler.event(.{ .prefix = @intCast(cp & 0x1f) }); } - if (plain and key.cp >= 0x20 and key.cp <= 0x7f) { - return handler.event(.{ .prefix = @intCast(key.cp) }); + if (plain and cp >= 0x20 and cp <= 0x7f) { + return handler.event(.{ .prefix = @intCast(cp) }); } return handler.event(.{ - .prefix = if (key.cp <= 0x7f) @as(u8, @intCast(key.cp)) else '?', + .prefix = if (cp <= 0x7f) @as(u8, @intCast(cp)) else '?', }); } - if (key.cp == 'a' and ctrl_only) { + if (cp == 'a' and ctrl_only) { self.held_len = 0; // Releases are swallowed: the session never saw the press. if (!release) self.pending_prefix = true; @@ -470,6 +479,47 @@ pub const InputParser = struct { try self.flushHeld(handler); } + /// A complete `ESC [ ... ~` non-paste sequence is in the hold + /// buffer while modifyOtherKeys is mirrored: possibly an xterm + /// `CSI 27;mods;cp~` key. Intercepts the prefix key and the + /// command key after an armed prefix, mirroring keys.Parser. The + /// protocol has no event types and never encodes an unmodified + /// Esc, so there is no release or cancel handling; a repeat of + /// the held prefix key is indistinguishable from a second press + /// and dispatches C-a C-a, exactly like repeated raw 0x01 bytes. + fn finishModify(self: *InputParser, prefixed: bool, handler: anytype) !void { + const seq = self.held[0..self.held_len]; + const key = keys.parseModify(seq[2 .. seq.len - 1]) orelse + return self.flushHeld(handler); + + const mods = (key.mods -| 1) & 0x3f; + const ctrl_only = mods == 0x4; + const plain = mods == 0; + + if (prefixed) { + self.held_len = 0; + if (ctrl_only and key.cp >= 'a' and key.cp <= 'z') { + return handler.event(.{ .prefix = @intCast(key.cp & 0x1f) }); + } + if (plain and key.cp >= 0x20 and key.cp <= 0x7f) { + return handler.event(.{ .prefix = @intCast(key.cp) }); + } + return handler.event(.{ + .prefix = if (key.cp <= 0x7f) @as(u8, @intCast(key.cp)) else '?', + }); + } + + if (key.cp == 'a' and ctrl_only) { + self.held_len = 0; + self.pending_prefix = true; + return; + } + + // Some other key (Ctrl+Shift+H, ...): the session's input, + // exactly as the terminal encoded it. + try self.flushHeld(handler); + } + fn parseField(field: ?[]const u8) ?u16 { const text = field orelse return null; return std.fmt.parseInt(u16, text, 10) catch null; @@ -879,7 +929,7 @@ const enter_sequence = "\x1b[?1002h\x1b[?1006h" ++ // mouse: button events, SGR encoding "\x1b[?1004h" ++ // focus reporting "\x1b[?2004h" ++ // bracketed paste - "\x1b[=0;1u" ++ // kitty keyboard off until a view sets it + "\x1b[=0;1u\x1b[>4;0m" ++ // keyboard protocols off until a view sets them "\x1b]2;boo ui\x07"; // window title /// reset_state_sequence turns every mode above back off. @@ -966,6 +1016,9 @@ const Ui = struct { /// kitty-protocol application is focused and no UI prompt owns /// the keyboard. kitty_flags: u5 = 0, + /// modifyOtherKeys=2 state currently applied to the user's real + /// terminal, mirroring the focused view under the same rules. + modify_keys: bool = false, /// Pending kill confirmation: index into sessions. confirm_kill: ?usize = null, /// Rename input buffer; non-null while the rename prompt is open. @@ -1158,7 +1211,10 @@ const Ui = struct { // The status bar shows the keybind list while the prefix is // armed, so arming and disarming both need a repaint. const was_pending = self.parser.pending_prefix; - try self.parser.feed(buf[0..n], self.kitty_flags != 0, Handler{ .ui = self }); + try self.parser.feed(buf[0..n], .{ + .kitty = self.kitty_flags != 0, + .modify = self.modify_keys, + }, Handler{ .ui = self }); if (self.parser.pending_prefix != was_pending) self.need_render = true; // A read that ends in a bare ESC is ambiguous: the ESC key, // or a split escape sequence. Deliver it on a short timeout @@ -1193,24 +1249,21 @@ const Ui = struct { self.confirm_kill != null or self.browsing or self.resizing; } - /// Mirror the focused application's kitty keyboard flags onto - /// the real terminal, the same state the repaint of a plain - /// attach replays. Without the mirror the terminal keeps legacy - /// encodings: Shift+Enter is indistinguishable from Enter, and a - /// kitty-mode application sits on a bare Esc waiting for a - /// sequence that never comes. - /// - /// modifyOtherKeys is deliberately not mirrored: under it the - /// terminal encodes C-a as CSI 27;5;97~, which this parser does - /// not decode, so the prefix key would stop working whenever a - /// modifyOtherKeys application (vim) is focused. Those - /// applications lose only the extra modifier combinations the - /// protocol adds; plain control keys keep their legacy bytes. + /// Mirror the focused application's keyboard protocol state + /// (kitty flags and modifyOtherKeys) onto the real terminal, the + /// same state the repaint of a plain attach replays. Without the + /// mirror the terminal keeps legacy encodings: Shift+Enter is + /// indistinguishable from Enter, and a kitty-mode application + /// sits on a bare Esc waiting for a sequence that never comes. + /// The parser decodes the prefix and command keys in both + /// encodings, so C-a keeps working while either is mirrored. fn syncKeyboard(self: *Ui) void { var kitty: u5 = 0; + var modify = false; if (!self.uiOwnsKeyboard()) { if (self.liveView()) |v| { kitty = v.term.screens.active.kitty_keyboard.current().int(); + modify = v.term.flags.modify_other_keys_2; } } if (kitty != self.kitty_flags) { @@ -1219,6 +1272,11 @@ const Ui = struct { const seq = std.fmt.bufPrint(&buf, "\x1b[={d};1u", .{kitty}) catch unreachable; protocol.writeAll(1, seq) catch {}; } + if (modify != self.modify_keys) { + self.modify_keys = modify; + const seq: []const u8 = if (modify) "\x1b[>4;2m" else "\x1b[>4;0m"; + protocol.writeAll(1, seq) catch {}; + } } fn handleEvent(self: *Ui, ev: InputEvent) !void { @@ -2931,7 +2989,7 @@ test "parser: plain bytes pass through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("hello", false, &h); + try p.feed("hello", .{}, &h); try std.testing.expectEqualStrings("hello", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -2940,7 +2998,7 @@ test "parser: prefix commands" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("ab\x01cde", false, &h); + try p.feed("ab\x01cde", .{}, &h); try std.testing.expectEqualStrings("abde", h.forwarded.items); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 'c' }, h.events.items[0]); @@ -2950,9 +3008,9 @@ test "parser: prefix split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x01", false, &h); + try p.feed("\x01", .{}, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); - try p.feed("k", false, &h); + try p.feed("k", .{}, &h); try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); } @@ -2960,12 +3018,12 @@ test "parser: esc backs out of an armed prefix" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x01\x1b", false, &h); + try p.feed("\x01\x1b", .{}, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); try std.testing.expect(!p.pending_prefix); // The prefix is disarmed: the next byte is plain input again. - try p.feed("x", false, &h); + try p.feed("x", .{}, &h); try std.testing.expectEqualStrings("x", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -2976,7 +3034,7 @@ test "parser: a mouse click while the prefix is armed cancels it cleanly" { var p: InputParser = .{}; // Esc with trailing bytes is the start of a sequence, not a lone // cancel: the sequence must parse instead of leaking into the pty. - try p.feed("\x01\x1b[<0;5;7M", false, &h); + try p.feed("\x01\x1b[<0;5;7M", .{}, &h); try std.testing.expect(!p.pending_prefix); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); const m = h.events.items[0].mouse; @@ -2991,7 +3049,7 @@ test "parser: sgr mouse press and release" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[<0;5;7M\x1b[<0;5;7m", false, &h); + try p.feed("\x1b[<0;5;7M\x1b[<0;5;7m", .{}, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); const press = h.events.items[0].mouse; try std.testing.expectEqual(@as(u16, 0), press.code); @@ -3006,8 +3064,8 @@ test "parser: mouse sequence split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[<6", false, &h); - try p.feed("5;10;2M", false, &h); + try p.feed("\x1b[<6", .{}, &h); + try p.feed("5;10;2M", .{}, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); const m = h.events.items[0].mouse; try std.testing.expectEqual(@as(u16, 65), m.code); @@ -3019,7 +3077,7 @@ test "parser: non-intercepted CSI passes through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[1;5A\x1b[1;5C", false, &h); + try p.feed("\x1b[1;5A\x1b[1;5C", .{}, &h); try std.testing.expectEqualStrings("\x1b[1;5A\x1b[1;5C", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -3028,7 +3086,7 @@ test "parser: plain arrows become arrow events" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[A\x1b[B", false, &h); + try p.feed("\x1b[A\x1b[B", .{}, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, @@ -3045,7 +3103,7 @@ test "parser: side arrows become arrow events" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[D\x1b[C", false, &h); + try p.feed("\x1b[D\x1b[C", .{}, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .left, .prefixed = false } }, @@ -3057,7 +3115,7 @@ test "parser: side arrows become arrow events" { ); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // A side arrow binds to an armed prefix like up/down do. - try p.feed("\x01\x1b[C", false, &h); + try p.feed("\x01\x1b[C", .{}, &h); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .right, .prefixed = true } }, h.events.items[2], @@ -3069,7 +3127,7 @@ test "parser: arrows bind to an armed prefix" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x01\x1b[B", false, &h); + try p.feed("\x01\x1b[B", .{}, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .down, .prefixed = true } }, @@ -3078,7 +3136,7 @@ test "parser: arrows bind to an armed prefix" { try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // The prefix was consumed: the next bytes are plain input, and a // later bare arrow is not marked prefixed. - try p.feed("x\x1b[A", false, &h); + try p.feed("x\x1b[A", .{}, &h); try std.testing.expectEqualStrings("x", h.forwarded.items); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, @@ -3090,9 +3148,9 @@ test "parser: arrow split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[", false, &h); + try p.feed("\x1b[", .{}, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); - try p.feed("A", false, &h); + try p.feed("A", .{}, &h); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, h.events.items[0], @@ -3104,7 +3162,7 @@ test "parser: bracketed paste protects the prefix byte" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[200~a\x01b\x1b[201~", false, &h); + try p.feed("\x1b[200~a\x01b\x1b[201~", .{}, &h); try std.testing.expectEqualStrings("a\x01b", h.forwarded.items); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[0]); @@ -3115,7 +3173,7 @@ test "parser: focus reports" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[I\x1b[O", false, &h); + try p.feed("\x1b[I\x1b[O", .{}, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual(InputEvent{ .focus = true }, h.events.items[0]); try std.testing.expectEqual(InputEvent{ .focus = false }, h.events.items[1]); @@ -3125,7 +3183,7 @@ test "parser: a held lone esc flushes as the esc key" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b", false, &h); + try p.feed("\x1b", .{}, &h); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); try p.flushEsc(&h); try std.testing.expectEqualStrings("\x1b", h.escs.items); @@ -3137,7 +3195,7 @@ test "parser: kitty Ctrl+A arms the prefix, plain command key follows" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[97;5ud", true, &h); + try p.feed("\x1b[97;5ud", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 'd' }, h.events.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -3148,8 +3206,8 @@ test "parser: kitty Ctrl+A then encoded command key" { defer h.deinit(); var p: InputParser = .{}; // Encoded Ctrl+D, then (after re-arming) report-all-keys plain g. - try p.feed("\x1b[97;5u\x1b[100;5u", true, &h); - try p.feed("\x1b[97;5u\x1b[103u", true, &h); + try p.feed("\x1b[97;5u\x1b[100;5u", .{ .kitty = true }, &h); + try p.feed("\x1b[97;5u\x1b[103u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 0x04 }, h.events.items[0]); try std.testing.expectEqual(InputEvent{ .prefix = 'g' }, h.events.items[1]); @@ -3160,9 +3218,9 @@ test "parser: kitty Ctrl+A split across feeds" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[97;5", true, &h); + try p.feed("\x1b[97;5", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); - try p.feed("uk", true, &h); + try p.feed("uk", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); } @@ -3173,12 +3231,12 @@ test "parser: kitty prefix release and repeat stay armed" { var p: InputParser = .{}; // Press, repeat, release of Ctrl+A, then the command key: one // command, nothing typed into the session. - try p.feed("\x1b[97;5u\x1b[97;5:2u\x1b[97;5:3ud", true, &h); + try p.feed("\x1b[97;5u\x1b[97;5:2u\x1b[97;5:3ud", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 'd' }, h.events.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // A stray prefix release outside a pending sequence is swallowed. - try p.feed("\x1b[97;5:3u", true, &h); + try p.feed("\x1b[97;5:3u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); } @@ -3189,7 +3247,7 @@ test "parser: kitty double Ctrl+A press is the focus-last command" { var p: InputParser = .{}; // Two discrete presses (not a held-key repeat): the C-a C-a // focus-last binding, exactly like two raw 0x01 bytes. - try p.feed("\x1b[97;5u\x1b[97;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[97;5u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 0x01 }, h.events.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -3201,7 +3259,7 @@ test "parser: kitty modifier key events while armed do not eat the command" { var p: InputParser = .{}; // A reported left-ctrl press between the prefix and the command // (kitty report-all-keys flag) is not the command key. - try p.feed("\x1b[97;5u\x1b[57442;5u\x1b[100;5u", true, &h); + try p.feed("\x1b[97;5u\x1b[57442;5u\x1b[100;5u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 1), h.events.items.len); try std.testing.expectEqual(InputEvent{ .prefix = 0x04 }, h.events.items[0]); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); @@ -3211,12 +3269,12 @@ test "parser: kitty esc cancels an armed prefix" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[97;5u\x1b[27u", true, &h); + try p.feed("\x1b[97;5u\x1b[27u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); // The prefix is disarmed: the next byte is plain input again. - try p.feed("x", true, &h); + try p.feed("x", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("x", h.forwarded.items); } @@ -3227,13 +3285,13 @@ test "parser: a bare esc after an armed kitty prefix disarms on flush" { // A read ending in a bare ESC while armed could be a split CSI-u // Esc, so it is held; the flush timeout resolves it as the // cancel key, consumed silently. - try p.feed("\x1b[97;5u\x1b", true, &h); + try p.feed("\x1b[97;5u\x1b", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); try p.flushEsc(&h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); // The prefix is disarmed: the next byte is plain input again. - try p.feed("x", true, &h); + try p.feed("x", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("x", h.forwarded.items); } @@ -3241,17 +3299,17 @@ test "parser: kitty esc press becomes the esc key, release passes through" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[27u", true, &h); + try p.feed("\x1b[27u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[27u", h.escs.items); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); h.escs.clearRetainingCapacity(); // The press-event form is the esc key as well. - try p.feed("\x1b[27;1:1u", true, &h); + try p.feed("\x1b[27;1:1u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[27;1:1u", h.escs.items); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); h.escs.clearRetainingCapacity(); // Releases and modified Esc belong to the application. - try p.feed("\x1b[27;1:3u\x1b[27;2u", true, &h); + try p.feed("\x1b[27;1:3u\x1b[27;2u", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); try std.testing.expectEqualStrings("\x1b[27;1:3u\x1b[27;2u", h.forwarded.items); } @@ -3261,13 +3319,13 @@ test "parser: kitty other CSI-u keys pass through verbatim" { defer h.deinit(); var p: InputParser = .{}; // Shift+Enter, Ctrl+B, and a bare 97 with no ctrl: session input. - try p.feed("\x1b[13;2u", true, &h); + try p.feed("\x1b[13;2u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[13;2u", h.forwarded.items); h.forwarded.clearRetainingCapacity(); - try p.feed("\x1b[98;5u", true, &h); + try p.feed("\x1b[98;5u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[98;5u", h.forwarded.items); h.forwarded.clearRetainingCapacity(); - try p.feed("\x1b[97u", true, &h); + try p.feed("\x1b[97u", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[97u", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); @@ -3277,7 +3335,7 @@ test "parser: kitty CSI-u encodings pass through when kitty mode is off" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[97;5ud\x1b[27u", false, &h); + try p.feed("\x1b[97;5ud\x1b[27u", .{}, &h); try std.testing.expectEqualStrings("\x1b[97;5ud\x1b[27u", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); @@ -3290,9 +3348,9 @@ test "parser: kitty turning off mid-hold replays the sequence whole" { // The mirror dropped (say, a prompt opened) while a CSI-u key // was split across reads: the bytes replay verbatim instead of // decoding or splitting. - try p.feed("\x1b[97", true, &h); + try p.feed("\x1b[97", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); - try p.feed(";5ud", false, &h); + try p.feed(";5ud", .{}, &h); try std.testing.expectEqualStrings("\x1b[97;5ud", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -3301,7 +3359,7 @@ test "parser: kitty mode keeps mouse, paste, and arrow interception" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[<0;5;7M\x1b[200~a\x1b[201~\x1b[A", true, &h); + try p.feed("\x1b[<0;5;7M\x1b[200~a\x1b[201~\x1b[A", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 4), h.events.items.len); try std.testing.expectEqual(@as(u16, 5), h.events.items[0].mouse.x); try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[1]); @@ -3317,7 +3375,7 @@ test "parser: kitty CSI-u inside a paste is application input" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); var p: InputParser = .{}; - try p.feed("\x1b[200~\x1b[27u\x1b[201~", true, &h); + try p.feed("\x1b[200~\x1b[27u\x1b[201~", .{ .kitty = true }, &h); try std.testing.expectEqual(@as(usize, 0), h.escs.items.len); try std.testing.expectEqualStrings("\x1b[27u", h.forwarded.items); try std.testing.expectEqual(@as(usize, 2), h.events.items.len); @@ -3329,7 +3387,7 @@ test "parser: legacy escape sequences pass through in kitty mode" { var p: InputParser = .{}; // A modified arrow and an F-key: the digits stay hold candidates // but the sequences replay verbatim. - try p.feed("\x1b[1;5A\x1b[15;5~", true, &h); + try p.feed("\x1b[1;5A\x1b[15;5~", .{ .kitty = true }, &h); try std.testing.expectEqualStrings("\x1b[1;5A\x1b[15;5~", h.forwarded.items); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } @@ -3341,12 +3399,100 @@ test "parser: legacy function keys replay as one chunk" { // F5 with kitty off: held to its final and replayed whole, so a // prompt's byte-wise reader sees one escape sequence instead of // "\x1b[" plus literal "15~" typed as text. - try p.feed("\x1b[15~", false, &h); + try p.feed("\x1b[15~", .{}, &h); try std.testing.expectEqualStrings("\x1b[15~", h.forwarded.items); try std.testing.expectEqual(@as(usize, 1), h.forward_chunks); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); } +test "parser: non-latin layout kitty prefix and command match the base key" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Russian layout: Ctrl+(A position) reports cyrillic ef (1092) + // with base 'a'; the command key at the D position reports + // cyrillic de (1076) with base 'd'. + try p.feed("\x1b[1092:1060:97;5u\x1b[1076::100;5u", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 0x04 }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + // An ASCII primary wins over its base (AZERTY Ctrl+Q): session + // input, not the prefix. + try p.feed("\x1b[113:81:97;5u", .{ .kitty = true }, &h); + try std.testing.expectEqualStrings("\x1b[113:81:97;5u", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); +} + +test "parser: modify Ctrl+A arms the prefix, plain command key follows" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[27;5;97~d", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 'd' }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: modify Ctrl+A then encoded command key" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[27;5;97~\x1b[27;5;100~", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 0x04 }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: modify double Ctrl+A is the focus-last command" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // No event types exist in this protocol, so a second sequence is + // a second press (or a repeat of a held key): the C-a C-a + // binding, like two raw 0x01 bytes. + try p.feed("\x1b[27;5;97~\x1b[27;5;97~", .{ .modify = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .prefix = 0x01 }, h.events.items[0]); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: modify other encoded keys pass through verbatim" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Ctrl+Shift+H, and the same sequence with the mode off: session + // input either way. + try p.feed("\x1b[27;6;72~", .{ .modify = true }, &h); + try std.testing.expectEqualStrings("\x1b[27;6;72~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 1), h.forward_chunks); + h.forwarded.clearRetainingCapacity(); + try p.feed("\x1b[27;5;97~", .{}, &h); + try std.testing.expectEqualStrings("\x1b[27;5;97~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + +test "parser: modify keys inside a paste are application input" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[200~\x1b[27;5;97~\x1b[201~", .{ .modify = true }, &h); + try std.testing.expectEqualStrings("\x1b[27;5;97~", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[0]); + try std.testing.expectEqual(InputEvent{ .paste = false }, h.events.items[1]); +} + +test "parser: paste markers still decode while modify is mirrored" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + try p.feed("\x1b[200~a\x01b\x1b[201~", .{ .modify = true }, &h); + try std.testing.expectEqualStrings("a\x01b", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 2), h.events.items.len); + try std.testing.expectEqual(InputEvent{ .paste = true }, h.events.items[0]); + try std.testing.expectEqual(InputEvent{ .paste = false }, h.events.items[1]); +} + test "ui: automatic focus skips attached sessions and prefers recent ones" { const alloc = std.testing.allocator; var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; diff --git a/src/window.zig b/src/window.zig index cfdd2da..59ed0e4 100644 --- a/src/window.zig +++ b/src/window.zig @@ -205,6 +205,14 @@ pub const Window = struct { return self.term.screens.active.kitty_keyboard.current().int() != 0; } + /// Whether the application has xterm modifyOtherKeys mode 2 set. + /// While attached, the user's terminal mirrors this state (the + /// repaint's keyboard extra replays CSI > 4 ; 2 m), so the C-a + /// prefix may arrive as CSI 27;5;97~ on xterm-faithful terminals. + pub fn modifyKeysActive(self: *Window) bool { + return self.term.flags.modify_other_keys_2; + } + /// Whether the application is on the alternate screen. The /// passthrough strips screen toggles, so clients cannot tell /// from the byte stream. diff --git a/test/integration.zig b/test/integration.zig index 3eff3c1..2141727 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1175,6 +1175,50 @@ test "kitty keyboard apps: auto-repeated C-a still detaches" { try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "97;5u") == null); } +test "modifyOtherKeys apps: encoded C-a still detaches" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + var client = try PtyClient.spawn(&h, &.{ "new", "mok", "--", "bash", "--norc" }, 24, 80); + defer client.deinit(); + try h.waitSessionUp("mok"); + try client.waitFor("\x1b[H\x1b[2J"); // attach repaint + + // The app enables xterm modifyOtherKeys mode 2, like vim. The + // passthrough mirrors it onto the client's terminal, which (on + // xterm-faithful terminals) then encodes Ctrl+A as CSI 27;5;97~ + // instead of 0x01. The marker is assembled from two printf + // arguments so the echoed command line cannot satisfy the wait. + try client.send("printf '\\033[>4;2mMODIFY-%s\\n' APP; read x\r"); + try client.waitFor("MODIFY-APP"); + try client.waitFor("\x1b[>4;2m"); + + // Press C-a d the way such a terminal sends it. + client.clearOutput(); + try client.send("\x1b[27;5;97~"); + try client.send("d"); + try client.waitFor("detached from mok"); + try std.testing.expectEqual(@as(u32, 0), try client.waitExit()); + + // The keys were intercepted, not leaked into the window. + const peek = try h.run(&.{ "peek", "mok" }); + defer alloc.free(peek.stdout); + defer alloc.free(peek.stderr); + try std.testing.expect(peek.term.Exited == 0); + try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "27;5;97") == null); + + // The session survives; a reattach repaint replays the mode, and + // the fully encoded C-a C-d variant detaches as well. + var second = try PtyClient.spawn(&h, &.{ "attach", "mok" }, 24, 80); + defer second.deinit(); + try second.waitFor("MODIFY-APP"); + try second.waitFor("\x1b[>4;2m"); + try second.send("\x1b[27;5;97~\x1b[27;5;100~"); + try second.waitFor("detached from mok"); + try std.testing.expectEqual(@as(u32, 0), try second.waitExit()); +} + test "agent loop: new, send, wait, peek, kill" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); @@ -2004,6 +2048,77 @@ test "ui: prompts suspend the mirrored kitty flags" { try ui.waitFor("\x1b[=1;1u"); } +test "ui: modifyOtherKeys state mirrors to the client terminal" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // The app sets modifyOtherKeys mode 2 (vim-style), drops it after + // one byte of input, and echoes everything else via cat -v. + try h.startDetached("mdm", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[>4;2m'; echo MODIFY-ON; " ++ + "head -c 1 >/dev/null; printf '\\033[>4;0m'; echo MODIFY-OFF; exec cat -v", + }); + const seeded = try h.waitPeekContains("mdm", "MODIFY-ON"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("MODIFY-ON"); + try ui.waitFor("\x1b[>4;2m"); + + // A prompt suspends the mirror like it does kitty flags; the + // modify-encoded prefix opens it, proving the decode works while + // mirrored. Cancelling restores the mode. + ui.clearOutput(); + try ui.send("\x1b[27;5;97~"); + try ui.send("g"); + try ui.waitFor(" goto: "); + try ui.waitFor("\x1b[>4;0m"); + try ui.send("\x1b"); + try ui.waitFor("goto cancelled"); + try ui.waitFor("\x1b[>4;2m"); + + // The app dropping the mode un-mirrors the terminal. + ui.clearOutput(); + try ui.send("x"); + try ui.waitFor("MODIFY-OFF"); + try ui.waitFor("\x1b[>4;0m"); +} + +test "ui: modify-encoded C-a is the prefix, not session input" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("mdp", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[>4;2m'; " ++ + "echo MODIFY-ON; exec cat -v", + }); + const seeded = try h.waitPeekContains("mdp", "MODIFY-ON"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("\x1b[>4;2m"); + + // C-a d the way an xterm-faithful modifyOtherKeys terminal sends + // it quits the UI. + try ui.send("\x1b[27;5;97~"); + try ui.send("d"); + try ui.waitFor("[boo ui closed]"); + try std.testing.expectEqual(@as(u32, 0), try ui.waitExit()); + + // The keys were intercepted, not leaked into the session. + const peek = try h.run(&.{ "peek", "mdp" }); + defer alloc.free(peek.stdout); + defer alloc.free(peek.stderr); + try std.testing.expect(peek.term.Exited == 0); + try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "27;5;97") == null); +} + test "ui: C-a g goes to a session by name" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);