236e3205
feat: keymap.zig — normalized key events to VT bytes, tables pinned
a73x 2026-08-13 10:21
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -196,6 +196,15 @@ pub fn build(b: *std.Build) void { | |||
| 196 | replica_mod.addImport("engine", engine_mod); | 196 | replica_mod.addImport("engine", engine_mod); |
| 197 | replica_mod.addImport("protocol", protocol_mod); | 197 | replica_mod.addImport("protocol", protocol_mod); |
| 198 | 198 | ||
| 199 | // Normalized key events -> VT bytes. A leaf with no platform imports — | ||
| 200 | // each shell (browser, later xkb) produces the normalized form and this | ||
| 201 | // owns the bytes; must compile for wasm32. | ||
| 202 | const keymap_mod = b.createModule(.{ | ||
| 203 | .root_source_file = b.path("src/keymap.zig"), | ||
| 204 | .target = target, | ||
| 205 | .optimize = optimize, | ||
| 206 | }); | ||
| 207 | |||
| 199 | // The socket path's identity and the right to bind it: the stale-socket | 208 | // The socket path's identity and the right to bind it: the stale-socket |
| 200 | // claim and the dev+ino record teardown compares against. A leaf — it | 209 | // claim and the dev+ino record teardown compares against. A leaf — it |
| 201 | // takes a path and nothing else, and knows no Server exists. | 210 | // takes a path and nothing else, and knows no Server exists. |
| @@ -404,7 +413,7 @@ pub fn build(b: *std.Build) void { | |||
| 404 | // absence here was a live hazard recorded in decisions.md — muxd's | 413 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 405 | // entrypoint could grow tests that silently never ran, exactly as | 414 | // entrypoint could grow tests that silently never ran, exactly as |
| 406 | // mux_main.zig's five did before it was added. | 415 | // mux_main.zig's five did before it was added. |
| 407 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { | 416 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { |
| 408 | const t = b.addTest(.{ .root_module = mod }); | 417 | const t = b.addTest(.{ .root_module = mod }); |
| 409 | t.use_llvm = true; | 418 | t.use_llvm = true; |
| 410 | t.use_lld = true; | 419 | t.use_lld = true; |
src/keymap.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,327 @@ | |||
| 1 | //! Normalized key events → VT byte sequences. The portable half of input: | ||
| 2 | //! each shell (browser KeyboardEvent, later xkb) produces the normalized | ||
| 3 | //! form; this module owns every byte that reaches the PTY, so the tables | ||
| 4 | //! are unit-tested natively with no browser in the loop (M-web Task 3). | ||
| 5 | //! | ||
| 6 | //! v1 scope (spec): printable input, control characters, arrows and nav | ||
| 7 | //! keys, function keys, the xterm modifier-encoded CSI variants, and | ||
| 8 | //! bracketed paste. Explicitly deferred: kitty/CSI-u. | ||
| 9 | //! | ||
| 10 | //! Deliberately platform-free — no posix, no fds, no clocks — this module | ||
| 11 | //! must compile for wasm32-freestanding. | ||
| 12 | |||
| 13 | const std = @import("std"); | ||
| 14 | |||
| 15 | pub const Mods = packed struct { | ||
| 16 | shift: bool = false, | ||
| 17 | alt: bool = false, | ||
| 18 | ctrl: bool = false, | ||
| 19 | |||
| 20 | /// The xterm modifier parameter: 1 + shift + 2*alt + 4*ctrl. A CSI | ||
| 21 | /// sequence carries it only when it is > 1 — an unmodified key uses | ||
| 22 | /// the short form. | ||
| 23 | pub fn param(self: Mods) u8 { | ||
| 24 | return 1 + @as(u8, @intFromBool(self.shift)) + | ||
| 25 | 2 * @as(u8, @intFromBool(self.alt)) + | ||
| 26 | 4 * @as(u8, @intFromBool(self.ctrl)); | ||
| 27 | } | ||
| 28 | |||
| 29 | pub fn any(self: Mods) bool { | ||
| 30 | return self.shift or self.alt or self.ctrl; | ||
| 31 | } | ||
| 32 | }; | ||
| 33 | |||
| 34 | pub const Key = enum { | ||
| 35 | /// A printable character; `Event.cp` carries the codepoint. | ||
| 36 | char, | ||
| 37 | enter, | ||
| 38 | tab, | ||
| 39 | backspace, | ||
| 40 | escape, | ||
| 41 | up, | ||
| 42 | down, | ||
| 43 | left, | ||
| 44 | right, | ||
| 45 | home, | ||
| 46 | end, | ||
| 47 | insert, | ||
| 48 | delete, | ||
| 49 | page_up, | ||
| 50 | page_down, | ||
| 51 | f1, | ||
| 52 | f2, | ||
| 53 | f3, | ||
| 54 | f4, | ||
| 55 | f5, | ||
| 56 | f6, | ||
| 57 | f7, | ||
| 58 | f8, | ||
| 59 | f9, | ||
| 60 | f10, | ||
| 61 | f11, | ||
| 62 | f12, | ||
| 63 | }; | ||
| 64 | |||
| 65 | pub const Event = struct { | ||
| 66 | key: Key, | ||
| 67 | /// Codepoint for .char, 0 otherwise. | ||
| 68 | cp: u21 = 0, | ||
| 69 | mods: Mods = .{}, | ||
| 70 | }; | ||
| 71 | |||
| 72 | /// Every sequence this module can emit fits here with room to spare (the | ||
| 73 | /// longest is ESC + 4 UTF-8 bytes, or an 8-byte modified CSI). | ||
| 74 | pub const max_seq_len = 16; | ||
| 75 | |||
| 76 | /// Encode one event into `buf` (at least max_seq_len bytes), returning the | ||
| 77 | /// slice written. An event this table has no bytes for — a bare modifier, | ||
| 78 | /// a .char with cp 0 — encodes to the empty slice, which callers send as | ||
| 79 | /// nothing rather than as a surprise. | ||
| 80 | pub fn encode(ev: Event, buf: []u8) []const u8 { | ||
| 81 | std.debug.assert(buf.len >= max_seq_len); | ||
| 82 | switch (ev.key) { | ||
| 83 | .char => { | ||
| 84 | if (ev.cp == 0) return buf[0..0]; | ||
| 85 | var n: usize = 0; | ||
| 86 | if (ev.mods.alt) { | ||
| 87 | buf[0] = 0x1b; | ||
| 88 | n = 1; | ||
| 89 | } | ||
| 90 | if (ev.mods.ctrl) { | ||
| 91 | // The terminal tradition: Ctrl clears bits 6-5 of the | ||
| 92 | // ASCII column, so ctrl-a..z are 0x01..0x1a, ctrl-@ (and | ||
| 93 | // ctrl-space) are NUL, ctrl-[ is ESC. Lowercase folds to | ||
| 94 | // the same control byte as its column; anything outside | ||
| 95 | // the foldable range falls through and sends plain. | ||
| 96 | const c = ev.cp; | ||
| 97 | if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) { | ||
| 98 | buf[n] = @intCast(c & 0x1f); | ||
| 99 | n += 1; | ||
| 100 | return buf[0..n]; | ||
| 101 | } | ||
| 102 | } | ||
| 103 | const len = std.unicode.utf8Encode(ev.cp, buf[n..]) catch return buf[0..0]; | ||
| 104 | return buf[0 .. n + len]; | ||
| 105 | }, | ||
| 106 | .enter => return altable(ev.mods, "\r", buf), | ||
| 107 | .tab => { | ||
| 108 | // Shift+Tab is backtab, its own sequence; plain Tab is a byte. | ||
| 109 | if (ev.mods.shift) return copy("\x1b[Z", buf); | ||
| 110 | return altable(ev.mods, "\t", buf); | ||
| 111 | }, | ||
| 112 | .backspace => { | ||
| 113 | // DEL, the modern default. Ctrl+Backspace sends BS so shells | ||
| 114 | // can tell them apart; Alt prefixes either. | ||
| 115 | const base: []const u8 = if (ev.mods.ctrl) "\x08" else "\x7f"; | ||
| 116 | return altable(ev.mods, base, buf); | ||
| 117 | }, | ||
| 118 | .escape => return altable(ev.mods, "\x1b", buf), | ||
| 119 | .up => return cursorKey(ev.mods, 'A', buf), | ||
| 120 | .down => return cursorKey(ev.mods, 'B', buf), | ||
| 121 | .right => return cursorKey(ev.mods, 'C', buf), | ||
| 122 | .left => return cursorKey(ev.mods, 'D', buf), | ||
| 123 | .home => return cursorKey(ev.mods, 'H', buf), | ||
| 124 | .end => return cursorKey(ev.mods, 'F', buf), | ||
| 125 | .insert => return tildeKey(ev.mods, 2, buf), | ||
| 126 | .delete => return tildeKey(ev.mods, 3, buf), | ||
| 127 | .page_up => return tildeKey(ev.mods, 5, buf), | ||
| 128 | .page_down => return tildeKey(ev.mods, 6, buf), | ||
| 129 | // F1-F4 are SS3 letters unmodified (their VT220 lineage) and CSI | ||
| 130 | // 1;mP..S modified — the xterm scheme. | ||
| 131 | .f1 => return ss3Key(ev.mods, 'P', buf), | ||
| 132 | .f2 => return ss3Key(ev.mods, 'Q', buf), | ||
| 133 | .f3 => return ss3Key(ev.mods, 'R', buf), | ||
| 134 | .f4 => return ss3Key(ev.mods, 'S', buf), | ||
| 135 | .f5 => return tildeKey(ev.mods, 15, buf), | ||
| 136 | .f6 => return tildeKey(ev.mods, 17, buf), | ||
| 137 | .f7 => return tildeKey(ev.mods, 18, buf), | ||
| 138 | .f8 => return tildeKey(ev.mods, 19, buf), | ||
| 139 | .f9 => return tildeKey(ev.mods, 20, buf), | ||
| 140 | .f10 => return tildeKey(ev.mods, 21, buf), | ||
| 141 | .f11 => return tildeKey(ev.mods, 23, buf), | ||
| 142 | .f12 => return tildeKey(ev.mods, 24, buf), | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | fn copy(seq: []const u8, buf: []u8) []const u8 { | ||
| 147 | @memcpy(buf[0..seq.len], seq); | ||
| 148 | return buf[0..seq.len]; | ||
| 149 | } | ||
| 150 | |||
| 151 | /// A single-byte key that Alt turns into ESC + byte. Ctrl is either baked | ||
| 152 | /// into `base` by the caller or meaningless for the key. | ||
| 153 | fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 { | ||
| 154 | var n: usize = 0; | ||
| 155 | if (mods.alt) { | ||
| 156 | buf[0] = 0x1b; | ||
| 157 | n = 1; | ||
| 158 | } | ||
| 159 | @memcpy(buf[n .. n + base.len], base); | ||
| 160 | return buf[0 .. n + base.len]; | ||
| 161 | } | ||
| 162 | |||
| 163 | /// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when | ||
| 164 | /// modified. | ||
| 165 | fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 { | ||
| 166 | if (!mods.any()) { | ||
| 167 | buf[0] = 0x1b; | ||
| 168 | buf[1] = '['; | ||
| 169 | buf[2] = final; | ||
| 170 | return buf[0..3]; | ||
| 171 | } | ||
| 172 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 173 | } | ||
| 174 | |||
| 175 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. | ||
| 176 | fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 { | ||
| 177 | if (!mods.any()) | ||
| 178 | return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable; | ||
| 179 | return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable; | ||
| 180 | } | ||
| 181 | |||
| 182 | /// SS3 form for F1-F4: ESC O <final> unmodified, CSI 1 ; <mods> <final> | ||
| 183 | /// modified (SS3 has nowhere to put a parameter). | ||
| 184 | fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { | ||
| 185 | if (!mods.any()) { | ||
| 186 | buf[0] = 0x1b; | ||
| 187 | buf[1] = 'O'; | ||
| 188 | buf[2] = final; | ||
| 189 | return buf[0..3]; | ||
| 190 | } | ||
| 191 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 192 | } | ||
| 193 | |||
| 194 | pub const paste_begin = "\x1b[200~"; | ||
| 195 | pub const paste_end = "\x1b[201~"; | ||
| 196 | |||
| 197 | /// Bracketed paste: the wrap and nothing else. The bytes between the | ||
| 198 | /// brackets are the paste verbatim — filtering (say, of a pasted ESC) is | ||
| 199 | /// a policy question this module does not answer in v1. | ||
| 200 | pub fn pasteInto( | ||
| 201 | list: *std.ArrayList(u8), | ||
| 202 | alloc: std.mem.Allocator, | ||
| 203 | bytes: []const u8, | ||
| 204 | ) !void { | ||
| 205 | try list.appendSlice(alloc, paste_begin); | ||
| 206 | try list.appendSlice(alloc, bytes); | ||
| 207 | try list.appendSlice(alloc, paste_end); | ||
| 208 | } | ||
| 209 | |||
| 210 | // --------------------------------------------------------------------------- | ||
| 211 | |||
| 212 | test "keymap: the table" { | ||
| 213 | const cases = [_]struct { ev: Event, want: []const u8 }{ | ||
| 214 | // Printable, plain and modified. | ||
| 215 | .{ .ev = .{ .key = .char, .cp = 'a' }, .want = "a" }, | ||
| 216 | .{ .ev = .{ .key = .char, .cp = 'Z' }, .want = "Z" }, | ||
| 217 | .{ .ev = .{ .key = .char, .cp = 'a', .mods = .{ .ctrl = true } }, .want = "\x01" }, | ||
| 218 | .{ .ev = .{ .key = .char, .cp = 'z', .mods = .{ .ctrl = true } }, .want = "\x1a" }, | ||
| 219 | .{ .ev = .{ .key = .char, .cp = 'C', .mods = .{ .ctrl = true } }, .want = "\x03" }, | ||
| 220 | .{ .ev = .{ .key = .char, .cp = ' ', .mods = .{ .ctrl = true } }, .want = "\x00" }, | ||
| 221 | .{ .ev = .{ .key = .char, .cp = '[', .mods = .{ .ctrl = true } }, .want = "\x1b" }, | ||
| 222 | .{ .ev = .{ .key = .char, .cp = '_', .mods = .{ .ctrl = true } }, .want = "\x1f" }, | ||
| 223 | .{ .ev = .{ .key = .char, .cp = 'x', .mods = .{ .alt = true } }, .want = "\x1bx" }, | ||
| 224 | .{ .ev = .{ .key = .char, .cp = 'b', .mods = .{ .alt = true, .ctrl = true } }, .want = "\x1b\x02" }, | ||
| 225 | // UTF-8 out, straight through. | ||
| 226 | .{ .ev = .{ .key = .char, .cp = 0x6f22 }, .want = "\xe6\xbc\xa2" }, // 漢 | ||
| 227 | .{ .ev = .{ .key = .char, .cp = 0xe9 }, .want = "\xc3\xa9" }, // é | ||
| 228 | // The single-byte keys. | ||
| 229 | .{ .ev = .{ .key = .enter }, .want = "\r" }, | ||
| 230 | .{ .ev = .{ .key = .enter, .mods = .{ .alt = true } }, .want = "\x1b\r" }, | ||
| 231 | .{ .ev = .{ .key = .tab }, .want = "\t" }, | ||
| 232 | .{ .ev = .{ .key = .tab, .mods = .{ .shift = true } }, .want = "\x1b[Z" }, | ||
| 233 | .{ .ev = .{ .key = .backspace }, .want = "\x7f" }, | ||
| 234 | .{ .ev = .{ .key = .backspace, .mods = .{ .ctrl = true } }, .want = "\x08" }, | ||
| 235 | .{ .ev = .{ .key = .backspace, .mods = .{ .alt = true } }, .want = "\x1b\x7f" }, | ||
| 236 | .{ .ev = .{ .key = .escape }, .want = "\x1b" }, | ||
| 237 | // Cursor and nav keys, short forms. | ||
| 238 | .{ .ev = .{ .key = .up }, .want = "\x1b[A" }, | ||
| 239 | .{ .ev = .{ .key = .down }, .want = "\x1b[B" }, | ||
| 240 | .{ .ev = .{ .key = .right }, .want = "\x1b[C" }, | ||
| 241 | .{ .ev = .{ .key = .left }, .want = "\x1b[D" }, | ||
| 242 | .{ .ev = .{ .key = .home }, .want = "\x1b[H" }, | ||
| 243 | .{ .ev = .{ .key = .end }, .want = "\x1b[F" }, | ||
| 244 | .{ .ev = .{ .key = .insert }, .want = "\x1b[2~" }, | ||
| 245 | .{ .ev = .{ .key = .delete }, .want = "\x1b[3~" }, | ||
| 246 | .{ .ev = .{ .key = .page_up }, .want = "\x1b[5~" }, | ||
| 247 | .{ .ev = .{ .key = .page_down }, .want = "\x1b[6~" }, | ||
| 248 | // Function keys. | ||
| 249 | .{ .ev = .{ .key = .f1 }, .want = "\x1bOP" }, | ||
| 250 | .{ .ev = .{ .key = .f4 }, .want = "\x1bOS" }, | ||
| 251 | .{ .ev = .{ .key = .f5 }, .want = "\x1b[15~" }, | ||
| 252 | .{ .ev = .{ .key = .f6 }, .want = "\x1b[17~" }, | ||
| 253 | .{ .ev = .{ .key = .f10 }, .want = "\x1b[21~" }, | ||
| 254 | .{ .ev = .{ .key = .f11 }, .want = "\x1b[23~" }, | ||
| 255 | .{ .ev = .{ .key = .f12 }, .want = "\x1b[24~" }, | ||
| 256 | // Modified CSI spot checks (the matrix test below is exhaustive). | ||
| 257 | .{ .ev = .{ .key = .up, .mods = .{ .ctrl = true } }, .want = "\x1b[1;5A" }, | ||
| 258 | .{ .ev = .{ .key = .up, .mods = .{ .shift = true, .alt = true } }, .want = "\x1b[1;4A" }, | ||
| 259 | .{ .ev = .{ .key = .delete, .mods = .{ .shift = true } }, .want = "\x1b[3;2~" }, | ||
| 260 | .{ .ev = .{ .key = .f5, .mods = .{ .ctrl = true } }, .want = "\x1b[15;5~" }, | ||
| 261 | .{ .ev = .{ .key = .f1, .mods = .{ .shift = true } }, .want = "\x1b[1;2P" }, | ||
| 262 | // Nothing to send. | ||
| 263 | .{ .ev = .{ .key = .char, .cp = 0 }, .want = "" }, | ||
| 264 | }; | ||
| 265 | var buf: [max_seq_len]u8 = undefined; | ||
| 266 | for (cases) |case| { | ||
| 267 | const got = encode(case.ev, &buf); | ||
| 268 | std.testing.expectEqualSlices(u8, case.want, got) catch |err| { | ||
| 269 | std.debug.print("keymap: {any} -> {any}, want {any}\n", .{ case.ev, got, case.want }); | ||
| 270 | return err; | ||
| 271 | }; | ||
| 272 | } | ||
| 273 | } | ||
| 274 | |||
| 275 | test "keymap: the modifier matrix over every CSI-carrying key" { | ||
| 276 | // param = 1 + shift + 2*alt + 4*ctrl; every modified form carries it, | ||
| 277 | // every unmodified form omits it. Exhaustive over the seven non-empty | ||
| 278 | // modifier combinations x every key with a CSI form. | ||
| 279 | const csi_keys = [_]struct { key: Key, mid: []const u8, tail: []const u8 }{ | ||
| 280 | .{ .key = .up, .mid = "1;", .tail = "A" }, | ||
| 281 | .{ .key = .down, .mid = "1;", .tail = "B" }, | ||
| 282 | .{ .key = .right, .mid = "1;", .tail = "C" }, | ||
| 283 | .{ .key = .left, .mid = "1;", .tail = "D" }, | ||
| 284 | .{ .key = .home, .mid = "1;", .tail = "H" }, | ||
| 285 | .{ .key = .end, .mid = "1;", .tail = "F" }, | ||
| 286 | .{ .key = .insert, .mid = "2;", .tail = "~" }, | ||
| 287 | .{ .key = .delete, .mid = "3;", .tail = "~" }, | ||
| 288 | .{ .key = .page_up, .mid = "5;", .tail = "~" }, | ||
| 289 | .{ .key = .page_down, .mid = "6;", .tail = "~" }, | ||
| 290 | .{ .key = .f1, .mid = "1;", .tail = "P" }, | ||
| 291 | .{ .key = .f2, .mid = "1;", .tail = "Q" }, | ||
| 292 | .{ .key = .f3, .mid = "1;", .tail = "R" }, | ||
| 293 | .{ .key = .f4, .mid = "1;", .tail = "S" }, | ||
| 294 | .{ .key = .f5, .mid = "15;", .tail = "~" }, | ||
| 295 | .{ .key = .f6, .mid = "17;", .tail = "~" }, | ||
| 296 | .{ .key = .f7, .mid = "18;", .tail = "~" }, | ||
| 297 | .{ .key = .f8, .mid = "19;", .tail = "~" }, | ||
| 298 | .{ .key = .f9, .mid = "20;", .tail = "~" }, | ||
| 299 | .{ .key = .f10, .mid = "21;", .tail = "~" }, | ||
| 300 | .{ .key = .f11, .mid = "23;", .tail = "~" }, | ||
| 301 | .{ .key = .f12, .mid = "24;", .tail = "~" }, | ||
| 302 | }; | ||
| 303 | var buf: [max_seq_len]u8 = undefined; | ||
| 304 | var want_buf: [max_seq_len]u8 = undefined; | ||
| 305 | for (csi_keys) |k| { | ||
| 306 | var m: u8 = 2; | ||
| 307 | while (m <= 8) : (m += 1) { | ||
| 308 | const mods = Mods{ | ||
| 309 | .shift = (m - 1) & 1 != 0, | ||
| 310 | .alt = (m - 1) & 2 != 0, | ||
| 311 | .ctrl = (m - 1) & 4 != 0, | ||
| 312 | }; | ||
| 313 | try std.testing.expectEqual(m, mods.param()); | ||
| 314 | const want = try std.fmt.bufPrint(&want_buf, "\x1b[{s}{d}{s}", .{ k.mid, m, k.tail }); | ||
| 315 | const got = encode(.{ .key = k.key, .mods = mods }, &buf); | ||
| 316 | try std.testing.expectEqualSlices(u8, want, got); | ||
| 317 | } | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | test "keymap: bracketed paste wraps" { | ||
| 322 | const alloc = std.testing.allocator; | ||
| 323 | var out: std.ArrayList(u8) = .empty; | ||
| 324 | defer out.deinit(alloc); | ||
| 325 | try pasteInto(&out, alloc, "two\nlines"); | ||
| 326 | try std.testing.expectEqualStrings("\x1b[200~two\nlines\x1b[201~", out.items); | ||
| 327 | } | ||