From f2c0c6f1aa90a567dc9d37c5eb8f53ece58cff88 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 14:11:05 -0300 Subject: Terminal replies and malformed escape sequences never become typed text --- build.zig | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/tty/tty.zig | 79 ++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 79de8a81..aee8541a 100644 --- a/build.zig +++ b/build.zig @@ -682,11 +682,195 @@ pub fn build(b: *std.Build) void { .tables_path = uucode_tables, }).module("uucode"); - const vaxis_mod = b.dependency("vaxis", .{ + const vaxis_dep = b.dependency("vaxis", .{ .target = target, .optimize = optimize, .external_uucode = true, - }).module("vaxis"); + }); + const vaxis_mod = vaxis_dep.module("vaxis"); + // vaxis's input parser, patched here at build time like mvzr so that a + // fresh fetch keeps it: no reply, split read or malformed sequence may + // reach the editor as typed text, and none may panic or end input. + // - A cursor position report that no F3 could have sent is no key: the + // startup width queries' `CSI 1;1R` arrived as two F3s, which a shell + // pane took for text. + // - ESC before a CSI or SS3 key (rxvt's Alt+arrow) is Alt on that key, + // not Alt+Escape followed by `[A` as text. + // - An X10 mouse report and a kitty graphics reply cut short by a read + // wait for their rest: the first became three bytes of text, the + // second sliced past its input. + // - A malformed color or clipboard reply is dropped instead of failing + // the parse, which stopped input; so is a byte no UTF-8 starts with. + // - An OSC reply ends at its first terminator: one ended by BEL ran on to + // the next sequence's ESC and left what lay between as text. + // - A device report's fields are sought in the sequence, not past it. + // A bump that moves an anchor stops the build. + { + const parser_path = vaxis_dep.path("src/Parser.zig").getPath(b); + var src = std.Io.Dir.cwd().readFileAlloc(io, parser_path, b.allocator, .limited(1 << 20)) catch @panic("read vaxis Parser.zig"); + const edits = [_]struct { anchor: []const u8, with: []const u8, count: usize = 1 }{ + .{ + .anchor = + \\ 0x5F => return parseApc(input), + \\ else => { + , + .with = + \\ 0x5F => return parseApc(input), + \\ // pardes's patch (its build.zig): ESC before a key's sequence is Alt on the key. + \\ 0x1B => if (input.len > 2 and (input[2] == '[' or input[2] == 'O')) { + \\ var inner = if (input[2] == '[') parseCsi(input[1..], &self.buf) else parseSs3(input[1..]); + \\ if (inner.n == 0) return inner; + \\ if (inner.event) |*event| switch (event.*) { + \\ .key_press, .key_release => |*key| key.mods.alt = true, + \\ else => {}, + \\ }; + \\ inner.n += 1; + \\ return inner; + \\ } else return .{ .event = .{ .key_press = .{ .codepoint = Key.escape, .mods = .{ .alt = true } } }, .n = 2 }, + \\ else => { + , + }, + .{ + .anchor = " const first_cp = iter.next() orelse return error.InvalidUTF8;\n", + .with = " const first_cp = iter.next() orelse return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return error.InvalidUTF8;\n", + .with = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + .with = + \\ // pardes's patch (its build.zig): an ESC that ends the input is half an ST. + \\ if (input.len < end + 2) return .{ .event = null, .n = 0 }; + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + }, + .{ + .anchor = + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ + \\ // No escape, could be BEL terminated + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07) orelse return .{ + \\ .event = null, + \\ .n = 0, + \\ }; + \\ bel_terminated = true; + \\ break :blk bel + 1; + , + .with = + \\ // pardes's patch (its build.zig): whichever terminator comes first ends it. + \\ const esc = std.mem.indexOfScalarPos(u8, input, 2, 0x1b); + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07); + \\ if (bel != null and (esc == null or bel.? < esc.?)) { + \\ bel_terminated = true; + \\ break :blk bel.? + 1; + \\ } + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ return .{ .event = null, .n = 0 }; + , + }, + .{ + .anchor = " const color = try Color.rgbFromSpec(color_spec);\n", + .with = " const color = Color.rgbFromSpec(color_spec) catch return null_event; // pardes's patch (its build.zig)\n", + .count = 2, + }, + .{ + .anchor = + \\ if (input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = if (bel_terminated) + \\ input[semicolon_idx + 3 .. sequence.len - 1] + \\ else + \\ input[semicolon_idx + 3 .. sequence.len - 2]; + \\ const decoder = std.base64.standard.Decoder; + \\ const text = try paste_allocator.?.alloc(u8, try decoder.calcSizeForSlice(payload)); + \\ try decoder.decode(text, payload); + , + .with = + \\ // pardes's patch (its build.zig): a malformed reply is dropped. + \\ const body_end = if (bel_terminated) sequence.len - 1 else sequence.len - 2; + \\ if (semicolon_idx + 3 > body_end or input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = input[semicolon_idx + 3 .. body_end]; + \\ const decoder = std.base64.standard.Decoder; + \\ const gpa = paste_allocator orelse return null_event; + \\ const text = gpa.alloc(u8, decoder.calcSizeForSlice(payload) catch return null_event) catch return null_event; + \\ decoder.decode(text, payload) catch { + \\ gpa.free(text); + \\ return null_event; + \\ }; + , + }, + .{ + .anchor = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ + , + .with = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ // pardes's patch (its build.zig): a cursor position report is no key. + \\ if (final == 'R' and cursorReport(sequence)) return null_event; + \\ + , + }, + .{ + .anchor = " std.debug.assert(sequence.len >= 4); // ESC [ ? c == 4 bytes\n", + .with = " if (sequence.len < 4) return null_event; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = "const delim_idx = std.mem.indexOfScalarPos(u8, input, 3, ';')", + .with = "const delim_idx = std.mem.indexOfScalarPos(u8, sequence, 3, ';')", + .count = 2, + }, + .{ + .anchor = "input[delim_idx + 1 .. sequence.len - 2]", + .with = "sequence[@min(delim_idx + 1, sequence.len - 2) .. sequence.len - 2]", + }, + .{ + .anchor = " if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) {\n", + .with = + \\ // pardes's patch (its build.zig): an X10 report still arriving is incomplete, not text. + \\ if (input.len == 3 and input[2] == 'M' and full_input.len < 6) return .{ .event = null, .n = 0 }; + \\ if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) { + \\ + , + }, + }; + for (edits) |edit| { + if (std.mem.count(u8, src, edit.anchor) != edit.count) std.debug.panic("vaxis's Parser.zig changed at `{s}`: redo its patch in build.zig", .{edit.anchor}); + src = std.mem.replaceOwned(u8, b.allocator, src, edit.anchor, edit.with) catch @panic("OOM"); + } + const cursor_report = + \\ + \\/// pardes's patch (its build.zig): whether `CSI ... R` answers a cursor + \\/// position query. Only `CSI 1 ; mods R` with mods past 1 is a modified F3 + \\/// (a bare F3 is `SS3 R`); a report has another row, or col 1 (mods 1). + \\fn cursorReport(sequence: []const u8) bool { + \\ const params = sequence[2 .. sequence.len - 1]; + \\ if (params.len > 0 and params[0] == '?') return true; + \\ var fields = std.mem.splitScalar(u8, params, ';'); + \\ const row = fields.first(); + \\ const col = fields.next() orelse return false; + \\ if (fields.next() != null or row.len == 0 or col.len == 0) return false; + \\ for (params) |c| if (c != ';' and !std.ascii.isDigit(c)) return false; + \\ return !std.mem.eql(u8, row, "1") or std.mem.eql(u8, col, "1"); + \\} + \\ + ; + const files = b.addWriteFiles(); + _ = files.add("Parser.zig", b.fmt("{s}{s}", .{ src, cursor_report })); + _ = files.addCopyFile(vaxis_dep.path("src/widgets/terminal/Parser.zig"), "widgets/terminal/Parser.zig"); + // Suffixes: this leaves out both Parser.zig files, written above. + _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{"Parser.zig"} }); + vaxis_mod.root_source_file = files.getDirectory().path(b, "main.zig"); + } var ghostty_vt_for_snap: ?*std.Build.Module = null; const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin()); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index a6f6bdfc..8992b51b 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -243,6 +243,85 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" try std.testing.expect(resized and up and pasted and clipboard and escape); } +test "terminal replies and malformed sequences never become typed text" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ + .parts = &.{ + // The startup width queries' answers where the terminal has no + // explicit width: both used to arrive as a bare F3, typed as text. + "a\x1b[1;1R\x1b[1;1R", + // A late report somewhere else on the screen, and DECXCPR. + "\x1b[12;40R\x1b[?3;7;1R", + // rxvt's Alt+Up: ESC before the sequence, not Alt+Esc then `[A`. + "b\x1b\x1b[A", + // A kitty graphics reply cut inside its ST sliced past its input. + "\x1b_Gi=1;OK\x1b", + "\\", + // An X10 mouse report cut after `CSI M` left three bytes as text. + "\x1b[M", + " !!", + // Malformed color and clipboard replies failed the parse, which + // ended input altogether; and a reply ended by BEL ran on to the + // next sequence's ESC, leaving what lay between as text. + "\x1b]11;rgb:zz/zz/zz\x1b\\\x1b]52;c;!!!!\x07\x1b]52\x07", + // Device reports whose fields end early. + "\x1b[?1;y\x1b[?997;n\x1b[c", + "\x1b[?62;c", + "c", + }, + }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(vx.queries_done.load(.unordered)); + try std.testing.expect(vx.caps.kitty_graphics); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + var alt_up = false; + var clicked = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => {}, + .key_press => |key| { + if (key.codepoint == vaxis.Key.up and key.mods.alt) { + alt_up = true; + } else if (key.text) |bytes| { + try text.appendSlice(gpa, bytes); + } else return error.UnexpectedInputKey; + }, + .mouse => |mouse| { + try std.testing.expectEqual(@as(i16, 0), mouse.col); + clicked = true; + }, + else => return error.UnexpectedInputEvent, + }; + try std.testing.expectEqualStrings("abc", text.items); + try std.testing.expect(alt_up and clicked); +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; -- cgit v1.3 From 1f929613ad10a525fc2c63d399eda9b2827e1bae Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 14:14:47 -0300 Subject: A lone ESC that ends a read waits for the rest of its sequence --- src/tty/tty.zig | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 8992b51b..acd75e0a 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -148,7 +148,19 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (scalar_len > end - (lead - 1)) parse_end = lead - 1; } var consumed: usize = 0; + var waited = false; while (consumed < parse_end) { + // A lone ESC that ends the read is the Escape key, or the first + // byte of a sequence whose rest the read boundary held back (an + // ssh packet, a busy link), which parsed now left the rest to be + // typed as text. Wait a moment for more. Under the kitty keyboard + // protocol Escape is `CSI 27 u`, so there the ESC can only start + // a sequence and the wait can be long. + const rest = buf[consumed..parse_end]; + if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { + waited = true; + if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; + } const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); if (result.n == 0) break; consumed += result.n; @@ -163,6 +175,21 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } } +/// Whether more input is readable within `ms`, polled in short slices so +/// that a cancel of the input thread is still seen. A test reader answers +/// for itself. +fn inputFollows(tty: anytype, io: std.Io, ms: u32) !bool { + if (@hasDecl(@TypeOf(tty.*), "inputFollows")) return tty.inputFollows(ms); + const handle = if (@TypeOf(tty.fd) == std.Io.File) tty.fd.handle else tty.fd; + var fds = [_]posix.pollfd{.{ .fd = handle, .events = posix.POLL.IN, .revents = 0 }}; + var left = ms; + while (left > 0) : (left -|= 10) { + try io.checkCancel(); + if (try posix.poll(&fds, @intCast(@min(left, 10))) > 0) return true; + } + return false; +} + test "terminal input preserves fragmented keys queries paste and text after EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -183,6 +210,9 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } fn read(self: *@This(), buf: []u8) !usize { if (self.next == self.parts.len) return 0; const part = self.parts[self.next]; @@ -263,6 +293,9 @@ test "terminal replies and malformed sequences never become typed text" { fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } fn read(self: *@This(), buf: []u8) !usize { if (self.next == self.parts.len) return 0; const part = self.parts[self.next]; @@ -322,6 +355,102 @@ test "terminal replies and malformed sequences never become typed text" { try std.testing.expect(alt_up and clicked); } +test "terminal input split at every byte with a gap reads as it does whole" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + // Two reads with `gap_ms` between them, as an ssh link delivers a + // sequence cut across two packets. + const Reader = struct { + parts: [2][]const u8, + gap_ms: u32, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), ms: u32) bool { + return self.next < self.parts.len and self.gap_ms <= ms; + } + fn read(self: *@This(), buf: []u8) !usize { + while (self.next < self.parts.len) { + const part = self.parts[self.next]; + self.next += 1; + if (part.len == 0) continue; + @memcpy(buf[0..part.len], part); + return part.len; + } + return 0; + } + /// What the loop was handed, one line per event. + fn events(l: *Loop, out: *std.ArrayList(u8)) !void { + out.clearRetainingCapacity(); + while (try l.tryEvent()) |event| switch (event) { + .winsize => {}, + .key_press => |k| try out.print(std.testing.allocator, "key {d} {d} {?s}\n", .{ k.codepoint, @as(u8, @bitCast(k.mods)), k.text }), + .mouse => |m| try out.print(std.testing.allocator, "mouse {d} {d} {t} {t}\n", .{ m.col, m.row, m.button, m.type }), + .paste => |bytes| { + try out.print(std.testing.allocator, "paste {s}\n", .{bytes}); + std.testing.allocator.free(@constCast(bytes)); + }, + else => |other| try out.print(std.testing.allocator, "{t}\n", .{other}), + }; + } + }; + const Case = struct { bytes: []const u8, kitty: bool = false }; + const cases = [_]Case{ + .{ .bytes = "\x1b[118;5u", .kitty = true }, // Ctrl-V + .{ .bytes = "\x1b[97;;97u", .kitty = true }, // a, with its text + .{ .bytes = "\x1b[57442;5u", .kitty = true }, // Ctrl on its own + .{ .bytes = "\x1b[27u", .kitty = true }, // Escape + .{ .bytes = "\x1b[A" }, + .{ .bytes = "\x1bOA" }, + .{ .bytes = "\x1b[3;5~" }, + .{ .bytes = "\x1b\x1b[A" }, + // Four wheel notches: cut at a `;` with 60 ms between the halves, + // ssh's framing lost all four. + .{ .bytes = "\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M" }, + .{ .bytes = "\x1b[200~pasted\x1b[201~" }, + .{ .bytes = "\x1b]52;c;Y2xpcA==\x1b\\" }, + .{ .bytes = "\x1b_Gi=1;OK\x1b\\" }, + .{ .bytes = "\x1bP>|kitty(0.40)\x1b\\" }, + }; + var whole: std.ArrayList(u8) = .empty; + defer whole.deinit(gpa); + var split: std.ArrayList(u8) = .empty; + defer split.deinit(gpa); + for (cases) |case| { + vx.caps.kitty_keyboard = case.kitty; + const all = try std.mem.concat(gpa, u8, &.{ "x", case.bytes, "y" }); + defer gpa.free(all); + var one: Reader = .{ .parts = .{ all, "" }, .gap_ms = 0 }; + try readInput(&loop, &one, &cache); + try Reader.events(&loop, &whole); + for (1..all.len) |cut| { + // A cut just after an ESC is the one that has to wait, and a + // legacy terminal's Escape waits only 50 ms: 40 ms there. A cut + // anywhere else is carried however long the gap. + const after_esc = all[cut - 1] == 0x1b; + var two: Reader = .{ .parts = .{ all[0..cut], all[cut..] }, .gap_ms = if (after_esc and !case.kitty) 40 else 60 }; + try readInput(&loop, &two, &cache); + try Reader.events(&loop, &split); + std.testing.expectEqualStrings(whole.items, split.items) catch |err| { + std.debug.print("case {f} cut at {d}\n", .{ std.ascii.hexEscape(case.bytes, .lower), cut }); + return err; + }; + } + } + vx.caps.kitty_keyboard = false; +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -341,6 +470,9 @@ test "terminal input cancellation joins blocked reads and queued EOF" { fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(_: *@This(), _: u32) bool { + return false; + } fn read(self: *@This(), buf: []u8) !usize { self.entered.set(std.testing.io); if (self.eof) { -- cgit v1.3 From e373dea7e32ab83e37c9db97ef5709786b9b2188 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 14:16:32 -0300 Subject: A modifier or function key alone types nothing into a terminal pane --- src/Terminal.zig | 6 +++++- src/tty/tty.zig | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/Terminal.zig b/src/Terminal.zig index 32595d25..a32819c3 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1131,7 +1131,11 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { }; var utf8: [4]u8 = undefined; const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; - const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) + // A key with no text of its own types its code point only when that + // is a character: the kitty keyboard protocol numbers function and + // modifier keys in the private use area (F3 is U+E016), and a shell + // was typed those as junk. + const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace and !(key.cp >= 0xE000 and key.cp <= 0xF8FF)) utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] else ""; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index acd75e0a..206d5c58 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -164,6 +164,10 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); if (result.n == 0) break; consumed += result.n; + // A modifier pressed on its own, which the kitty keyboard + // protocol reports: pardes binds none, it would break a pending + // chord, and a shell pane was typed its private-use code. + if (result.event) |event| if (event == .key_press and event.key_press.isModifier()) continue; if (result.event) |event| vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| { if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste)); @@ -1962,6 +1966,67 @@ fn keyEvent(key: vaxis.Key) pardes.Event { } }; } +test "keys with no text of their own type nothing into a terminal pane" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ + .parts = &.{ + // The startup width queries' answers from a terminal without + // explicit width (foot, ghostty, tmux): two F3s, typed at the prompt. + "\x1b[1;1R\x1b[1;1R\x1b[?62;c", + // Kitty reports Ctrl and Shift pressed on their own: the Ctrl of a + // Ctrl-V typed `\x1b[57442;5u` into the shell. + "\x1b[57442;5u\x1b[57441;2u\x1b[57358u", + // F3, F5 and Insert, kitty's way and the legacy way. + "\x1b[13~\x1b[15~\x1b[2~\x1bOR", + "\x1b[104;;104u", + }, + }; + try readInput(&loop, &reader, &cache); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + core.panes[0].?.body.mode = .tty; + while (core.nextEffect()) |_| {} + while (try loop.tryEvent()) |event| switch (event) { + .key_press => |key| core.update(keyEvent(key)), + else => {}, + }; + var written: std.Io.Writer.Allocating = .init(gpa); + defer written.deinit(); + while (core.nextEffect()) |effect| { + if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice()); + } + try std.testing.expectEqualStrings("h", written.written()); +} + test "keypad Enter matches Return for editor input terminal writes and paste" { for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| { const key: vaxis.Key = .{ .codepoint = codepoint }; -- cgit v1.3 From f9502fa884ac3baff04de40c82b68034e02df61e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 14:27:29 -0300 Subject: Function keys, Insert and the keypad reach a terminal pane as their terminal sequences --- src/Terminal.zig | 11 +++++++++++ src/pardes.zig | 8 ++++++++ src/tty/tty.zig | 28 ++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Terminal.zig b/src/Terminal.zig index a32819c3..c99a92a6 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1113,6 +1113,11 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { const pane = p.panes[id] orelse return; const state = pane.terminal orelse return; const input = ghostty_vt.input; + const function_keys = comptime keys: { + var keys: [Key.f25 - Key.f1 + 1]input.Key = undefined; + for (&keys, 1..) |*k, n| k.* = @field(input.Key, std.fmt.comptimePrint("f{d}", .{n})); + break :keys keys; + }; const physical: input.Key = switch (key.cp) { Key.enter => .enter, Key.backspace => .backspace, @@ -1127,6 +1132,12 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { Key.page_up => .page_up, Key.page_down => .page_down, Key.delete => .delete, + Key.insert => .insert, + Key.print_screen => .print_screen, + Key.scroll_lock => .scroll_lock, + Key.pause => .pause, + Key.menu => .context_menu, + Key.f1...Key.f25 => function_keys[key.cp - Key.f1], else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, }; var utf8: [4]u8 = undefined; diff --git a/src/pardes.zig b/src/pardes.zig index ae04b4e7..4fb11362 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3590,6 +3590,14 @@ pub const Key = struct { pub const page_up: u21 = 0xF0007; pub const page_down: u21 = 0xF0008; pub const delete: u21 = 0xF0009; + pub const insert: u21 = 0xF000A; + pub const print_screen: u21 = 0xF000B; + pub const scroll_lock: u21 = 0xF000C; + pub const pause: u21 = 0xF000D; + pub const menu: u21 = 0xF000E; + /// F1 to F25, in order: Fn is `f1 + n - 1`. + pub const f1: u21 = 0xF0011; + pub const f25: u21 = f1 + 24; }; pub fn hit(key: Key, binding: []const config.Chord) bool { diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 206d5c58..01d2f08e 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1941,6 +1941,24 @@ fn mapKey(cp: u21) u21 { vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, + vaxis.Key.insert => pardes.Key.insert, + vaxis.Key.print_screen => pardes.Key.print_screen, + vaxis.Key.scroll_lock => pardes.Key.scroll_lock, + vaxis.Key.pause => pardes.Key.pause, + vaxis.Key.menu => pardes.Key.menu, + vaxis.Key.f1...vaxis.Key.f25 => pardes.Key.f1 + (cp - vaxis.Key.f1), + // The keypad's own keys, which the kitty keyboard protocol tells + // apart, do what the keys they are named for do. + vaxis.Key.kp_left => pardes.Key.left, + vaxis.Key.kp_right => pardes.Key.right, + vaxis.Key.kp_up => pardes.Key.up, + vaxis.Key.kp_down => pardes.Key.down, + vaxis.Key.kp_page_up => pardes.Key.page_up, + vaxis.Key.kp_page_down => pardes.Key.page_down, + vaxis.Key.kp_home => pardes.Key.home, + vaxis.Key.kp_end => pardes.Key.end, + vaxis.Key.kp_insert => pardes.Key.insert, + vaxis.Key.kp_delete => pardes.Key.delete, else => cp, }; } @@ -1966,7 +1984,7 @@ fn keyEvent(key: vaxis.Key) pardes.Event { } }; } -test "keys with no text of their own type nothing into a terminal pane" { +test "a terminal pane gets function keys as their sequences and lone modifiers not at all" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; @@ -2005,8 +2023,10 @@ test "keys with no text of their own type nothing into a terminal pane" { // Kitty reports Ctrl and Shift pressed on their own: the Ctrl of a // Ctrl-V typed `\x1b[57442;5u` into the shell. "\x1b[57442;5u\x1b[57441;2u\x1b[57358u", - // F3, F5 and Insert, kitty's way and the legacy way. - "\x1b[13~\x1b[15~\x1b[2~\x1bOR", + // F3, F5 and Insert the kitty way, F3 the legacy way and the + // keypad's Up: once typed as private-use junk, now what htop, + // mc and vim read them as. + "\x1b[13~\x1b[15~\x1b[2~\x1bOR\x1b[57419u", "\x1b[104;;104u", }, }; @@ -2024,7 +2044,7 @@ test "keys with no text of their own type nothing into a terminal pane" { while (core.nextEffect()) |effect| { if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice()); } - try std.testing.expectEqualStrings("h", written.written()); + try std.testing.expectEqualStrings("\x1bOR\x1b[15~\x1b[2~\x1bOR\x1b[Ah", written.written()); } test "keypad Enter matches Return for editor input terminal writes and paste" { -- cgit v1.3 From b59cb8b28727b5cff1265df65a944926e8ad3a10 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 13:24:25 -0300 Subject: config.md: Crt keeps the GUI drawing while idle --- docs/config.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index b5ec1aa5..c5a79e4c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -554,7 +554,9 @@ keeps the last good one and says why), and GUI attached to a detached session runs the session's chain the same way. With the chain empty the pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom and vignette, and no curvature, so clicks land where -they are drawn. +they are drawn. Its slow hum and dither move on their own, so with Crt on the +GUI keeps drawing while idle (under `ShaderAnimation on`, while focused); +the other bundled passes are still and let it rest. Three more bundled passes take the same levels, each off by default and each costing nothing while off: -- cgit v1.3