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/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 2455965..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); } @@ -226,23 +281,50 @@ pub const Parser = struct { } }; -const KittyKey = 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`), `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, NUM_LOCK, and LEFT_SHIFT through +/// keys: CAPS_LOCK, SCROLL_LOCK, NUM_LOCK, and LEFT_SHIFT through /// ISO_LEVEL5_SHIFT (modifiers). -fn isModifierKey(cp: u32) bool { - return cp == 57358 or cp == 57360 or (cp >= 57441 and cp <= 57454); +pub fn isModifierKey(cp: u32) bool { + return (cp >= 57358 and 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 { +/// 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, ';'); @@ -250,6 +332,18 @@ 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, ':'); @@ -266,6 +360,23 @@ 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, @@ -288,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); } @@ -297,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]); @@ -308,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); } @@ -317,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]); } @@ -329,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]); @@ -342,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); @@ -352,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]); } @@ -360,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); @@ -370,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); @@ -380,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]); } @@ -389,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]); } @@ -401,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); } @@ -416,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); @@ -427,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); @@ -439,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); @@ -450,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]); } @@ -459,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); } @@ -476,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); } @@ -488,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]); } @@ -496,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); } @@ -505,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); } @@ -516,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 f7f07d4..d22991a 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,45 @@ 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 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, /// 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 + /// 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 - /// it (C-a Up/Down), 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, + /// 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; - pub fn feed(self: *InputParser, input: []const u8, handler: anytype) !void { + /// 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). + /// + /// `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) { @@ -223,8 +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. - if (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; @@ -262,14 +292,19 @@ 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~). + /// 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, 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 == '['; if (len == 2) return switch (byte) { - '<', 'I', 'O', '2', 'A', 'B', 'C', 'D' => true, + '<', 'I', 'O', '0'...'9', 'A', 'B', 'C', 'D' => true, else => false, }; return switch (self.held[2]) { @@ -277,8 +312,8 @@ pub const InputParser = struct { '0'...'9', ';', 'M', 'm' => true, else => false, }, - '2' => switch (byte) { - '0'...'9', '~' => true, + '0'...'9' => switch (byte) { + '0'...'9', ';', ':', '~', 'u' => true, else => false, }, else => false, @@ -287,7 +322,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 +353,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.prot.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); @@ -336,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); } @@ -355,6 +402,124 @@ 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 (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 + 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; + const cp = keys.effectiveCp(key); + + 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 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 (cp == 'a' and ctrl_only and key.event == 2) { + 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 cp >= 'a' and cp <= 'z') { + return handler.event(.{ .prefix = @intCast(cp & 0x1f) }); + } + if (plain and cp >= 0x20 and cp <= 0x7f) { + return handler.event(.{ .prefix = @intCast(cp) }); + } + return handler.event(.{ + .prefix = if (cp <= 0x7f) @as(u8, @intCast(cp)) else '?', + }); + } + + 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; + 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. 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); + } + + /// 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; @@ -369,6 +534,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 +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\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. @@ -785,9 +967,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); @@ -825,6 +1011,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 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. @@ -877,6 +1071,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 }; @@ -918,6 +1116,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 @@ -949,6 +1152,7 @@ const Ui = struct { self.refreshSessions() catch {}; } } + self.syncKeyboard(); } } @@ -1007,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], 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 @@ -1030,7 +1237,46 @@ 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. + /// 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) { + 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 +1302,7 @@ const Ui = struct { } return; }, - .prefix, .arrow => { + .prefix, .arrow, .esc => { self.confirm_kill = null; self.setMessage("kill cancelled", .{}); return; @@ -1137,6 +1383,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(); + }, } } @@ -1173,7 +1438,7 @@ const Ui = struct { self.need_render = true; return true; }, - .prefix => { + .prefix, .esc => { self.cancelRename(); return true; }, @@ -1225,7 +1490,7 @@ const Ui = struct { self.need_render = true; return true; }, - .prefix => { + .prefix, .esc => { self.cancelGoto(); return true; }, @@ -1245,7 +1510,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(), @@ -2688,15 +2960,26 @@ 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, 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), + .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), } } @@ -2706,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", &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); } @@ -2715,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", &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]); @@ -2725,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", &h); + try p.feed("\x01", .{}, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); - try p.feed("k", &h); + try p.feed("k", .{}, &h); try std.testing.expectEqual(InputEvent{ .prefix = 'k' }, h.events.items[0]); } @@ -2735,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", &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", &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); } @@ -2751,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", &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; @@ -2766,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", &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); @@ -2781,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", &h); - try p.feed("5;10;2M", &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); @@ -2794,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", &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); } @@ -2803,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", &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 } }, @@ -2820,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", &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 } }, @@ -2832,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", &h); + try p.feed("\x01\x1b[C", .{}, &h); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .right, .prefixed = true } }, h.events.items[2], @@ -2844,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", &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 } }, @@ -2853,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", &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 } }, @@ -2865,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[", &h); + try p.feed("\x1b[", .{}, &h); try std.testing.expectEqual(@as(usize, 0), h.events.items.len); - try p.feed("A", &h); + try p.feed("A", .{}, &h); try std.testing.expectEqual( InputEvent{ .arrow = .{ .dir = .up, .prefixed = false } }, h.events.items[0], @@ -2879,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~", &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]); @@ -2890,12 +3173,326 @@ 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", .{}, &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", .{}, &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", .{ .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); +} + +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", .{ .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]); + 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", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + 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]); +} + +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", .{ .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", .{ .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); +} + +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", .{ .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); +} + +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", .{ .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); +} + +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", .{ .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", .{ .kitty = true }, &h); + 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", .{ .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", .{ .kitty = 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", .{ .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", .{ .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", .{ .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); +} + +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", .{ .kitty = true }, &h); + try std.testing.expectEqualStrings("\x1b[13;2u", h.forwarded.items); + h.forwarded.clearRetainingCapacity(); + 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", .{ .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); +} + +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", .{}, &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 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", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + 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); +} + +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", .{ .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]); + 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~", .{ .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); +} + +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: the digits stay hold candidates + // but the sequences replay verbatim. + 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); +} + +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~", .{}, &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 99a0a77..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); @@ -1880,6 +1924,201 @@ 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: 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);