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 --- src/tty/tty.zig | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) (limited to 'src/tty/tty.zig') 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(+) (limited to 'src/tty/tty.zig') 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(-) (limited to 'src/tty/tty.zig') 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(-) (limited to 'src/tty/tty.zig') 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