From 3eb37f40dbe8c846b135fa6377f2410a09504929 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 13 Jun 2026 18:07:32 +0000 Subject: [PATCH] fix(ui): recognize encoded arrows so C-a Up/Down/Left/Right works again v0.5.16 began mirroring the focused app's keyboard protocols onto the real terminal. When the app reports event types (kitty flag 2, set by Claude Code and most modern TUIs), the terminal then encodes even an unmodified arrow press as the functional form ESC [ 1;1:1 A rather than the legacy ESC [ A. The UI input parser only recognized the bare form, so after the C-a prefix the encoded arrow was flushed to the session instead of driving sidebar browse (C-a Up/Down) or resize (C-a Left/Right). The parser now accepts the functional cursor-key grammar (ESC [ 1 ; mods [: event] A/B/C/D). An unmodified press or repeat becomes an arrow event (browse/resize); a modified arrow (Ctrl+Left word motion) or a release event is the application's input and is forwarded verbatim. Arrow events now carry their original bytes, so arrows forwarded to the focused app keep the exact encoding the terminal produced instead of a downgraded legacy arrow. Tests: 3 parser unit tests (functional press/repeat navigate, original-byte forwarding, modified/release passthrough) and a PTY integration test driving C-a + report-events Down to browse without leaking into the session. --- src/ui.zig | 166 +++++++++++++++++++++++++++++++++++++------ test/integration.zig | 41 +++++++++++ 2 files changed, 187 insertions(+), 20 deletions(-) diff --git a/src/ui.zig b/src/ui.zig index d22991a..fe7568c 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -173,8 +173,27 @@ pub const InputEvent = union(enum) { pub const Arrow = struct { dir: Dir, prefixed: bool, + /// The original bytes, forwarded verbatim when the arrow is + /// not intercepted for browse/resize, so a modified arrow or + /// the report-events encoding the terminal used reaches the + /// application intact. Empty for arrows constructed without a + /// source sequence; `bytes()` then falls back to the legacy + /// form. + seq: []const u8 = &.{}, pub const Dir = enum { up, down, left, right }; + + /// Bytes to forward to the application: the original sequence + /// when present, else the legacy encoding of the direction. + pub fn bytes(self: Arrow) []const u8 { + if (self.seq.len > 0) return self.seq; + return switch (self.dir) { + .up => "\x1b[A", + .down => "\x1b[B", + .right => "\x1b[C", + .left => "\x1b[D", + }; + } }; }; @@ -292,8 +311,9 @@ pub const InputParser = struct { } /// Whether `byte` keeps the held bytes a candidate for a sequence - /// this parser handles as a unit: plain arrows (ESC [ A/B/C/D), - /// CSI mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), and + /// this parser handles as a unit: plain and functional arrows + /// (ESC [ A/B/C/D and ESC [ 1 ; mods [: event] 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 @@ -313,7 +333,10 @@ pub const InputParser = struct { else => false, }, '0'...'9' => switch (byte) { - '0'...'9', ';', ':', '~', 'u' => true, + // A/B/C/D close the functional cursor-key form + // (ESC [ 1 ; mods [: event] A); ~/u close paste, + // kitty, and modifyOtherKeys keys. + '0'...'9', ';', ':', '~', 'u', 'A', 'B', 'C', 'D' => true, else => false, }, else => false, @@ -334,21 +357,29 @@ pub const InputParser = struct { const prefixed = self.prefix_held; self.prefix_held = false; - // Plain arrows. heldAccepts admits the final only directly - // after the bracket, so the body is always empty; modified - // arrows (ESC [ 1;5 A) diverge earlier and are replayed. + // Arrows. A bare ESC [ A/B/C/D directly after the bracket is + // an unmodified cursor key; the functional form + // ESC [ 1 ; mods [: event] A/B/C/D carries modifiers and, when + // the terminal reports event types, even an unmodified press. + // Only an unmodified press or repeat drives browse/resize; + // modified arrows (Ctrl+Left word motion) and release events + // are the application's input and replay verbatim. switch (final) { 'A', 'B', 'C', 'D' => { - self.held_len = 0; - return handler.event(.{ .arrow = .{ - .dir = switch (final) { - 'A' => .up, - 'B' => .down, - 'C' => .right, - else => .left, - }, - .prefixed = prefixed, - } }); + if (body.len == 0 or arrowNavigates(body)) { + self.held_len = 0; + return handler.event(.{ .arrow = .{ + .dir = switch (final) { + 'A' => .up, + 'B' => .down, + 'C' => .right, + else => .left, + }, + .prefixed = prefixed, + .seq = seq, + } }); + } + return self.flushHeld(handler); }, else => {}, } @@ -525,6 +556,32 @@ pub const InputParser = struct { return std.fmt.parseInt(u16, text, 10) catch null; } + /// Whether the body of a functional cursor key + /// (ESC [ 1 ; mods [: event] A/B/C/D) is an unmodified press or + /// repeat, the only forms that drive browse/resize. A modified + /// arrow (mods other than none, ignoring lock bits) or a release + /// event belongs to the application and replays verbatim. The + /// leading parameter is always `1` for these keys. + fn arrowNavigates(body: []const u8) bool { + var sections = std.mem.splitScalar(u8, body, ';'); + const first = sections.next() orelse return false; + if (!std.mem.eql(u8, first, "1")) return false; + const mods_section = sections.next() orelse return false; + if (sections.next() != null) return false; + var fields = std.mem.splitScalar(u8, mods_section, ':'); + const mods_text = fields.next() orelse return false; + const mods = std.fmt.parseInt(u32, mods_text, 10) catch return false; + // Lock bits (caps/num) do not count as a real modifier. + if ((mods -| 1) & 0x3f != 0) return false; + if (fields.next()) |event_text| { + const event = std.fmt.parseInt(u32, event_text, 10) catch return false; + // 1 press, 2 repeat navigate; 3 release does not. + if (event != 1 and event != 2) return false; + } + if (fields.next() != null) return false; + return true; + } + /// Replay held bytes as session input: the sequence is some other /// key encoding (function keys, modified arrows, ...) that belongs /// to the application. @@ -1344,8 +1401,7 @@ const Ui = struct { } const v = self.liveView() orelse return; self.snapViewBottom(); - v.sendInput(if (a.dir == .left) "\x1b[D" else "\x1b[C") catch - self.markViewLost(); + v.sendInput(a.bytes()) catch self.markViewLost(); }, .up, .down => { // An active resize keeps its width before the @@ -1360,8 +1416,7 @@ const Ui = struct { } const v = self.liveView() orelse return; self.snapViewBottom(); - v.sendInput(if (a.dir == .up) "\x1b[A" else "\x1b[B") catch - self.markViewLost(); + v.sendInput(a.bytes()) catch self.markViewLost(); }, }, .mouse => |m| { @@ -2966,11 +3021,15 @@ const TestHandler = struct { /// Esc-event payload bytes, copied out (they alias the parser's /// hold buffer). escs: std.ArrayList(u8) = .empty, + /// Bytes of the most recent arrow event, copied out (they alias + /// the parser's hold buffer). + last_arrow_seq: std.ArrayList(u8) = .empty, fn deinit(self: *TestHandler) void { self.events.deinit(self.alloc); self.forwarded.deinit(self.alloc); self.escs.deinit(self.alloc); + self.last_arrow_seq.deinit(self.alloc); } fn event(self: *TestHandler, ev: InputEvent) !void { @@ -2980,6 +3039,15 @@ const TestHandler = struct { try self.forwarded.appendSlice(self.alloc, bytes); }, .esc => |bytes| try self.escs.appendSlice(self.alloc, bytes), + .arrow => |a| { + // Copy the seq before storing; the slice aliases the + // hold buffer and is only valid during this call. + self.last_arrow_seq.clearRetainingCapacity(); + try self.last_arrow_seq.appendSlice(self.alloc, a.seq); + var rec = a; + rec.seq = &.{}; + try self.events.append(self.alloc, .{ .arrow = rec }); + }, else => try self.events.append(self.alloc, ev), } } @@ -3158,6 +3226,64 @@ test "parser: arrow split across feeds" { try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); } +test "parser: report-events functional arrows still navigate" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // A report-events terminal encodes even an unmodified arrow press + // as ESC [ 1;1:1 A. After the kitty-encoded prefix it must still + // drive browse/resize, the way the legacy ESC [ A did before the + // keyboard mirror existed. + try p.feed("\x1b[97;5u\x1b[1;1:1A", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqual( + InputEvent{ .arrow = .{ .dir = .up, .prefixed = true } }, + h.events.items[0], + ); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); + // The forms without an event subfield (ESC [ 1;1 A) and the + // repeat event navigate too, unprefixed. + try p.feed("\x1b[1;1B\x1b[1;1:2C", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 3), h.events.items.len); + try std.testing.expectEqual( + InputEvent{ .arrow = .{ .dir = .down, .prefixed = false } }, + h.events.items[1], + ); + try std.testing.expectEqual( + InputEvent{ .arrow = .{ .dir = .right, .prefixed = false } }, + h.events.items[2], + ); + try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len); +} + +test "parser: functional arrows forward the original bytes" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // An unprefixed functional arrow is an arrow event carrying its + // original bytes, so the focused application receives the exact + // report-events encoding rather than a downgraded legacy arrow. + try p.feed("\x1b[1;1:1A", .{ .kitty = true }, &h); + try std.testing.expectEqual(@as(usize, 1), h.events.items.len); + try std.testing.expectEqualStrings("\x1b[1;1:1A", h.last_arrow_seq.items); +} + +test "parser: modified arrows and releases are application input" { + var h: TestHandler = .{ .alloc = std.testing.allocator }; + defer h.deinit(); + var p: InputParser = .{}; + // Ctrl+Left (word motion) and an arrow release are the + // application's, not browse/resize: forwarded verbatim, never an + // arrow event, even right after the prefix. + try p.feed("\x1b[1;5D", .{ .kitty = true }, &h); + try std.testing.expectEqualStrings("\x1b[1;5D", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); + h.forwarded.clearRetainingCapacity(); + try p.feed("\x1b[1;1:3A", .{ .kitty = true }, &h); + try std.testing.expectEqualStrings("\x1b[1;1:3A", h.forwarded.items); + try std.testing.expectEqual(@as(usize, 0), h.events.items.len); +} + test "parser: bracketed paste protects the prefix byte" { var h: TestHandler = .{ .alloc = std.testing.allocator }; defer h.deinit(); diff --git a/test/integration.zig b/test/integration.zig index 2141727..d562f44 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -2218,6 +2218,47 @@ test "ui: arrow browsing selects without attaching until enter" { try std.testing.expect(std.mem.indexOf(u8, bravo_peek.stdout, "STILL-ALPHA-MARK") == null); } +test "ui: report-events arrows browse after the prefix" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // A focused app on the kitty keyboard protocol: the UI mirrors + // it, so a report-events terminal would encode the prefix CSI-u + // and arrows in the functional ESC [ 1 ; mods : event form. cat -v + // makes any leaked bytes visible. + try h.startDetached("kca", &.{ + "sh", "-c", + "stty -echo -icanon; printf '\\033[>1u'; " ++ + "echo KITTY-ON; exec cat -v", + }); + const seeded = try h.waitPeekContains("kca", "KITTY-ON"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("KITTY-ON"); + try ui.waitFor("\x1b[=1;1u"); + + // C-a then Down, exactly as a report-events terminal sends them: + // the prefix kitty-encoded, the arrow with an explicit press + // event subfield. This must arm browse mode (the sidebar hint), + // the regression that the legacy ESC [ B handled before the + // keyboard mirror started re-encoding arrows. + ui.clearOutput(); + try ui.send("\x1b[97;5u"); + try ui.send("\x1b[1;1:1B"); + try ui.waitFor("enter attach"); + + // Neither the prefix nor the arrow leaked into the session. + const peek = try h.run(&.{ "peek", "kca" }); + 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, "1;1:1B") == null); + try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "97;5u") == null); +} + test "ui: enter attaches the selection when nothing is focused" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);