abe12351
refactor: interact.zig owns what happens at an open session
a73x 2026-08-20 01:06
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -217,20 +217,18 @@ const mod_table = [_]ModSpec{ | |||
| 217 | // under it. | 217 | // under it. |
| 218 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, | 218 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, |
| 219 | // ---- layer 2 ---- | 219 | // ---- layer 2 ---- |
| 220 | // Everything that happens between a user at a terminal and one already | ||
| 221 | // open session: the chord table, the wheel splitter, prediction, the | ||
| 222 | // side channels, terminal ownership. It sits BELOW client because it | ||
| 223 | // must be drivable by anything holding a transport — the CLI client | ||
| 224 | // today, a wall tile from the wall's phase-3 convergence on — and it | ||
| 225 | // names no transport type for exactly that reason (see its header). | ||
| 226 | .{ .name = "interact", .path = "src/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "paint" } }, | ||
| 220 | // quic and quic_server both: the listener it owns, and the vocabulary | 227 | // quic and quic_server both: the listener it owns, and the vocabulary |
| 221 | // it names directly (the key it loads, the idle default it falls back | 228 | // it names directly (the key it loads, the idle default it falls back |
| 222 | // to). xdg is for endpoint_req's lazy bind — the default key path, | 229 | // to). xdg is for endpoint_req's lazy bind — the default key path, |
| 223 | // resolved by the daemon itself when nobody handed it a --key. | 230 | // resolved by the daemon itself when nobody handed it a --key. |
| 224 | .{ .name = "server", .path = "src/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "cmd", "shellint", "sockpath", "quic", "quic_server", "xdg" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true }, | 231 | .{ .name = "server", .path = "src/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "cmd", "shellint", "sockpath", "quic", "quic_server", "xdg" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true }, |
| 225 | // The client is the only thing that predicts: the overlay is a local | ||
| 226 | // display decision and never becomes state anybody else can see. It | ||
| 227 | // also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so | ||
| 228 | // this adds no cycle and teaches the proxy nothing. | ||
| 229 | // `wall` is the spelling grammar AND the state file: a grid-claiming | ||
| 230 | // attach records its own tile (the wall is attach history), and the | ||
| 231 | // chord switches that re-dial from inside client.attach have to record | ||
| 232 | // theirs too, so the writer cannot live up in mux_main. | ||
| 233 | .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "quic_client", "quic", "predict", "handoff", "proxy", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | ||
| 234 | // The agent-facing client. It speaks frames and owns no terminal, which | 232 | // The agent-facing client. It speaks frames and owns no terminal, which |
| 235 | // is the whole point — it attaches at 0x0 and never claims the grid. | 233 | // is the whole point — it attaches at 0x0 and never claims the grid. |
| 236 | // The transport modules are the CLI client's, minus everything that | 234 | // The transport modules are the CLI client's, minus everything that |
| @@ -240,20 +238,34 @@ const mod_table = [_]ModSpec{ | |||
| 240 | .{ .name = "muxa", .path = "src/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true }, | 238 | .{ .name = "muxa", .path = "src/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true }, |
| 241 | .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, | 239 | .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, |
| 242 | // ---- layer 3 ---- | 240 | // ---- layer 3 ---- |
| 241 | // Dialling, and what a chord means. The client is the only thing that | ||
| 242 | // predicts — the overlay is a local display decision and never becomes | ||
| 243 | // state anybody else can see — but the predicting itself is interact's | ||
| 244 | // now, along with the rest of the terminal-facing machinery; what stays | ||
| 245 | // here is Target/Transport, the attach loop and the session's meanings. | ||
| 246 | // It also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so | ||
| 247 | // this adds no cycle and teaches the proxy nothing. | ||
| 248 | // `wall` is the spelling grammar AND the state file: a grid-claiming | ||
| 249 | // attach records its own tile (the wall is attach history), and the | ||
| 250 | // chord switches that re-dial from inside client.attach have to record | ||
| 251 | // theirs too, so the writer cannot live up in mux_main. | ||
| 252 | .{ .name = "client", .path = "src/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "interact", "quic_client", "quic", "predict", "handoff", "proxy", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | ||
| 243 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route | 253 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route |
| 244 | // table, WS endpoint naming. Assets are injected (the exe root | 254 | // table, WS endpoint naming. Assets are injected (the exe root |
| 245 | // @embedFiles them), so its tests build no artifacts. | 255 | // @embedFiles them), so its tests build no artifacts. |
| 246 | // sockpath is the sun_path bound a `--sock` tile is refused against — | 256 | // sockpath is the sun_path bound a `--sock` tile is refused against — |
| 247 | // the check argv used to make before the Hub owned resolution. | 257 | // the check argv used to make before the Hub owned resolution. |
| 248 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true }, | 258 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true }, |
| 249 | // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of | 259 | // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of |
| 250 | // which can be ZOOMED — promoted to the terminal's size and typed | 260 | // which can be ZOOMED — promoted to the terminal's size and typed |
| 251 | // through. Same layer as webhub for the same reason — both sit on | 261 | // through. Same layer as webhub for the same reason — both sit on |
| 252 | // client's Transport and wall's grammar; neither may import the other, | 262 | // client's Transport and wall's grammar; neither may import the other, |
| 253 | // which is why each carries its own spelling→Target resolution. | 263 | // which is why each carries its own spelling→Target resolution. |
| 254 | // `predict` is here because a zoomed tile speculates like any other | 264 | // `predict` is here because a zoomed tile speculates like any other |
| 255 | // typed-at session; the overlay machinery itself is client's, shared. | 265 | // typed-at session; the overlay machinery itself is interact's, shared — |
| 256 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true }, | 266 | // and phase 3 promotes the tile into that core rather than growing a |
| 267 | // second copy of it. | ||
| 268 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true }, | ||
| 257 | // The daemon entrypoint loads the key and constructs the listener, so | 269 | // The daemon entrypoint loads the key and constructs the listener, so |
| 258 | // it needs quic/quic_server directly rather than through the server. | 270 | // it needs quic/quic_server directly rather than through the server. |
| 259 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | 271 | // `muxd endpoint` prints the announce line handoff spells; sockpath is |
| @@ -261,20 +273,20 @@ const mod_table = [_]ModSpec{ | |||
| 261 | // keygen round-trip test needs a directory to generate into, which the | 273 | // keygen round-trip test needs a directory to generate into, which the |
| 262 | // daemon itself never touches. | 274 | // daemon itself never touches. |
| 263 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 275 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 264 | // ---- layer 4 ---- | 276 | // ---- layer 5 ---- |
| 265 | // wall owns the spelling grammar and the state file, so argv is parsed | 277 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 266 | // by the SAME rules the page's POST /tiles and the restored file are — | 278 | // by the SAME rules the page's POST /tiles and the restored file are — |
| 267 | // one grammar, not three. Resolution itself now lives in the Hub, so | 279 | // one grammar, not three. Resolution itself now lives in the Hub, so |
| 268 | // handoff/protocol left with it; sockpath stays for the one startup | 280 | // handoff/protocol left with it; sockpath stays for the one startup |
| 269 | // message that names the sun_path bound. | 281 | // message that names the sun_path bound. |
| 270 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, | 282 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, |
| 271 | // sockpath is the sun_path bound only; the client binds no socket itself. | 283 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 272 | // protocol is the session-name validator alone (validSessionName): a bad | 284 | // protocol is the session-name validator alone (validSessionName): a bad |
| 273 | // --session has to be a usage error here, at parse, not bytes some | 285 | // --session has to be a usage error here, at parse, not bytes some |
| 274 | // daemon downstream has to notice and refuse. Layer 4 since `mux wall` | 286 | // daemon downstream has to notice and refuse. Layer 4 since `mux wall` |
| 275 | // pulled in wallview (layer 3); wall rides along for the no-arg wall | 287 | // pulled in wallview (layer 3); wall rides along for the no-arg wall |
| 276 | // (the state file the browser hub builds). | 288 | // (the state file the browser hub builds). |
| 277 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true }, | 289 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true }, |
| 278 | }; | 290 | }; |
| 279 | 291 | ||
| 280 | /// Comptime row lookup. Every hand-written module name in this file goes | 292 | /// Comptime row lookup. Every hand-written module name in this file goes |
| @@ -414,12 +426,12 @@ fn shellGate(b: *std.Build, step: *std.Build.Step) void { | |||
| 414 | /// escape pins. mux and exe are executable roots but carry the argument | 426 | /// escape pins. mux and exe are executable roots but carry the argument |
| 415 | /// parsers — a test that is never built is not a test (decisions.md). | 427 | /// parsers — a test that is never built is not a test (decisions.md). |
| 416 | const test_order = [_][]const u8{ | 428 | const test_order = [_][]const u8{ |
| 417 | "script", "protocol", "client_core", "engine", "pty", "delta", | 429 | "script", "protocol", "client_core", "interact", "engine", "pty", "delta", |
| 418 | "cmd", "wall", "shellint", "replica", "keymap", "webhub", | 430 | "cmd", "wall", "shellint", "replica", "keymap", "webhub", "wallview", |
| 419 | "wallview", "sockpath", "muxa", "server", "client", "proxy", | 431 | "sockpath", "muxa", "server", "client", "proxy", "mux", "quic", |
| 420 | "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", | 432 | "quic_server", "exe", "testtmp", "quic_client", "predict", "rawmode", "delaypipe", |
| 421 | "predict", "rawmode", "delaypipe", "xdg", "spawn", "handoff", | 433 | "xdg", "spawn", "handoff", "paint", "render", "ptyclient", "webhub_main", |
| 422 | "paint", "render", "ptyclient", "webhub_main", "wsclient", | 434 | "wsclient", |
| 423 | }; | 435 | }; |
| 424 | 436 | ||
| 425 | comptime { | 437 | comptime { |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,11 +10,18 @@ | |||
| 10 | //! previous session in the daemon's list, `Ctrl-\ w` shows them all as a | 10 | //! previous session in the daemon's list, `Ctrl-\ w` shows them all as a |
| 11 | //! read-only wall (a `mux wall` child on this terminal) and returns here | 11 | //! read-only wall (a `mux wall` child on this terminal) and returns here |
| 12 | //! when it leaves. While dialling or reconnecting there is no session to | 12 | //! when it leaves. While dialling or reconnecting there is no session to |
| 13 | //! command and a bare Ctrl-\ still aborts. | 13 | //! command and a bare Ctrl-\ still aborts. The chord table itself, and |
| 14 | //! every other thing that happens between a user and an already-open | ||
| 15 | //! session, live in interact.zig; what a chord MEANS is still decided | ||
| 16 | //! here. | ||
| 14 | const std = @import("std"); | 17 | const std = @import("std"); |
| 15 | const Engine = @import("engine").Engine; | 18 | const Engine = @import("engine").Engine; |
| 16 | const Replica = @import("replica").Replica; | 19 | const Replica = @import("replica").Replica; |
| 17 | const client_core = @import("client_core"); | 20 | const client_core = @import("client_core"); |
| 21 | // The session-interaction core: chords, the wheel, prediction, the side | ||
| 22 | // channels, terminal ownership. Everything this file keeps is about | ||
| 23 | // BUILDING a link and deciding what a chord means. | ||
| 24 | const interact = @import("interact"); | ||
| 18 | const proto = @import("protocol"); | 25 | const proto = @import("protocol"); |
| 19 | const TmpDir = @import("testtmp").TmpDir; | 26 | const TmpDir = @import("testtmp").TmpDir; |
| 20 | const quic_client = @import("quic_client"); | 27 | const quic_client = @import("quic_client"); |
| @@ -30,201 +37,6 @@ const paint_mod = @import("paint"); | |||
| 30 | // For ignoreSigpipe only, which proxy.zig owns. | 37 | // For ignoreSigpipe only, which proxy.zig owns. |
| 31 | const proxy = @import("proxy"); | 38 | const proxy = @import("proxy"); |
| 32 | 39 | ||
| 33 | // Ctrl-\. In a live session it is the command prefix (see PrefixFilter); | ||
| 34 | // while dialling or reconnecting there is no session to command, so a bare | ||
| 35 | // press still means "give up". | ||
| 36 | const detach_key: u8 = 0x1c; | ||
| 37 | |||
| 38 | /// The attached client's keybinding layer: Ctrl-\ selects a command rather | ||
| 39 | /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new | ||
| 40 | /// session, `n` and `p` step to the next and previous one, `l` skips to the | ||
| 41 | /// last one visited, `w` shows the wall of this daemon's sessions; any other | ||
| 42 | /// key is dropped along with the prefix. Dropping is not a loss — a literal | ||
| 43 | /// 0x1c never reached the pty before this layer existed either. | ||
| 44 | /// | ||
| 45 | /// Only what is typed at an established session passes through here: the | ||
| 46 | /// keystrokes `attach` carried across the opening handshake go straight out | ||
| 47 | /// as input (see `carry`), because there was no session to command yet when | ||
| 48 | /// they were typed. | ||
| 49 | /// | ||
| 50 | /// Public because the CLI wall's ZOOMED tile needs the same layer over the | ||
| 51 | /// same keys (wallview.zig): a zoomed tile is a session on this terminal, | ||
| 52 | /// and a second copy of this table would be a twin that drifts. What each | ||
| 53 | /// action MEANS is the caller's — `.detach` leaves a client's session and | ||
| 54 | /// unzooms the wall's tile, `.next_session` steps the client's session ring | ||
| 55 | /// and moves the wall's zoom — but which byte spells it is one table. | ||
| 56 | pub const PrefixFilter = struct { | ||
| 57 | /// Callers switch on it, so a new variant is additive. | ||
| 58 | pub const Action = enum { none, detach, new_session, next_session, prev_session, last_session, wall }; | ||
| 59 | |||
| 60 | pub const Out = struct { forward: []const u8, action: Action }; | ||
| 61 | |||
| 62 | /// A prefix arrived at the end of a read and its command key has not | ||
| 63 | /// been typed yet. Held across reads so a chord split by a read | ||
| 64 | /// boundary is still one chord. | ||
| 65 | pending: bool = false, | ||
| 66 | |||
| 67 | /// Filters one raw stdin chunk in place — the layer only ever removes | ||
| 68 | /// bytes, so the survivors compact leftwards over the same buffer. | ||
| 69 | /// An action ends the chunk: whatever was typed behind it is dropped. | ||
| 70 | /// For `.detach` that is trivially right (the loop returns), and for a | ||
| 71 | /// switch it is the only honest answer — those bytes were typed at the | ||
| 72 | /// OLD session, so forwarding them to the new one would put them in the | ||
| 73 | /// wrong shell, and sending them back to the old one races the detach | ||
| 74 | /// that is already on its way. | ||
| 75 | /// | ||
| 76 | /// `.wall` is the variant where the loss is visible: the user comes | ||
| 77 | /// BACK to this same session, so bytes dropped behind `Ctrl-\ w` are | ||
| 78 | /// bytes they will look for and not find. The rule stays as it is | ||
| 79 | /// anyway — those bytes were typed before the wall took the terminal, | ||
| 80 | /// and delivering them after it gives them back would replay them into | ||
| 81 | /// a shell whose prompt has moved on. | ||
| 82 | pub fn feed(self: *PrefixFilter, buf: []u8) Out { | ||
| 83 | var kept: usize = 0; | ||
| 84 | for (buf) |b| { | ||
| 85 | if (self.pending) { | ||
| 86 | self.pending = false; | ||
| 87 | switch (b) { | ||
| 88 | 'd', detach_key => return .{ .forward = buf[0..kept], .action = .detach }, | ||
| 89 | 'c' => return .{ .forward = buf[0..kept], .action = .new_session }, | ||
| 90 | 'n' => return .{ .forward = buf[0..kept], .action = .next_session }, | ||
| 91 | 'p' => return .{ .forward = buf[0..kept], .action = .prev_session }, | ||
| 92 | // Not the same as the `else` arm `l` used to fall | ||
| 93 | // through to, and the difference is real: an unknown | ||
| 94 | // command key drops itself and lets the REST of the | ||
| 95 | // read through, while a chord ends the chunk and drops | ||
| 96 | // whatever was typed behind it. `l` is a chord now, so | ||
| 97 | // it behaves like `n` and `p` and not like `z`. The | ||
| 98 | // client has no meaning for the action and swallows it | ||
| 99 | // (see the session loop), but the bytes behind it are | ||
| 100 | // gone either way — which is the rule every chord | ||
| 101 | // already keeps, for the reason argued above. | ||
| 102 | 'l' => return .{ .forward = buf[0..kept], .action = .last_session }, | ||
| 103 | 'w' => return .{ .forward = buf[0..kept], .action = .wall }, | ||
| 104 | else => {}, | ||
| 105 | } | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | if (b == detach_key) { | ||
| 109 | self.pending = true; | ||
| 110 | continue; | ||
| 111 | } | ||
| 112 | buf[kept] = b; | ||
| 113 | kept += 1; | ||
| 114 | } | ||
| 115 | return .{ .forward = buf[0..kept], .action = .none }; | ||
| 116 | } | ||
| 117 | }; | ||
| 118 | |||
| 119 | /// One read of the session's stdin. The mouse filter's scratch is sized | ||
| 120 | /// from it, so they are one constant. | ||
| 121 | const stdin_chunk = 16 * 1024; | ||
| 122 | |||
| 123 | /// How many rows one wheel notch moves the scrollback view. Three is what | ||
| 124 | /// every terminal's own scrollback does per notch, so it is what a user's | ||
| 125 | /// hand already expects; a page per notch (the granularity the scroll KEYS | ||
| 126 | /// use) overshoots so far that finding a line means hunting for it. | ||
| 127 | const wheel_rows: u32 = 3; | ||
| 128 | |||
| 129 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones | ||
| 130 | /// into scrollback movement. Only runs while no application in the session | ||
| 131 | /// has asked for the mouse — when one has, its bytes are its own and this | ||
| 132 | /// filter is bypassed entirely (and reset, so a report split across that | ||
| 133 | /// transition cannot be half-eaten). | ||
| 134 | /// | ||
| 135 | /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is | ||
| 136 | /// the only form the client ever asks its terminal for (`client_mouse_setup`). | ||
| 137 | /// | ||
| 138 | /// What it deliberately does NOT do is hold a bare `ESC` or `ESC [` across | ||
| 139 | /// a read boundary waiting to see whether a mouse report follows. That | ||
| 140 | /// would be the complete parse, and it would cost the one thing a | ||
| 141 | /// multiplexer must never delay: a bare Escape typed in vim would sit here | ||
| 142 | /// until the next keystroke. So the hold starts at `ESC [ <` — three bytes | ||
| 143 | /// no keyboard produces — and a report split inside those three bytes | ||
| 144 | /// leaks through as input. A terminal writes a report with one write, and | ||
| 145 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical | ||
| 146 | /// one; a delayed Escape would be an every-session one. | ||
| 147 | const MouseFilter = struct { | ||
| 148 | /// Longest report worth holding: `ESC [ <` plus three parameters. A | ||
| 149 | /// candidate that outgrows it was never a mouse report. | ||
| 150 | const max_held = 24; | ||
| 151 | |||
| 152 | const Out = struct { | ||
| 153 | /// The bytes that were not mouse reports, in order. | ||
| 154 | forward: []const u8, | ||
| 155 | /// Net wheel notches: positive is up, into history. | ||
| 156 | wheel: i32, | ||
| 157 | }; | ||
| 158 | |||
| 159 | held: [max_held]u8 = undefined, | ||
| 160 | len: usize = 0, | ||
| 161 | |||
| 162 | fn reset(self: *MouseFilter) void { | ||
| 163 | self.len = 0; | ||
| 164 | } | ||
| 165 | |||
| 166 | /// Filter one raw stdin chunk into `out`, which must have room for | ||
| 167 | /// `in.len + max_held` — a candidate held from the previous read is | ||
| 168 | /// handed back ahead of this chunk's bytes when it turns out not to | ||
| 169 | /// have been a report after all. | ||
| 170 | fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { | ||
| 171 | var kept: usize = 0; | ||
| 172 | var wheel: i32 = 0; | ||
| 173 | for (in) |b| { | ||
| 174 | if (self.len > 0) { | ||
| 175 | if (b == 'M' or b == 'm') { | ||
| 176 | self.held[self.len] = b; | ||
| 177 | wheel += wheelNotches(self.held[0 .. self.len + 1]); | ||
| 178 | self.len = 0; | ||
| 179 | continue; | ||
| 180 | } | ||
| 181 | if ((std.ascii.isDigit(b) or b == ';') and self.len + 1 < max_held) { | ||
| 182 | self.held[self.len] = b; | ||
| 183 | self.len += 1; | ||
| 184 | continue; | ||
| 185 | } | ||
| 186 | // Not a report: give the held bytes back in the order they | ||
| 187 | // were typed, then let `b` take its chances below — it may | ||
| 188 | // be the ESC of the next candidate. | ||
| 189 | @memcpy(out[kept..][0..self.len], self.held[0..self.len]); | ||
| 190 | kept += self.len; | ||
| 191 | self.len = 0; | ||
| 192 | } | ||
| 193 | out[kept] = b; | ||
| 194 | kept += 1; | ||
| 195 | if (kept >= 3 and std.mem.eql(u8, out[kept - 3 .. kept], "\x1b[<")) { | ||
| 196 | kept -= 3; | ||
| 197 | @memcpy(self.held[0..3], "\x1b[<"); | ||
| 198 | self.len = 3; | ||
| 199 | } | ||
| 200 | } | ||
| 201 | return .{ .forward = out[0..kept], .wheel = wheel }; | ||
| 202 | } | ||
| 203 | |||
| 204 | /// The wheel movement one complete SGR report means, or 0 for anything | ||
| 205 | /// else — a click, a drag, a release, a horizontal wheel, a button this | ||
| 206 | /// terminal invented. Discarding those is the point: with no | ||
| 207 | /// application asking for the mouse there is nobody to send them to, | ||
| 208 | /// and forwarding them would type `[<0;40;12M` into the user's shell. | ||
| 209 | fn wheelNotches(seq: []const u8) i32 { | ||
| 210 | // Wheel events are presses; a release cannot be one. | ||
| 211 | if (seq[seq.len - 1] != 'M') return 0; | ||
| 212 | var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); | ||
| 213 | const button = std.fmt.parseInt(u16, it.first(), 10) catch return 0; | ||
| 214 | // Bit 6 marks the wheel buttons, bit 5 marks motion (a drag with | ||
| 215 | // the wheel held is not a scroll). The low two bits pick which of | ||
| 216 | // the four: 0/1 are vertical, 2/3 horizontal and unhandled. The | ||
| 217 | // modifier bits (shift/meta/ctrl, 4/8/16) are ignored on purpose — | ||
| 218 | // Ctrl+wheel is a zoom nobody here implements, so it scrolls. | ||
| 219 | if (button & 0x40 == 0 or button & 0x20 != 0) return 0; | ||
| 220 | return switch (button & 0x03) { | ||
| 221 | 0 => 1, | ||
| 222 | 1 => -1, | ||
| 223 | else => 0, | ||
| 224 | }; | ||
| 225 | } | ||
| 226 | }; | ||
| 227 | |||
| 228 | /// A session name held by value. The names a switch travels on are decoded | 40 | /// A session name held by value. The names a switch travels on are decoded |
| 229 | /// out of a frame payload that is freed before the re-dial, so they cannot | 41 | /// out of a frame payload that is freed before the re-dial, so they cannot |
| 230 | /// be carried as slices. | 42 | /// be carried as slices. |
| @@ -400,12 +212,6 @@ const PendingSwitch = struct { | |||
| 400 | } | 212 | } |
| 401 | }; | 213 | }; |
| 402 | 214 | ||
| 403 | var winch_flag = std.atomic.Value(bool).init(false); | ||
| 404 | |||
| 405 | fn onWinch(_: c_int) callconv(.c) void { | ||
| 406 | winch_flag.store(true, .release); | ||
| 407 | } | ||
| 408 | |||
| 409 | /// The client's transport: a read fd and a write fd. For a unix socket they | 215 | /// The client's transport: a read fd and a write fd. For a unix socket they |
| 410 | /// are one and the same; under `--via` they are the child command's stdout | 216 | /// are one and the same; under `--via` they are the child command's stdout |
| 411 | /// and stdin. Nothing below the transport setup knows which it is — that | 217 | /// and stdin. Nothing below the transport setup knows which it is — that |
| @@ -960,7 +766,7 @@ fn waitReady( | |||
| 960 | const n = std.posix.read(abort_fd, &buf) catch 0; | 766 | const n = std.posix.read(abort_fd, &buf) catch 0; |
| 961 | if (n == 0) watch_stdin = false; | 767 | if (n == 0) watch_stdin = false; |
| 962 | if (n > 0) { | 768 | if (n > 0) { |
| 963 | if (std.mem.indexOfScalar(u8, buf[0..n], detach_key) != null) return error.UserAbort; | 769 | if (std.mem.indexOfScalar(u8, buf[0..n], interact.detach_key) != null) return error.UserAbort; |
| 964 | // Not the abort key. Whether these bytes are kept or dropped | 770 | // Not the abort key. Whether these bytes are kept or dropped |
| 965 | // is the caller's policy, not this function's: on a first | 771 | // is the caller's policy, not this function's: on a first |
| 966 | // attach they are the user's first keystrokes and are owed to | 772 | // attach they are the user's first keystrokes and are owed to |
| @@ -1044,7 +850,7 @@ fn readAnnounceAbortable( | |||
| 1044 | const got = std.posix.read(abort_fd, &in) catch 0; | 850 | const got = std.posix.read(abort_fd, &in) catch 0; |
| 1045 | if (got == 0) watch_stdin = false; | 851 | if (got == 0) watch_stdin = false; |
| 1046 | if (got > 0) { | 852 | if (got > 0) { |
| 1047 | if (std.mem.indexOfScalar(u8, in[0..got], detach_key) != null) | 853 | if (std.mem.indexOfScalar(u8, in[0..got], interact.detach_key) != null) |
| 1048 | return error.UserAbort; | 854 | return error.UserAbort; |
| 1049 | // waitReady's contract, and for waitReady's reason: on a | 855 | // waitReady's contract, and for waitReady's reason: on a |
| 1050 | // first attach these are the user's first keystrokes and are | 856 | // first attach these are the user's first keystrokes and are |
| @@ -1652,7 +1458,7 @@ fn session( | |||
| 1652 | const stdout_fd = std.posix.STDOUT_FILENO; | 1458 | const stdout_fd = std.posix.STDOUT_FILENO; |
| 1653 | const is_tty = std.posix.isatty(stdin_fd); | 1459 | const is_tty = std.posix.isatty(stdin_fd); |
| 1654 | 1460 | ||
| 1655 | var size = ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; | 1461 | var size = interact.ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; |
| 1656 | 1462 | ||
| 1657 | var eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); | 1463 | var eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); |
| 1658 | defer eng.deinit(); | 1464 | defer eng.deinit(); |
| @@ -1681,7 +1487,7 @@ fn session( | |||
| 1681 | // put back and on the normal screen, like every other message. | 1487 | // put back and on the normal screen, like every other message. |
| 1682 | var overlay = predict.Overlay.init(alloc, size.cols, size.rows); | 1488 | var overlay = predict.Overlay.init(alloc, size.cols, size.rows); |
| 1683 | defer overlay.deinit(); | 1489 | defer overlay.deinit(); |
| 1684 | defer dumpPredictStats(overlay.counters); | 1490 | defer interact.dumpPredictStats(overlay.counters); |
| 1685 | 1491 | ||
| 1686 | // Raw mode when we own a terminal. The alternate screen is NOT entered | 1492 | // Raw mode when we own a terminal. The alternate screen is NOT entered |
| 1687 | // here — see the first-frame gate in the loop below. | 1493 | // here — see the first-frame gate in the loop below. |
| @@ -1699,7 +1505,7 @@ fn session( | |||
| 1699 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | 1505 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); |
| 1700 | 1506 | ||
| 1701 | var sa: std.posix.Sigaction = .{ | 1507 | var sa: std.posix.Sigaction = .{ |
| 1702 | .handler = .{ .handler = onWinch }, | 1508 | .handler = .{ .handler = interact.onWinch }, |
| 1703 | .mask = std.posix.sigemptyset(), | 1509 | .mask = std.posix.sigemptyset(), |
| 1704 | .flags = 0, | 1510 | .flags = 0, |
| 1705 | }; | 1511 | }; |
| @@ -1708,7 +1514,7 @@ fn session( | |||
| 1708 | defer { | 1514 | defer { |
| 1709 | // Only undo what was actually done: leaving the alternate screen we | 1515 | // Only undo what was actually done: leaving the alternate screen we |
| 1710 | // never entered would wipe the user's own scrollback. | 1516 | // never entered would wipe the user's own scrollback. |
| 1711 | if (alt_screen) proto.writeAllFd(stdout_fd, terminal_teardown) catch {}; | 1517 | if (alt_screen) proto.writeAllFd(stdout_fd, interact.terminal_teardown) catch {}; |
| 1712 | if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | 1518 | if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; |
| 1713 | } | 1519 | } |
| 1714 | 1520 | ||
| @@ -1750,8 +1556,8 @@ fn session( | |||
| 1750 | // Mouse reports arrive in the same reads as keystrokes; this splits | 1556 | // Mouse reports arrive in the same reads as keystrokes; this splits |
| 1751 | // them back out, across read boundaries. Sized to hold one chunk plus | 1557 | // them back out, across read boundaries. Sized to hold one chunk plus |
| 1752 | // whatever a previous read left mid-report — see MouseFilter.feed. | 1558 | // whatever a previous read left mid-report — see MouseFilter.feed. |
| 1753 | var mouse: MouseFilter = .{}; | 1559 | var mouse: interact.MouseFilter = .{}; |
| 1754 | var mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined; | 1560 | var mouse_buf: [interact.stdin_chunk + interact.MouseFilter.max_held]u8 = undefined; |
| 1755 | // Set by any site that finds the transport dead; serviced at the top of | 1561 | // Set by any site that finds the transport dead; serviced at the top of |
| 1756 | // the loop so the bookkeeping around a reconnect lives in one place. | 1562 | // the loop so the bookkeeping around a reconnect lives in one place. |
| 1757 | var needs_reconnect = false; | 1563 | var needs_reconnect = false; |
| @@ -1763,7 +1569,7 @@ fn session( | |||
| 1763 | // [reconnecting] banner goes away with everything else now stale. | 1569 | // [reconnecting] banner goes away with everything else now stale. |
| 1764 | var repaint_after_resync = false; | 1570 | var repaint_after_resync = false; |
| 1765 | // Chord state lives across reads, so it outlives one buffer. | 1571 | // Chord state lives across reads, so it outlives one buffer. |
| 1766 | var prefix: PrefixFilter = .{}; | 1572 | var prefix: interact.PrefixFilter = .{}; |
| 1767 | // Whether this run is still a switch's ARRIVAL, which is what makes a | 1573 | // Whether this run is still a switch's ARRIVAL, which is what makes a |
| 1768 | // pre-state refusal recoverable. Mutable because the property expires: | 1574 | // pre-state refusal recoverable. Mutable because the property expires: |
| 1769 | // see the reconnect below. | 1575 | // see the reconnect below. |
| @@ -1773,7 +1579,7 @@ fn session( | |||
| 1773 | // a reply nobody asked for cannot move a user off their session — and | 1579 | // a reply nobody asked for cannot move a user off their session — and |
| 1774 | // the intent is what says which name the answer names. | 1580 | // the intent is what says which name the answer names. |
| 1775 | var pending_switch: PendingSwitch = .{}; | 1581 | var pending_switch: PendingSwitch = .{}; |
| 1776 | var buf: [stdin_chunk]u8 = undefined; | 1582 | var buf: [interact.stdin_chunk]u8 = undefined; |
| 1777 | while (true) { | 1583 | while (true) { |
| 1778 | if (needs_reconnect) { | 1584 | if (needs_reconnect) { |
| 1779 | needs_reconnect = false; | 1585 | needs_reconnect = false; |
| @@ -1856,8 +1662,8 @@ fn session( | |||
| 1856 | pending_switch.clear(); | 1662 | pending_switch.clear(); |
| 1857 | continue; | 1663 | continue; |
| 1858 | } | 1664 | } |
| 1859 | if (winch_flag.swap(false, .acq_rel)) { | 1665 | if (interact.winch_flag.swap(false, .acq_rel)) { |
| 1860 | if (ttySize(stdout_fd)) |new_size| { | 1666 | if (interact.ttySize(stdout_fd)) |new_size| { |
| 1861 | if (new_size.cols != size.cols or new_size.rows != size.rows) { | 1667 | if (new_size.cols != size.cols or new_size.rows != size.rows) { |
| 1862 | // Only the local clip size changes here; the replica | 1668 | // Only the local clip size changes here; the replica |
| 1863 | // follows the daemon, which answers the resize frame | 1669 | // follows the daemon, which answers the resize frame |
| @@ -2006,7 +1812,7 @@ fn session( | |||
| 2006 | // deeper is not the part they will notice. Same exposure as | 1812 | // deeper is not the part they will notice. Same exposure as |
| 2007 | // every other line of the teardown, not a new one. | 1813 | // every other line of the teardown, not a new one. |
| 2008 | if (is_tty and !alt_screen) { | 1814 | if (is_tty and !alt_screen) { |
| 2009 | try proto.writeAllFd(stdout_fd, terminal_setup); | 1815 | try proto.writeAllFd(stdout_fd, interact.terminal_setup); |
| 2010 | alt_screen = true; | 1816 | alt_screen = true; |
| 2011 | } | 1817 | } |
| 2012 | switch (frame.type) { | 1818 | switch (frame.type) { |
| @@ -2067,7 +1873,7 @@ fn session( | |||
| 2067 | } | 1873 | } |
| 2068 | // Judged against the replica the frame has just been fed | 1874 | // Judged against the replica the frame has just been fed |
| 2069 | // into, which is the only authority there is. | 1875 | // into, which is the only authority there is. |
| 2070 | const verdict = reconcileOverlay( | 1876 | const verdict = interact.reconcileOverlay( |
| 2071 | alloc, | 1877 | alloc, |
| 2072 | &overlay, | 1878 | &overlay, |
| 2073 | rep.eng, | 1879 | rep.eng, |
| @@ -2098,7 +1904,7 @@ fn session( | |||
| 2098 | // Last, and after either paint: the rows the daemon | 1904 | // Last, and after either paint: the rows the daemon |
| 2099 | // just sent have overwritten anything drawn on them, | 1905 | // just sent have overwritten anything drawn on them, |
| 2100 | // including predictions that are still outstanding. | 1906 | // including predictions that are still outstanding. |
| 2101 | paintOverlay(alloc, &overlay, rep.eng.cursorPos(), size, stdout_fd); | 1907 | interact.paintOverlay(alloc, &overlay, rep.eng.cursorPos(), size, stdout_fd); |
| 2102 | } | 1908 | } |
| 2103 | }, | 1909 | }, |
| 2104 | .pty_mode => { | 1910 | .pty_mode => { |
| @@ -2128,22 +1934,22 @@ fn session( | |||
| 2128 | // restores the host after a new connection. | 1934 | // restores the host after a new connection. |
| 2129 | // Occurrence effects take the separate arm below | 1935 | // Occurrence effects take the separate arm below |
| 2130 | // and are never covered by this repeat policy. | 1936 | // and are never covered by this repeat policy. |
| 2131 | try writeSideChannel( | 1937 | try interact.writeSideChannel( |
| 2132 | alloc, | 1938 | alloc, |
| 2133 | stdout_fd, | 1939 | stdout_fd, |
| 2134 | alt_screen, | 1940 | alt_screen, |
| 2135 | client_core.State, | 1941 | client_core.State, |
| 2136 | state, | 1942 | state, |
| 2137 | appendTermState, | 1943 | interact.appendTermState, |
| 2138 | ); | 1944 | ); |
| 2139 | }, | 1945 | }, |
| 2140 | .effect => |effect| try writeSideChannel( | 1946 | .effect => |effect| try interact.writeSideChannel( |
| 2141 | alloc, | 1947 | alloc, |
| 2142 | stdout_fd, | 1948 | stdout_fd, |
| 2143 | alt_screen, | 1949 | alt_screen, |
| 2144 | client_core.Effect, | 1950 | client_core.Effect, |
| 2145 | effect, | 1951 | effect, |
| 2146 | appendHostEffect, | 1952 | interact.appendHostEffect, |
| 2147 | ), | 1953 | ), |
| 2148 | .reply => {}, | 1954 | .reply => {}, |
| 2149 | } | 1955 | } |
| @@ -2156,13 +1962,13 @@ fn session( | |||
| 2156 | // no-op with no counter or stack behind it. Note the | 1962 | // no-op with no counter or stack behind it. Note the |
| 2157 | // daemon never sends an empty one, so a repeat can | 1963 | // daemon never sends an empty one, so a repeat can |
| 2158 | // never clear a title the user is looking at. | 1964 | // never clear a title the user is looking at. |
| 2159 | try writeSideChannel( | 1965 | try interact.writeSideChannel( |
| 2160 | alloc, | 1966 | alloc, |
| 2161 | stdout_fd, | 1967 | stdout_fd, |
| 2162 | alt_screen, | 1968 | alt_screen, |
| 2163 | []const u8, | 1969 | []const u8, |
| 2164 | frame.payload, | 1970 | frame.payload, |
| 2165 | appendTermTitle, | 1971 | interact.appendTermTitle, |
| 2166 | ); | 1972 | ); |
| 2167 | }, | 1973 | }, |
| 2168 | .exit_status => { | 1974 | .exit_status => { |
| @@ -2337,7 +2143,7 @@ fn session( | |||
| 2337 | // before the session took the alt screen still owns its | 2143 | // before the session took the alt screen still owns its |
| 2338 | // wheel, and moving that view is what a notch there means. | 2144 | // wheel, and moving that view is what a notch there means. |
| 2339 | if (wheel != 0 and scroll_rows == 0 and rep.eng.onAltScreen()) { | 2145 | if (wheel != 0 and scroll_rows == 0 and rep.eng.onAltScreen()) { |
| 2340 | sendAltScroll(transport, wheel, rep.eng.cursorKeys()) catch { | 2146 | interact.sendAltScroll(transport, wheel, rep.eng.cursorKeys()) catch { |
| 2341 | needs_reconnect = true; | 2147 | needs_reconnect = true; |
| 2342 | continue; | 2148 | continue; |
| 2343 | }; | 2149 | }; |
| @@ -2352,7 +2158,7 @@ fn session( | |||
| 2352 | // once: a chunk can hold a notch and a keystroke, and | 2158 | // once: a chunk can hold a notch and a keystroke, and |
| 2353 | // dropping either would be a scroll the user made and did | 2159 | // dropping either would be a scroll the user made and did |
| 2354 | // not get. The keys move a screenful, the wheel a few rows. | 2160 | // not get. The keys move a screenful, the wheel a few rows. |
| 2355 | var by: i64 = @as(i64, wheel) * wheel_rows; | 2161 | var by: i64 = @as(i64, wheel) * interact.wheel_rows; |
| 2356 | if (key_up) by += size.rows; | 2162 | if (key_up) by += size.rows; |
| 2357 | if (key_dn) by -= size.rows; | 2163 | if (key_dn) by -= size.rows; |
| 2358 | // Whether this read began at the live view, remembered | 2164 | // Whether this read began at the live view, remembered |
| @@ -2381,7 +2187,7 @@ fn session( | |||
| 2381 | // so a prediction painted at it would land in the | 2187 | // so a prediction painted at it would land in the |
| 2382 | // middle of history. | 2188 | // middle of history. |
| 2383 | overlay.setScrollMode(true); | 2189 | overlay.setScrollMode(true); |
| 2384 | requestScrollPage(transport, &rep, scroll_rows, size) catch { | 2190 | interact.requestScrollPage(transport, &rep, scroll_rows, size) catch { |
| 2385 | needs_reconnect = true; | 2191 | needs_reconnect = true; |
| 2386 | continue; | 2192 | continue; |
| 2387 | }; | 2193 | }; |
| @@ -2410,7 +2216,7 @@ fn session( | |||
| 2410 | // Speculate before sending, so the glyph is on screen | 2216 | // Speculate before sending, so the glyph is on screen |
| 2411 | // while the keystroke is still in flight. The bytes that | 2217 | // while the keystroke is still in flight. The bytes that |
| 2412 | // go out are unchanged either way. | 2218 | // go out are unchanged either way. |
| 2413 | offerKeystroke(alloc, &overlay, rep.eng, keys, size, stdout_fd); | 2219 | interact.offerKeystroke(alloc, &overlay, rep.eng, keys, size, stdout_fd); |
| 2414 | transport.writeFrame(.input, keys) catch { | 2220 | transport.writeFrame(.input, keys) catch { |
| 2415 | // These keystrokes are lost with the transport, by | 2221 | // These keystrokes are lost with the transport, by |
| 2416 | // the same policy that drops what is typed while | 2222 | // the same policy that drops what is typed while |
| @@ -2424,507 +2230,6 @@ fn session( | |||
| 2424 | } | 2230 | } |
| 2425 | } | 2231 | } |
| 2426 | 2232 | ||
| 2427 | fn ttySize(fd: std.posix.fd_t) ?proto.Size { | ||
| 2428 | if (!std.posix.isatty(fd)) return null; | ||
| 2429 | var ws: std.posix.winsize = undefined; | ||
| 2430 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | ||
| 2431 | // A pty can report 0x0 (e.g. `script` with piped stdin); a zero-sized | ||
| 2432 | // grid is invalid for the engine. Treat it as "unknown". | ||
| 2433 | if (ws.col < 2 or ws.row < 2) return null; | ||
| 2434 | return .{ .cols = ws.col, .rows = ws.row }; | ||
| 2435 | } | ||
| 2436 | |||
| 2437 | // ---- side channels ----------------------------------------------------- | ||
| 2438 | // | ||
| 2439 | // Everything below turns a typed semantic value into bytes for the host | ||
| 2440 | // terminal. Untrusted wire validation belongs to client_core; these adapters | ||
| 2441 | // only perform the native platform operation selected by that shared core. | ||
| 2442 | |||
| 2443 | /// Everything the client does TO the host terminal on the way in, in the | ||
| 2444 | /// order it does it: push the title, enter the alternate screen, hide the | ||
| 2445 | /// cursor, disable autowrap. Named rather than inline because it is one | ||
| 2446 | /// half of a pair — `terminal_teardown` undoes each of these, and the pair | ||
| 2447 | /// is pinned together in one test so neither half can drift alone. | ||
| 2448 | /// | ||
| 2449 | /// Written exactly once per process, under the same `alt_screen` gate that | ||
| 2450 | /// admits the teardown; the argument for that gate is at the call site. | ||
| 2451 | /// | ||
| 2452 | /// The mouse enables are last and they are the client's OWN: with no mouse | ||
| 2453 | /// reporting on, a host terminal answers the wheel by synthesising arrow | ||
| 2454 | /// keys on the alternate screen (DEC 1007, "alternate scroll"), which land | ||
| 2455 | /// in the session as input and move the shell's history instead of the | ||
| 2456 | /// view. Asking for real wheel events is what makes the wheel scrollable at | ||
| 2457 | /// all, and it turns 1007's synthesis off as a side effect. The session's | ||
| 2458 | /// own modes arrive moments later in the first `term_modes` and level-set | ||
| 2459 | /// these; this is what the wheel does until they do. | ||
| 2460 | const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup; | ||
| 2461 | |||
| 2462 | /// The mouse modes the client asks its own terminal for when no application | ||
| 2463 | /// in the session wants them: button presses (1000) reported in SGR (1006). | ||
| 2464 | /// 1000 rather than 1002/1003 because the wheel is all we consume — motion | ||
| 2465 | /// and drag reports would be bytes read and thrown away thousands of times | ||
| 2466 | /// a session. | ||
| 2467 | /// | ||
| 2468 | /// The list, not the escape, is the fact: `client_mouse_setup` writes it at | ||
| 2469 | /// startup and `appendMouseModes` level-sets the same modes on every sample, | ||
| 2470 | /// so a mode spelled in one place and not the other would be one the client | ||
| 2471 | /// turns on and then immediately turns off. | ||
| 2472 | const client_mouse_capture = [_]u16{ 1000, 1006 }; | ||
| 2473 | |||
| 2474 | const client_mouse_setup = blk: { | ||
| 2475 | var s: []const u8 = ""; | ||
| 2476 | for (client_mouse_capture) |dec| s = s ++ std.fmt.comptimePrint("\x1b[?{d}h", .{dec}); | ||
| 2477 | break :blk s; | ||
| 2478 | }; | ||
| 2479 | |||
| 2480 | /// Whether `dec` is one of the modes the client asks for on its own behalf. | ||
| 2481 | fn inClientCapture(comptime dec: u16) bool { | ||
| 2482 | for (client_mouse_capture) |c| { | ||
| 2483 | if (c == dec) return true; | ||
| 2484 | } | ||
| 2485 | return false; | ||
| 2486 | } | ||
| 2487 | |||
| 2488 | /// Everything the client must undo on its way out, in one literal. mux | ||
| 2489 | /// turns these on; leaving any of them set hands the user a terminal that | ||
| 2490 | /// behaves oddly long after mux exited, with nothing on screen to explain | ||
| 2491 | /// it. `?2004l` leads because it is the one a session asked for rather | ||
| 2492 | /// than one the client needed for itself. | ||
| 2493 | /// | ||
| 2494 | /// Note what the `?2004l` assumes: it restores 2004 to the terminal's | ||
| 2495 | /// power-on default rather than to whatever the outer program had, because | ||
| 2496 | /// mux never asked the host what it had. That is correct rather than merely | ||
| 2497 | /// tolerable, and by observation rather than by hope — a zsh running under | ||
| 2498 | /// a pty writes `?2004h`, `?2004l`, `?2004h`, `?2004l`: it arms bracketed | ||
| 2499 | /// paste when zle starts reading and DISARMS it before running each | ||
| 2500 | /// command. readline does the same. So for the whole time mux runs as a | ||
| 2501 | /// child of the shell that launched it, host 2004 is already off, and off | ||
| 2502 | /// is exactly what we put back. A host that armed 2004 and then ran a child | ||
| 2503 | /// without disarming would be restored wrongly — no shell in use here does. | ||
| 2504 | /// | ||
| 2505 | /// The title pop (`23;0t`) is the one entry here that restores the user's | ||
| 2506 | /// OWN value rather than a power-on default — the terminal kept it on its | ||
| 2507 | /// stack, because mux cannot read a title back to restore it by hand. | ||
| 2508 | /// | ||
| 2509 | /// It sits SECOND TO LAST, and that placement is load-bearing even though | ||
| 2510 | /// the title stack and the alternate screen have nothing to do with each | ||
| 2511 | /// other. `?1049l` must remain the final bytes a tty client writes: the | ||
| 2512 | /// e2e doctored control for the pty capture (test/e2e.sh, tp1) appends | ||
| 2513 | /// bytes after the capture's trailing alt-screen exit, and `render` replays | ||
| 2514 | /// only up to the LAST one — so a teardown that stops ending there turns | ||
| 2515 | /// that control into a no-op that can never fail. Measured, not guessed: | ||
| 2516 | /// appending the pop after `?1049l` is what made that check fire. | ||
| 2517 | /// | ||
| 2518 | /// It is here because the question was ANSWERED, not assumed: the operator | ||
| 2519 | /// ran the push/set/pop probe in a bare Alacritty window on 2026-08-15 and | ||
| 2520 | /// the title returned (commit 217183c, and the design note it edits). A | ||
| 2521 | /// terminal without the stack ignores both halves, which costs a title bar | ||
| 2522 | /// left showing what the session set — the tmux behaviour, and the | ||
| 2523 | /// fallback this would otherwise have shipped as. | ||
| 2524 | const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; | ||
| 2525 | |||
| 2526 | /// Every mouse mode this client can ever have turned on, off. Built from | ||
| 2527 | /// the wire table rather than typed out, because the set it has to undo is | ||
| 2528 | /// exactly the set the daemon can ask it to mirror — a mode added there and | ||
| 2529 | /// forgotten here is a terminal left reporting clicks into the user's shell | ||
| 2530 | /// as escape sequences, long after mux exited. | ||
| 2531 | const mouse_teardown = blk: { | ||
| 2532 | var s: []const u8 = ""; | ||
| 2533 | for (proto.mouse_modes) |m| s = s ++ std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}); | ||
| 2534 | break :blk s; | ||
| 2535 | }; | ||
| 2536 | |||
| 2537 | /// Render validated terminal state as the DECSET/DECRST writes it implies. | ||
| 2538 | fn appendTermState( | ||
| 2539 | out: *std.ArrayList(u8), | ||
| 2540 | alloc: std.mem.Allocator, | ||
| 2541 | state: client_core.State, | ||
| 2542 | ) !void { | ||
| 2543 | switch (state) { | ||
| 2544 | .terminal_modes => |modes| { | ||
| 2545 | try out.appendSlice( | ||
| 2546 | alloc, | ||
| 2547 | if (modes.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l", | ||
| 2548 | ); | ||
| 2549 | try appendMouseModes(out, alloc, modes); | ||
| 2550 | }, | ||
| 2551 | } | ||
| 2552 | } | ||
| 2553 | |||
| 2554 | /// Who the wheel belongs to, written as the modes this terminal is asked | ||
| 2555 | /// for. An application that asked for the mouse gets EXACTLY the modes it | ||
| 2556 | /// asked for and every mouse byte verbatim (see `MouseFilter`'s call site); | ||
| 2557 | /// otherwise the client keeps its own capture set and spends the wheel on | ||
| 2558 | /// scrollback. | ||
| 2559 | /// | ||
| 2560 | /// A full level-set of all eight modes every time, not a diff: `term_modes` | ||
| 2561 | /// is sampled state that repeats on every attach and reconnect, and the | ||
| 2562 | /// terminal on the other end may be one this process never configured (a | ||
| 2563 | /// reconnect, a `--via` that reconnected under us). Level-setting is | ||
| 2564 | /// idempotent, so the repeats cost bytes and nothing else. | ||
| 2565 | fn appendMouseModes( | ||
| 2566 | out: *std.ArrayList(u8), | ||
| 2567 | alloc: std.mem.Allocator, | ||
| 2568 | modes: proto.TermModes, | ||
| 2569 | ) !void { | ||
| 2570 | const app = modes.appMouse(); | ||
| 2571 | inline for (proto.mouse_modes) |m| { | ||
| 2572 | // The client's own capture set comes from the one list that | ||
| 2573 | // `client_mouse_setup` is built from, so the startup write and this | ||
| 2574 | // level-set cannot disagree about what "ours" is. | ||
| 2575 | const on = if (app) @field(modes, m.field) else comptime inClientCapture(m.dec); | ||
| 2576 | try out.appendSlice(alloc, if (on) | ||
| 2577 | comptime std.fmt.comptimePrint("\x1b[?{d}h", .{m.dec}) | ||
| 2578 | else | ||
| 2579 | comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec})); | ||
| 2580 | } | ||
| 2581 | } | ||
| 2582 | |||
| 2583 | /// Render one host effect onto the bytes destined for the terminal. | ||
| 2584 | /// | ||
| 2585 | /// The target and alphabet are re-checked here rather than trusted from the | ||
| 2586 | /// effect. `ClipboardSet` is a plain struct, so Zig cannot make the | ||
| 2587 | /// validating decoder its only constructor — and one caller already builds | ||
| 2588 | /// an unvalidated one: `wasm_core.zig` default-initialises its borrowed | ||
| 2589 | /// clipboard slot to `.{ .target = 0, .base64 = &.{} }`, which | ||
| 2590 | /// `validClipboard` refuses. A linear scan over at most 64 KiB is free next | ||
| 2591 | /// to the write it guards, and the alternative is `ESC]52;<NUL>;BEL` on a | ||
| 2592 | /// real tty. | ||
| 2593 | /// | ||
| 2594 | /// Built whole before the first byte is appended, like every other builder | ||
| 2595 | /// here: a rejection must not leave half an escape behind for a caller that | ||
| 2596 | /// reuses one buffer across events. | ||
| 2597 | fn appendHostEffect( | ||
| 2598 | out: *std.ArrayList(u8), | ||
| 2599 | alloc: std.mem.Allocator, | ||
| 2600 | effect: client_core.Effect, | ||
| 2601 | ) !void { | ||
| 2602 | switch (effect) { | ||
| 2603 | .clipboard_set => |clip| { | ||
| 2604 | if (!client_core.validClipboard(clip.target, clip.base64)) return; | ||
| 2605 | try out.appendSlice(alloc, "\x1b]52;"); | ||
| 2606 | try out.append(alloc, clip.target); | ||
| 2607 | try out.append(alloc, ';'); | ||
| 2608 | try out.appendSlice(alloc, clip.base64); | ||
| 2609 | try out.append(alloc, 0x07); | ||
| 2610 | }, | ||
| 2611 | .bell => try out.append(alloc, 0x07), | ||
| 2612 | } | ||
| 2613 | } | ||
| 2614 | |||
| 2615 | /// Render a term_title frame as the OSC 0 write it implies. | ||
| 2616 | /// | ||
| 2617 | /// Refuses any byte below 0x20 or the DEL at 0x7f. Such a byte terminates | ||
| 2618 | /// the OSC early — BEL is the terminator itself, ESC begins the other one — | ||
| 2619 | /// and everything after it lands on the user's screen as text they then | ||
| 2620 | /// have to clear. Same reasoning as the base64 alphabet check on the | ||
| 2621 | /// clipboard path, and the same all-or-nothing shape: nothing is appended | ||
| 2622 | /// until every check has passed. | ||
| 2623 | /// | ||
| 2624 | /// Refuses an empty title too, which is not a parse question but the client | ||
| 2625 | /// half of the daemon's policy (`sampleTermTitle`): `ESC]0;BEL` CLEARS the | ||
| 2626 | /// host terminal's title, and mux will not do that to a title it never set. | ||
| 2627 | /// Checked here as well as there because the peer is not necessarily this | ||
| 2628 | /// version of muxd. | ||
| 2629 | /// | ||
| 2630 | /// OSC 0 rather than OSC 2, so the icon name moves with the title: that is | ||
| 2631 | /// what the session's own applications write (both forms reach the engine | ||
| 2632 | /// as one window-title operation), and mirroring it is the point. | ||
| 2633 | /// | ||
| 2634 | /// Restoring the user's original title on exit is NOT this function's job | ||
| 2635 | /// and is not left undone: the terminal's own title stack carries it, via | ||
| 2636 | /// the `22;0t` that leads the alt-screen entry and the `23;0t` that closes | ||
| 2637 | /// `terminal_teardown`. See that constant for the observation that settled | ||
| 2638 | /// it. Nothing here needs to remember the old title, which is just as well | ||
| 2639 | /// — mux cannot read one back, and the engine cannot help either: ghostty's | ||
| 2640 | /// terminal handler ignores title_push/title_pop outright, so the SESSION's | ||
| 2641 | /// title stack does not exist to be mirrored. | ||
| 2642 | fn appendTermTitle( | ||
| 2643 | out: *std.ArrayList(u8), | ||
| 2644 | alloc: std.mem.Allocator, | ||
| 2645 | payload: []const u8, | ||
| 2646 | ) !void { | ||
| 2647 | if (payload.len == 0 or payload.len > proto.term_title_max) return; | ||
| 2648 | for (payload) |b| if (b < 0x20 or b == 0x7f) return; | ||
| 2649 | try out.appendSlice(alloc, "\x1b]0;"); | ||
| 2650 | try out.appendSlice(alloc, payload); | ||
| 2651 | try out.append(alloc, 0x07); | ||
| 2652 | } | ||
| 2653 | |||
| 2654 | /// Write one side channel's rendering of a frame to the host terminal. | ||
| 2655 | /// | ||
| 2656 | /// Outside the paint's synchronized-update bracket: these are messages TO | ||
| 2657 | /// the terminal, not part of the picture, and a sync bracket around one | ||
| 2658 | /// would hold it until the next frame. Nothing is written when the builder | ||
| 2659 | /// produced nothing — every builder here is all-or-nothing, so an empty | ||
| 2660 | /// buffer is a refusal, and half an escape sequence on a real tty paints | ||
| 2661 | /// garbage the user has to clear. | ||
| 2662 | /// | ||
| 2663 | /// `owns_terminal` is the caller's `alt_screen`, and it gates every channel | ||
| 2664 | /// rather than any one of them. mux writes a side channel only once it has | ||
| 2665 | /// taken the terminal over, because taking it over is also what arms the | ||
| 2666 | /// teardown that puts it back: the title pop, and the `?2004l` for a | ||
| 2667 | /// session that asked for bracketed paste and died without unasking. | ||
| 2668 | /// | ||
| 2669 | /// The hole this closes was the title's to find. `is_tty` is `isatty` of | ||
| 2670 | /// STDIN — it gates raw mode and the alt-screen entry, both of which are | ||
| 2671 | /// about input — while these writes go to STDOUT. With stdin redirected | ||
| 2672 | /// and stdout still a terminal (`echo x | mux`, `mux < /dev/null` typed at | ||
| 2673 | /// a prompt) `alt_screen` never becomes true, so mux would set the user's | ||
| 2674 | /// title and never pop it, and turn bracketed paste on and never turn it | ||
| 2675 | /// off. Every other side channel had the same shape; only the title made | ||
| 2676 | /// it a broken promise, because the title is the one mux justified by | ||
| 2677 | /// saying it could put things back. | ||
| 2678 | /// | ||
| 2679 | /// The cost, accepted deliberately: in that mode the session's title, | ||
| 2680 | /// clipboard and bell go nowhere, even though a terminal is attached to | ||
| 2681 | /// stdout and would have shown them. That matches what mux already does | ||
| 2682 | /// there — no raw mode, no alternate screen, no hidden cursor — and the | ||
| 2683 | /// alternative is a client that changes terminal state it has arranged no | ||
| 2684 | /// way to change back. Gating here rather than at the three call sites so | ||
| 2685 | /// a fourth channel cannot arrive without it. | ||
| 2686 | /// | ||
| 2687 | /// `append` is a DECLARED function type rather than `anytype`, because the | ||
| 2688 | /// declaration is the specification of a side-channel builder: an output | ||
| 2689 | /// buffer, an allocator, one value of the type it renders — and allocation | ||
| 2690 | /// as the only way it may fail. Everything else it refuses, it refuses by | ||
| 2691 | /// writing nothing, which is what makes "empty buffer means refusal" above | ||
| 2692 | /// a rule rather than a hope. `anytype` accepts a builder that fails some | ||
| 2693 | /// other way, and that error propagates out of `session()` and ends the | ||
| 2694 | /// client: not something a stray clipboard byte gets to do. `Value` is | ||
| 2695 | /// comptime for the same reason — it names the contract, and it lets each | ||
| 2696 | /// caller's value coerce to the type its builder actually declares. | ||
| 2697 | fn writeSideChannel( | ||
| 2698 | alloc: std.mem.Allocator, | ||
| 2699 | stdout_fd: std.posix.fd_t, | ||
| 2700 | owns_terminal: bool, | ||
| 2701 | comptime Value: type, | ||
| 2702 | value: Value, | ||
| 2703 | comptime append: fn (*std.ArrayList(u8), std.mem.Allocator, Value) std.mem.Allocator.Error!void, | ||
| 2704 | ) !void { | ||
| 2705 | if (!owns_terminal) return; | ||
| 2706 | var esc: std.ArrayList(u8) = .empty; | ||
| 2707 | defer esc.deinit(alloc); | ||
| 2708 | try append(&esc, alloc, value); | ||
| 2709 | if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items); | ||
| 2710 | } | ||
| 2711 | |||
| 2712 | // ---- prediction -------------------------------------------------------- | ||
| 2713 | // | ||
| 2714 | // The overlay is a display decision and nothing else. It never writes to | ||
| 2715 | // the replica, so the replica keeps meaning exactly "what the daemon said" | ||
| 2716 | // and stays comparable to `muxd dump` at every instant. Everything below | ||
| 2717 | // either reads the replica or paints on top of it. | ||
| 2718 | |||
| 2719 | /// What the replica shows at one cell — the `prev_ch` a prediction is | ||
| 2720 | /// judged against later. | ||
| 2721 | /// | ||
| 2722 | /// Read at the PREDICTED cursor, not the replica's own: mid-burst those are | ||
| 2723 | /// different cells, and reading the wrong one hands reconcile a `prev_ch` | ||
| 2724 | /// that belongs to somebody else's cell, which turns "the frame has not | ||
| 2725 | /// answered yet" into "we were contradicted" and flushes the queue. That is | ||
| 2726 | /// the failure the real-Engine test below exists to catch. | ||
| 2727 | fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { | ||
| 2728 | const plain = replica.dumpPlain(alloc) catch return ' '; | ||
| 2729 | defer alloc.free(plain); | ||
| 2730 | const grid: predict.PlainGrid = .{ .text = plain, .cols = @intCast(replica.term.cols) }; | ||
| 2731 | return grid.cellChar(at.y, at.x) orelse ' '; | ||
| 2732 | } | ||
| 2733 | |||
| 2734 | /// Judge the overlay against the replica as it now stands. Call after the | ||
| 2735 | /// replica has taken the frame, never before: the whole question is what | ||
| 2736 | /// the authoritative state says now. | ||
| 2737 | /// | ||
| 2738 | /// Public, with `paintOverlay` and `offerKeystroke`, for the CLI wall's | ||
| 2739 | /// zoomed tile (wallview.zig): typing at a zoomed tile is typing at a | ||
| 2740 | /// session, and it gets the same speculation the client gives its own. | ||
| 2741 | /// Shared rather than reimplemented — the overlay is the one place the | ||
| 2742 | /// "never enters the replica" rule is kept, and a second implementation is | ||
| 2743 | /// a second place to break it. | ||
| 2744 | pub fn reconcileOverlay( | ||
| 2745 | alloc: std.mem.Allocator, | ||
| 2746 | overlay: *predict.Overlay, | ||
| 2747 | replica: *Engine, | ||
| 2748 | seq: u64, | ||
| 2749 | now_ms: i64, | ||
| 2750 | ) predict.Verdict { | ||
| 2751 | const plain = replica.dumpPlain(alloc) catch return .none; | ||
| 2752 | defer alloc.free(plain); | ||
| 2753 | return overlay.reconcile( | ||
| 2754 | predict.PlainGrid{ .text = plain, .cols = @intCast(replica.term.cols) }, | ||
| 2755 | seq, | ||
| 2756 | now_ms, | ||
| 2757 | ); | ||
| 2758 | } | ||
| 2759 | |||
| 2760 | /// Paint every pending prediction on top of whatever is on screen, and | ||
| 2761 | /// leave the cursor where the typist believes it is. | ||
| 2762 | /// | ||
| 2763 | /// Idempotent and called after every authoritative paint as well as on each | ||
| 2764 | /// keystroke, because a delta repaints whole rows: the row content the | ||
| 2765 | /// daemon sent would otherwise wipe an underlined glyph whose prediction is | ||
| 2766 | /// still outstanding, and the burst would flicker away one frame after it | ||
| 2767 | /// was drawn. | ||
| 2768 | pub fn paintOverlay( | ||
| 2769 | alloc: std.mem.Allocator, | ||
| 2770 | overlay: *predict.Overlay, | ||
| 2771 | base: Engine.CursorPos, | ||
| 2772 | tty: proto.Size, | ||
| 2773 | out_fd: std.posix.fd_t, | ||
| 2774 | ) void { | ||
| 2775 | if (!overlay.confident or overlay.pendingCount() == 0) return; | ||
| 2776 | var paint: std.ArrayList(u8) = .empty; | ||
| 2777 | defer paint.deinit(alloc); | ||
| 2778 | paint.appendSlice(alloc, paint_mod.sync_begin) catch return; | ||
| 2779 | |||
| 2780 | var i: usize = 0; | ||
| 2781 | while (i < overlay.pendingCount()) : (i += 1) { | ||
| 2782 | const cell = overlay.pendingAt(i).cell; | ||
| 2783 | if (cell.row >= tty.rows or cell.col >= tty.cols) continue; | ||
| 2784 | var b: [32]u8 = undefined; | ||
| 2785 | // Underlined, so a prediction is visibly a prediction until the | ||
| 2786 | // daemon's own row content replaces it. | ||
| 2787 | const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{ | ||
| 2788 | cell.row + 1, | ||
| 2789 | cell.col + 1, | ||
| 2790 | cell.ch, | ||
| 2791 | }) catch continue; | ||
| 2792 | paint.appendSlice(alloc, s) catch return; | ||
| 2793 | // Counted here rather than at prediction time, because this is | ||
| 2794 | // where a cell actually reaches the screen — including one queued | ||
| 2795 | // while unconfident that a promotion has since made visible. The | ||
| 2796 | // overlay counts it once however often this redraws it. | ||
| 2797 | overlay.markPainted(i); | ||
| 2798 | } | ||
| 2799 | |||
| 2800 | const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); | ||
| 2801 | const cur = paint_mod.clampCursor(.{ .x = pc.x, .y = pc.y }, tty); | ||
| 2802 | var cbuf: [32]u8 = undefined; | ||
| 2803 | const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{ | ||
| 2804 | cur.y + 1, | ||
| 2805 | cur.x + 1, | ||
| 2806 | }) catch return; | ||
| 2807 | paint.appendSlice(alloc, tail) catch return; | ||
| 2808 | proto.writeAllFd(out_fd, paint.items) catch {}; | ||
| 2809 | } | ||
| 2810 | |||
| 2811 | /// Offer one chunk of typed bytes to the overlay. The chunk goes to the | ||
| 2812 | /// daemon unchanged whatever happens here — prediction never alters what | ||
| 2813 | /// the shell receives, only what the screen shows before it answers. | ||
| 2814 | pub fn offerKeystroke( | ||
| 2815 | alloc: std.mem.Allocator, | ||
| 2816 | overlay: *predict.Overlay, | ||
| 2817 | replica: *Engine, | ||
| 2818 | chunk: []const u8, | ||
| 2819 | tty: proto.Size, | ||
| 2820 | out_fd: std.posix.fd_t, | ||
| 2821 | ) void { | ||
| 2822 | if (chunk.len != 1) { | ||
| 2823 | // An escape sequence, a multi-byte character, or a paste. None is | ||
| 2824 | // one cell's worth of change and M9 speculates about none of them. | ||
| 2825 | // The decision is made here, so the count is recorded here — a | ||
| 2826 | // paste's lead byte is printable, so handing it to predictAt would | ||
| 2827 | // predict the paste's first character instead of refusing it. | ||
| 2828 | overlay.recordSuppressed(); | ||
| 2829 | return; | ||
| 2830 | } | ||
| 2831 | |||
| 2832 | const base = replica.cursorPos(); | ||
| 2833 | const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); | ||
| 2834 | const out = overlay.predictAt(.{ | ||
| 2835 | .cursor = at, | ||
| 2836 | .ch = chunk[0], | ||
| 2837 | .prev_ch = replicaCellChar(alloc, replica, at), | ||
| 2838 | .now_ms = std.time.milliTimestamp(), | ||
| 2839 | }); | ||
| 2840 | switch (out) { | ||
| 2841 | .display => paintOverlay(alloc, overlay, base, tty, out_fd), | ||
| 2842 | // Queued but unearned, or refused outright: either way nothing is | ||
| 2843 | // drawn, which is the entire safety property. | ||
| 2844 | .hidden, .suppressed => {}, | ||
| 2845 | } | ||
| 2846 | } | ||
| 2847 | |||
| 2848 | /// The one machine-readable line `MUX_PREDICT_STATS=1` produces. A pure | ||
| 2849 | /// function so the format the e2e greps for is pinned by a test rather than | ||
| 2850 | /// by whatever the process happened to print. | ||
| 2851 | fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 { | ||
| 2852 | return std.fmt.bufPrint( | ||
| 2853 | buf, | ||
| 2854 | "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++ | ||
| 2855 | " expired={d} abandoned={d} suppressed={d}", | ||
| 2856 | .{ | ||
| 2857 | c.made, c.displayed, c.confirmed, c.contradicted, | ||
| 2858 | c.expired, c.abandoned, c.suppressed, | ||
| 2859 | }, | ||
| 2860 | ); | ||
| 2861 | } | ||
| 2862 | |||
| 2863 | pub const predict_stats_len = 192; | ||
| 2864 | |||
| 2865 | fn dumpPredictStats(c: predict.Counters) void { | ||
| 2866 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; | ||
| 2867 | if (!std.mem.eql(u8, want, "1")) return; | ||
| 2868 | var buf: [predict_stats_len]u8 = undefined; | ||
| 2869 | const line = formatPredictStats(&buf, c) catch return; | ||
| 2870 | std.debug.print("{s}\n", .{line}); | ||
| 2871 | } | ||
| 2872 | |||
| 2873 | /// Turn wheel notches into the arrow keys an alt-screen application reads, | ||
| 2874 | /// `wheel_rows` of them per notch so the wheel moves the same distance | ||
| 2875 | /// whichever screen is up. | ||
| 2876 | /// | ||
| 2877 | /// Sent as input rather than predicted: `offerKeystroke` refuses anything | ||
| 2878 | /// that is not a single byte anyway, and a guess painted at the cursor of a | ||
| 2879 | /// full-screen application is a guess about a layout the client cannot see. | ||
| 2880 | /// | ||
| 2881 | /// Batched, because a spin arrives as one burst and one frame per arrow | ||
| 2882 | /// would put a hundred frames on the wire for one flick of a finger. The | ||
| 2883 | /// loop is what bounds the buffer rather than the burst. | ||
| 2884 | fn sendAltScroll(transport: *Transport, wheel: i32, app_cursor: bool) !void { | ||
| 2885 | const seq = altScrollSeq(wheel, app_cursor); | ||
| 2886 | var buf: [alt_scroll_batch * 3]u8 = undefined; | ||
| 2887 | var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows; | ||
| 2888 | while (left > 0) { | ||
| 2889 | const n = @min(left, alt_scroll_batch); | ||
| 2890 | for (0..n) |i| @memcpy(buf[i * 3 ..][0..3], seq); | ||
| 2891 | try transport.writeFrame(.input, buf[0 .. n * 3]); | ||
| 2892 | left -= n; | ||
| 2893 | } | ||
| 2894 | } | ||
| 2895 | |||
| 2896 | /// Arrows per alternate-scroll frame. Twenty-one notches' worth, which no | ||
| 2897 | /// hand produces in one read; the batching exists to bound the buffer, not | ||
| 2898 | /// to pace anything. | ||
| 2899 | const alt_scroll_batch: u32 = 64; | ||
| 2900 | |||
| 2901 | /// The arrow key one notch means, in the spelling this session reads. | ||
| 2902 | /// | ||
| 2903 | /// DECCKM decides what an arrow key IS, and getting it wrong is silent: | ||
| 2904 | /// `less` puts the cursor keys in APPLICATION mode and reads `ESC O A`, so | ||
| 2905 | /// `ESC [ A` arrives as an escape it ignores and the page does not move. | ||
| 2906 | /// Measured on `less +G` — the normal spelling scrolled nothing at all, and | ||
| 2907 | /// every curses program sets the same mode. | ||
| 2908 | /// | ||
| 2909 | /// Three bytes either way, which `sendAltScroll`'s buffer relies on. | ||
| 2910 | fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 { | ||
| 2911 | if (app_cursor) return if (wheel > 0) "\x1bOA" else "\x1bOB"; | ||
| 2912 | return if (wheel > 0) "\x1b[A" else "\x1b[B"; | ||
| 2913 | } | ||
| 2914 | |||
| 2915 | fn requestScrollPage( | ||
| 2916 | transport: *Transport, | ||
| 2917 | rep: *const Replica, | ||
| 2918 | rows_up: u32, | ||
| 2919 | size: proto.Size, | ||
| 2920 | ) !void { | ||
| 2921 | // The view is `size.rows` rows starting `rows_up` above the live | ||
| 2922 | // viewport top; the row math lives with the replica's history_rows | ||
| 2923 | // (replica.zig). | ||
| 2924 | const start = rep.scrollStart(rows_up); | ||
| 2925 | try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); | ||
| 2926 | } | ||
| 2927 | |||
| 2928 | /// Wait up to `timeout_ms` for the user to give up on a reconnect. Input | 2233 | /// Wait up to `timeout_ms` for the user to give up on a reconnect. Input |
| 2929 | /// typed while disconnected is read and dropped by policy — replaying a | 2234 | /// typed while disconnected is read and dropped by policy — replaying a |
| 2930 | /// burst of stale keystrokes into the shell on resume is worse than losing | 2235 | /// burst of stale keystrokes into the shell on resume is worse than losing |
| @@ -2950,7 +2255,7 @@ fn drainStdinForQuit(stdin_fd: std.posix.fd_t, timeout_ms: u64) bool { | |||
| 2950 | std.Thread.sleep((timeout_ms - elapsed) * std.time.ns_per_ms); | 2255 | std.Thread.sleep((timeout_ms - elapsed) * std.time.ns_per_ms); |
| 2951 | return false; | 2256 | return false; |
| 2952 | } | 2257 | } |
| 2953 | if (std.mem.indexOfScalar(u8, buf[0..n], detach_key) != null) return true; | 2258 | if (std.mem.indexOfScalar(u8, buf[0..n], interact.detach_key) != null) return true; |
| 2954 | // Anything else is dropped, and we keep waiting out the backoff — | 2259 | // Anything else is dropped, and we keep waiting out the backoff — |
| 2955 | // returning early here would collapse the pacing the moment the | 2260 | // returning early here would collapse the pacing the moment the |
| 2956 | // user touched a key. | 2261 | // user touched a key. |
| @@ -3056,309 +2361,6 @@ test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" { | |||
| 3056 | try std.testing.expectEqual(@as(u64, 2000), nextBackoffMs(2000)); | 2361 | try std.testing.expectEqual(@as(u64, 2000), nextBackoffMs(2000)); |
| 3057 | } | 2362 | } |
| 3058 | 2363 | ||
| 3059 | fn devNull() !std.posix.fd_t { | ||
| 3060 | return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); | ||
| 3061 | } | ||
| 3062 | |||
| 3063 | test "prediction: prev_ch is read at the predicted cursor, not the replica's" { | ||
| 3064 | const alloc = std.testing.allocator; | ||
| 3065 | const null_fd = try devNull(); | ||
| 3066 | defer std.posix.close(null_fd); | ||
| 3067 | |||
| 3068 | // A real engine, fed real VT bytes — the one part of the prediction | ||
| 3069 | // contract no test inside predict.zig can reach, because that module | ||
| 3070 | // has never heard of an engine. | ||
| 3071 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3072 | defer replica.deinit(); | ||
| 3073 | // Content with the cursor parked ON a character and a DIFFERENT | ||
| 3074 | // character in the cell after it. That difference is the whole test: | ||
| 3075 | // with nothing pending the replica's cursor and the predicted one agree, | ||
| 3076 | // and mid-burst they do not. | ||
| 3077 | replica.feed("abcXY\x1b[1;4H"); | ||
| 3078 | try std.testing.expectEqual(@as(u16, 3), replica.cursorPos().x); | ||
| 3079 | |||
| 3080 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3081 | defer ov.deinit(); | ||
| 3082 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 3083 | ov.noteSeq(1); | ||
| 3084 | |||
| 3085 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 3086 | offerKeystroke(alloc, &ov, replica, "d", tty, null_fd); | ||
| 3087 | offerKeystroke(alloc, &ov, replica, "e", tty, null_fd); | ||
| 3088 | |||
| 3089 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 3090 | try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch); | ||
| 3091 | // The second keystroke lands in the cell AFTER the first prediction, | ||
| 3092 | // and takes that cell's content as its prev_ch. | ||
| 3093 | try std.testing.expectEqual(@as(u16, 4), ov.pendingAt(1).cell.col); | ||
| 3094 | try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch); | ||
| 3095 | |||
| 3096 | // A frame that changed neither cell: the daemon has not seen the | ||
| 3097 | // keystrokes yet, so it has said nothing about them and both | ||
| 3098 | // predictions must survive it. Read prev_ch from the wrong cell and | ||
| 3099 | // this is where it shows — the second cell holds 'Y', which is neither | ||
| 3100 | // the prediction nor the 'X' a cursor-based read would have recorded, | ||
| 3101 | // so the frame reads as a contradiction and the burst is flushed. | ||
| 3102 | try std.testing.expectEqual( | ||
| 3103 | predict.Verdict.none, | ||
| 3104 | reconcileOverlay(alloc, &ov, replica, 2, 0), | ||
| 3105 | ); | ||
| 3106 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 3107 | try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); | ||
| 3108 | |||
| 3109 | // And when the daemon does answer, they confirm against the real grid. | ||
| 3110 | replica.feed("\x1b[1;4Hde"); | ||
| 3111 | try std.testing.expectEqual( | ||
| 3112 | predict.Verdict.confirmed, | ||
| 3113 | reconcileOverlay(alloc, &ov, replica, 3, 0), | ||
| 3114 | ); | ||
| 3115 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 3116 | try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed); | ||
| 3117 | } | ||
| 3118 | |||
| 3119 | test "prediction: a burst advances the predicted cursor one cell per keystroke" { | ||
| 3120 | const alloc = std.testing.allocator; | ||
| 3121 | const null_fd = try devNull(); | ||
| 3122 | defer std.posix.close(null_fd); | ||
| 3123 | |||
| 3124 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3125 | defer replica.deinit(); | ||
| 3126 | |||
| 3127 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3128 | defer ov.deinit(); | ||
| 3129 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 3130 | |||
| 3131 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 3132 | for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, tty, null_fd); | ||
| 3133 | |||
| 3134 | // The replica's own cursor has not moved — the daemon has answered | ||
| 3135 | // nothing — so every one of these came from the overlay. | ||
| 3136 | try std.testing.expectEqual(@as(u16, 0), replica.cursorPos().x); | ||
| 3137 | try std.testing.expectEqual(@as(usize, 5), ov.pendingCount()); | ||
| 3138 | for ("hello", 0..) |ch, i| { | ||
| 3139 | const p = ov.pendingAt(i); | ||
| 3140 | try std.testing.expectEqual(@as(u16, @intCast(i)), p.cell.col); | ||
| 3141 | try std.testing.expectEqual(ch, p.cell.ch); | ||
| 3142 | try std.testing.expectEqual(@as(u8, ' '), p.prev_ch); | ||
| 3143 | } | ||
| 3144 | try std.testing.expectEqual( | ||
| 3145 | predict.CursorPos{ .x = 5, .y = 0 }, | ||
| 3146 | ov.predictedCursor(.{ .x = 0, .y = 0 }), | ||
| 3147 | ); | ||
| 3148 | } | ||
| 3149 | |||
| 3150 | test "prediction paints underlined, and parks the cursor past what it drew" { | ||
| 3151 | const alloc = std.testing.allocator; | ||
| 3152 | const p = try std.posix.pipe(); | ||
| 3153 | defer std.posix.close(p[0]); | ||
| 3154 | |||
| 3155 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3156 | defer replica.deinit(); | ||
| 3157 | replica.feed("\x1b[1;4H"); // cursor at column 3 (0-based) | ||
| 3158 | |||
| 3159 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3160 | defer ov.deinit(); | ||
| 3161 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 3162 | |||
| 3163 | offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]); | ||
| 3164 | std.posix.close(p[1]); | ||
| 3165 | |||
| 3166 | var out: std.ArrayList(u8) = .empty; | ||
| 3167 | defer out.deinit(alloc); | ||
| 3168 | var rbuf: [4096]u8 = undefined; | ||
| 3169 | while (true) { | ||
| 3170 | const n = try std.posix.read(p[0], &rbuf); | ||
| 3171 | if (n == 0) break; | ||
| 3172 | try out.appendSlice(alloc, rbuf[0..n]); | ||
| 3173 | } | ||
| 3174 | |||
| 3175 | // Drawn at the predicted cell, underlined so a speculation is visibly | ||
| 3176 | // one, and with the SGR closed again so it cannot bleed into the rest. | ||
| 3177 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[1;4H\x1b[4mz\x1b[0m") != null); | ||
| 3178 | // Cursor left one past it: the typist's next character goes there, and | ||
| 3179 | // if it did not the shell's own cursor would appear to lag a column | ||
| 3180 | // behind everything they typed. | ||
| 3181 | try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[1;5H\x1b[?25h\x1b[?2026l")); | ||
| 3182 | // Wrapped in one synchronized update, so no terminal ever shows the | ||
| 3183 | // half-drawn state. | ||
| 3184 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h")); | ||
| 3185 | } | ||
| 3186 | |||
| 3187 | test "prediction: nothing is drawn for a context that has not earned it" { | ||
| 3188 | const alloc = std.testing.allocator; | ||
| 3189 | const p = try std.posix.pipe(); | ||
| 3190 | defer std.posix.close(p[0]); | ||
| 3191 | |||
| 3192 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3193 | defer replica.deinit(); | ||
| 3194 | |||
| 3195 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3196 | defer ov.deinit(); | ||
| 3197 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned | ||
| 3198 | |||
| 3199 | offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]); | ||
| 3200 | std.posix.close(p[1]); | ||
| 3201 | |||
| 3202 | // Queued, so it can be judged and earn the next one its visibility... | ||
| 3203 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 3204 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 3205 | |||
| 3206 | // ...and not one byte went to the terminal. The effect, not the counter: | ||
| 3207 | // this is the assertion that a password prompt depends on. | ||
| 3208 | var rbuf: [64]u8 = undefined; | ||
| 3209 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf)); | ||
| 3210 | } | ||
| 3211 | |||
| 3212 | test "prediction: a chunk that is not one printable byte is never speculated about" { | ||
| 3213 | const alloc = std.testing.allocator; | ||
| 3214 | const null_fd = try devNull(); | ||
| 3215 | defer std.posix.close(null_fd); | ||
| 3216 | |||
| 3217 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3218 | defer replica.deinit(); | ||
| 3219 | |||
| 3220 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3221 | defer ov.deinit(); | ||
| 3222 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 3223 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 3224 | |||
| 3225 | // An arrow key: three bytes, and predicting its lead byte would paint an | ||
| 3226 | // escape character on the screen. | ||
| 3227 | offerKeystroke(alloc, &ov, replica, "\x1b[A", tty, null_fd); | ||
| 3228 | // A multi-byte character, whose display width we do not know. | ||
| 3229 | offerKeystroke(alloc, &ov, replica, "é", tty, null_fd); | ||
| 3230 | // And a lone control byte, which goes down the single-byte path and is | ||
| 3231 | // refused there. | ||
| 3232 | offerKeystroke(alloc, &ov, replica, "\r", tty, null_fd); | ||
| 3233 | |||
| 3234 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 3235 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 3236 | try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed); | ||
| 3237 | |||
| 3238 | // A paste: several printable bytes in one read. This is the shape whose | ||
| 3239 | // lead byte would sail through the printability check, so the length | ||
| 3240 | // guard is the only thing refusing it — and M9 refuses it, because a | ||
| 3241 | // paste can carry newlines and bracketed-paste markers that are not one | ||
| 3242 | // cell's worth of change each. | ||
| 3243 | offerKeystroke(alloc, &ov, replica, "abc", tty, null_fd); | ||
| 3244 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 3245 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 3246 | // Counted like every other refusal. The decision is the client's — the | ||
| 3247 | // overlay never sees the chunk — but the counter is about decisions, | ||
| 3248 | // not about which side of the interface made them. | ||
| 3249 | try std.testing.expectEqual(@as(u64, 4), ov.counters.suppressed); | ||
| 3250 | } | ||
| 3251 | |||
| 3252 | test "prediction: a repaint never reveals what was never shown" { | ||
| 3253 | const alloc = std.testing.allocator; | ||
| 3254 | const p = try std.posix.pipe(); | ||
| 3255 | defer std.posix.close(p[0]); | ||
| 3256 | |||
| 3257 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3258 | defer replica.deinit(); | ||
| 3259 | const null_fd = try devNull(); | ||
| 3260 | defer std.posix.close(null_fd); | ||
| 3261 | |||
| 3262 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3263 | defer ov.deinit(); | ||
| 3264 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident | ||
| 3265 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 3266 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); | ||
| 3267 | offerKeystroke(alloc, &ov, replica, "b", tty, null_fd); | ||
| 3268 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 3269 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 3270 | |||
| 3271 | // Every authoritative paint is followed by re-laying the overlay on top, | ||
| 3272 | // because a delta's row content wipes anything drawn over it. That | ||
| 3273 | // repaint is a second, quieter chance to show a prediction that was | ||
| 3274 | // never displayed in the first place — so it asks the same question the | ||
| 3275 | // keystroke path did, and gets the same answer. | ||
| 3276 | paintOverlay(alloc, &ov, replica.cursorPos(), tty, p[1]); | ||
| 3277 | std.posix.close(p[1]); | ||
| 3278 | |||
| 3279 | var rbuf: [64]u8 = undefined; | ||
| 3280 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf)); | ||
| 3281 | } | ||
| 3282 | |||
| 3283 | test "prediction: a promotion mid-burst counts the cell it makes visible" { | ||
| 3284 | const alloc = std.testing.allocator; | ||
| 3285 | const p = try std.posix.pipe(); | ||
| 3286 | defer std.posix.close(p[0]); | ||
| 3287 | const null_fd = try devNull(); | ||
| 3288 | defer std.posix.close(null_fd); | ||
| 3289 | |||
| 3290 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 3291 | defer replica.deinit(); | ||
| 3292 | |||
| 3293 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 3294 | defer ov.deinit(); | ||
| 3295 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned | ||
| 3296 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 3297 | |||
| 3298 | // One confirm banked, one short of promotion. | ||
| 3299 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); | ||
| 3300 | replica.feed("a"); | ||
| 3301 | try std.testing.expectEqual( | ||
| 3302 | predict.Verdict.confirmed, | ||
| 3303 | reconcileOverlay(alloc, &ov, replica, 1, 0), | ||
| 3304 | ); | ||
| 3305 | |||
| 3306 | // Two more typed while still invisible, and the promoting confirmation | ||
| 3307 | // lands while the second of them is outstanding. | ||
| 3308 | offerKeystroke(alloc, &ov, replica, "b", tty, null_fd); | ||
| 3309 | offerKeystroke(alloc, &ov, replica, "c", tty, null_fd); | ||
| 3310 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 3311 | replica.feed("b"); | ||
| 3312 | try std.testing.expectEqual( | ||
| 3313 | predict.Verdict.confirmed, | ||
| 3314 | reconcileOverlay(alloc, &ov, replica, 2, 0), | ||
| 3315 | ); | ||
| 3316 | try std.testing.expect(ov.confident); | ||
| 3317 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 3318 | // Nothing has been drawn yet: it was queued invisible and no repaint | ||
| 3319 | // has happened since the promotion. | ||
| 3320 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 3321 | |||
| 3322 | // The post-frame re-lay is where it reaches the screen — nobody typed | ||
| 3323 | // anything to make that happen, so counting only at prediction time | ||
| 3324 | // would lose it. | ||
| 3325 | paintOverlay(alloc, &ov, replica.cursorPos(), tty, p[1]); | ||
| 3326 | std.posix.close(p[1]); | ||
| 3327 | try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); | ||
| 3328 | |||
| 3329 | var out: std.ArrayList(u8) = .empty; | ||
| 3330 | defer out.deinit(alloc); | ||
| 3331 | var rbuf: [4096]u8 = undefined; | ||
| 3332 | while (true) { | ||
| 3333 | const n = try std.posix.read(p[0], &rbuf); | ||
| 3334 | if (n == 0) break; | ||
| 3335 | try out.appendSlice(alloc, rbuf[0..n]); | ||
| 3336 | } | ||
| 3337 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[4mc\x1b[0m") != null); | ||
| 3338 | } | ||
| 3339 | |||
| 3340 | test "the predict stats line is one greppable row of counters" { | ||
| 3341 | var buf: [predict_stats_len]u8 = undefined; | ||
| 3342 | const line = try formatPredictStats(&buf, .{ | ||
| 3343 | .made = 5, | ||
| 3344 | .displayed = 4, | ||
| 3345 | .confirmed = 3, | ||
| 3346 | .contradicted = 2, | ||
| 3347 | .expired = 1, | ||
| 3348 | .abandoned = 7, | ||
| 3349 | .suppressed = 6, | ||
| 3350 | }); | ||
| 3351 | // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename | ||
| 3352 | // or a reorder is a broken suite rather than a cosmetic change. The | ||
| 3353 | // units differ between them — see predict.Counters — which is exactly | ||
| 3354 | // why every one of them is on the line rather than a chosen few. | ||
| 3355 | try std.testing.expectEqualStrings( | ||
| 3356 | "predict made=5 displayed=4 confirmed=3 contradicted=2" ++ | ||
| 3357 | " expired=1 abandoned=7 suppressed=6", | ||
| 3358 | line, | ||
| 3359 | ); | ||
| 3360 | } | ||
| 3361 | |||
| 3362 | test "Transport.close is idempotent: the abort path closes what reconnect already closed" { | 2364 | test "Transport.close is idempotent: the abort path closes what reconnect already closed" { |
| 3363 | const alloc = std.testing.allocator; | 2365 | const alloc = std.testing.allocator; |
| 3364 | 2366 | ||
| @@ -3937,556 +2939,6 @@ test "openFailure: a message too long for the buffer clips, and still fails" { | |||
| 3937 | try std.testing.expectEqual(@as(u8, 1), f.exit); | 2939 | try std.testing.expectEqual(@as(u8, 1), f.exit); |
| 3938 | } | 2940 | } |
| 3939 | 2941 | ||
| 3940 | /// Stands in a caller's buffer before a refusal, so "wrote nothing" is | ||
| 3941 | /// distinguishable from "never writes anything". Not base64, not part of | ||
| 3942 | /// any escape the builder emits. | ||
| 3943 | const refusal_sentinel: u8 = 0xfe; | ||
| 3944 | |||
| 3945 | test "client: a validated clipboard effect becomes an OSC 52 write" { | ||
| 3946 | const alloc = std.testing.allocator; | ||
| 3947 | var out: std.ArrayList(u8) = .empty; | ||
| 3948 | defer out.deinit(alloc); | ||
| 3949 | |||
| 3950 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 3951 | .target = 'c', | ||
| 3952 | .base64 = "aGk=", | ||
| 3953 | } }); | ||
| 3954 | // BEL rather than ESC-backslash: it is what most emitters in the wild | ||
| 3955 | // use, and every terminal that accepts one accepts it. | ||
| 3956 | try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items); | ||
| 3957 | } | ||
| 3958 | |||
| 3959 | test "client: a validated bell effect becomes a BEL" { | ||
| 3960 | const alloc = std.testing.allocator; | ||
| 3961 | var out: std.ArrayList(u8) = .empty; | ||
| 3962 | defer out.deinit(alloc); | ||
| 3963 | |||
| 3964 | try appendHostEffect(&out, alloc, .bell); | ||
| 3965 | try std.testing.expectEqualStrings("\x07", out.items); | ||
| 3966 | } | ||
| 3967 | |||
| 3968 | test "client: xterm Pc targets retain their exact OSC 52 spelling" { | ||
| 3969 | const alloc = std.testing.allocator; | ||
| 3970 | |||
| 3971 | for ([_]u8{ 'c', 'p', 'q', 's', '0', '7' }) |target| { | ||
| 3972 | var out: std.ArrayList(u8) = .empty; | ||
| 3973 | defer out.deinit(alloc); | ||
| 3974 | |||
| 3975 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 3976 | .target = target, | ||
| 3977 | .base64 = "aGk=", | ||
| 3978 | } }); | ||
| 3979 | const want = [_]u8{ 0x1b, ']', '5', '2', ';', target, ';', 'a', 'G', 'k', '=', 0x07 }; | ||
| 3980 | try std.testing.expectEqualSlices(u8, &want, out.items); | ||
| 3981 | } | ||
| 3982 | } | ||
| 3983 | |||
| 3984 | test "client: terminal mode state turns bracketed paste on and off on the host" { | ||
| 3985 | const alloc = std.testing.allocator; | ||
| 3986 | var out: std.ArrayList(u8) = .empty; | ||
| 3987 | defer out.deinit(alloc); | ||
| 3988 | |||
| 3989 | // The mouse level-set follows every mode sample, so the paste bytes are | ||
| 3990 | // asserted as a prefix and the mouse half gets its own test below. | ||
| 3991 | try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = true } }); | ||
| 3992 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004h")); | ||
| 3993 | |||
| 3994 | out.clearRetainingCapacity(); | ||
| 3995 | try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = false } }); | ||
| 3996 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004l")); | ||
| 3997 | } | ||
| 3998 | |||
| 3999 | test "client: with no application asking, the client keeps the mouse for the wheel" { | ||
| 4000 | const alloc = std.testing.allocator; | ||
| 4001 | var out: std.ArrayList(u8) = .empty; | ||
| 4002 | defer out.deinit(alloc); | ||
| 4003 | |||
| 4004 | // A format mode alone is not a claim on the mouse: nothing asked for an | ||
| 4005 | // event, so 1000+1006 stay ours and 1005 goes back off. | ||
| 4006 | try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_utf8 = true }); | ||
| 4007 | try std.testing.expectEqualStrings( | ||
| 4008 | "\x1b[?9l\x1b[?1000h\x1b[?1002l\x1b[?1003l" ++ | ||
| 4009 | "\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l", | ||
| 4010 | out.items, | ||
| 4011 | ); | ||
| 4012 | } | ||
| 4013 | |||
| 4014 | test "client: an application that asked for the mouse gets exactly the modes it asked for" { | ||
| 4015 | const alloc = std.testing.allocator; | ||
| 4016 | var out: std.ArrayList(u8) = .empty; | ||
| 4017 | defer out.deinit(alloc); | ||
| 4018 | |||
| 4019 | // vim's `set mouse=a`. 1000 is on because vim asked, not because we | ||
| 4020 | // want it, and 1002 proves the difference: our own set never asks for | ||
| 4021 | // drag reports. | ||
| 4022 | try appendMouseModes(&out, alloc, .{ | ||
| 4023 | .bracketed_paste = false, | ||
| 4024 | .mouse_normal = true, | ||
| 4025 | .mouse_button = true, | ||
| 4026 | .mouse_sgr = true, | ||
| 4027 | }); | ||
| 4028 | try std.testing.expectEqualStrings( | ||
| 4029 | "\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003l" ++ | ||
| 4030 | "\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l", | ||
| 4031 | out.items, | ||
| 4032 | ); | ||
| 4033 | |||
| 4034 | // An application on the legacy format gets the legacy format: leaving | ||
| 4035 | // our own 1006 on would spell every click in a shape it cannot parse. | ||
| 4036 | out.clearRetainingCapacity(); | ||
| 4037 | try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_normal = true }); | ||
| 4038 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1006l") != null); | ||
| 4039 | } | ||
| 4040 | |||
| 4041 | test "client: a title becomes an OSC 0 write, and empty or control bytes are refused" { | ||
| 4042 | const alloc = std.testing.allocator; | ||
| 4043 | var out: std.ArrayList(u8) = .empty; | ||
| 4044 | defer out.deinit(alloc); | ||
| 4045 | |||
| 4046 | try appendTermTitle(&out, alloc, "vim"); | ||
| 4047 | try std.testing.expectEqualStrings("\x1b]0;vim\x07", out.items); | ||
| 4048 | |||
| 4049 | // Seeded, not merely emptied: `len == 0` on a buffer that started empty | ||
| 4050 | // also passes for a builder that appends nothing ever. | ||
| 4051 | out.clearRetainingCapacity(); | ||
| 4052 | try out.append(alloc, refusal_sentinel); | ||
| 4053 | // A BEL inside the title would terminate the OSC early and paint the | ||
| 4054 | // rest — here a shell command — on the user's screen as text. | ||
| 4055 | try appendTermTitle(&out, alloc, "vim\x07rm -rf"); | ||
| 4056 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 4057 | |||
| 4058 | // ESC is the other terminator half (ST), and DEL is the control byte | ||
| 4059 | // that is not below 0x20 — both are refused by the same check. | ||
| 4060 | out.clearRetainingCapacity(); | ||
| 4061 | try out.append(alloc, refusal_sentinel); | ||
| 4062 | try appendTermTitle(&out, alloc, "vim\x1b]52;c;AAAA\x07"); | ||
| 4063 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 4064 | |||
| 4065 | out.clearRetainingCapacity(); | ||
| 4066 | try out.append(alloc, refusal_sentinel); | ||
| 4067 | try appendTermTitle(&out, alloc, "vim\x7f"); | ||
| 4068 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 4069 | |||
| 4070 | // Empty is refused rather than written: `ESC]0;BEL` would CLEAR the | ||
| 4071 | // host terminal's title, and no daemon of any version has a reason to | ||
| 4072 | // ask for that. See sampleTermTitle for the daemon half of this policy. | ||
| 4073 | out.clearRetainingCapacity(); | ||
| 4074 | try out.append(alloc, refusal_sentinel); | ||
| 4075 | try appendTermTitle(&out, alloc, ""); | ||
| 4076 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 4077 | } | ||
| 4078 | |||
| 4079 | test "client: the title cap is a cap, not an off-by-one" { | ||
| 4080 | const alloc = std.testing.allocator; | ||
| 4081 | var out: std.ArrayList(u8) = .empty; | ||
| 4082 | defer out.deinit(alloc); | ||
| 4083 | |||
| 4084 | const at_cap = try alloc.alloc(u8, proto.term_title_max); | ||
| 4085 | defer alloc.free(at_cap); | ||
| 4086 | @memset(at_cap, 'x'); | ||
| 4087 | try appendTermTitle(&out, alloc, at_cap); | ||
| 4088 | // "\x1b]0;" is four bytes and the BEL is one. | ||
| 4089 | try std.testing.expectEqual(proto.term_title_max + 5, out.items.len); | ||
| 4090 | |||
| 4091 | const over = try alloc.alloc(u8, proto.term_title_max + 1); | ||
| 4092 | defer alloc.free(over); | ||
| 4093 | @memset(over, 'x'); | ||
| 4094 | out.clearRetainingCapacity(); | ||
| 4095 | try out.append(alloc, refusal_sentinel); | ||
| 4096 | try appendTermTitle(&out, alloc, over); | ||
| 4097 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 4098 | } | ||
| 4099 | |||
| 4100 | test "client: the exit teardown unsets every mode mux turned on, and pops the title" { | ||
| 4101 | // A multiplexer that leaves your terminal in a mode it enabled is worse | ||
| 4102 | // than one that pastes badly, so the teardown string is pinned as a | ||
| 4103 | // literal rather than assembled from the constants it writes. | ||
| 4104 | try std.testing.expectEqualStrings( | ||
| 4105 | "\x1b[?2004l" ++ | ||
| 4106 | "\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l" ++ | ||
| 4107 | "\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" ++ | ||
| 4108 | "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l", | ||
| 4109 | terminal_teardown, | ||
| 4110 | ); | ||
| 4111 | // The pop is worthless — worse, it pops a stranger's title — without | ||
| 4112 | // the push that pairs with it, and the two live far apart: the push is | ||
| 4113 | // a literal inside the frame loop's alt-screen entry. Pinned here | ||
| 4114 | // together so deleting either one fails, rather than quietly leaving | ||
| 4115 | // the terminal one push deep forever or one pop too many. | ||
| 4116 | try std.testing.expectEqualStrings( | ||
| 4117 | "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h", | ||
| 4118 | terminal_setup, | ||
| 4119 | ); | ||
| 4120 | // Every mode the setup turns on has an `l` for it in the teardown. The | ||
| 4121 | // mouse half is the one that can drift, because the daemon can ask for | ||
| 4122 | // modes this string never mentions. | ||
| 4123 | inline for (proto.mouse_modes) |m| { | ||
| 4124 | try std.testing.expect(std.mem.indexOf( | ||
| 4125 | u8, | ||
| 4126 | terminal_teardown, | ||
| 4127 | comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}), | ||
| 4128 | ) != null); | ||
| 4129 | } | ||
| 4130 | } | ||
| 4131 | |||
| 4132 | test "client: an unvalidated clipboard effect writes nothing" { | ||
| 4133 | const alloc = std.testing.allocator; | ||
| 4134 | var out: std.ArrayList(u8) = .empty; | ||
| 4135 | defer out.deinit(alloc); | ||
| 4136 | |||
| 4137 | // Exactly the value wasm_core.zig default-initialises its borrowed | ||
| 4138 | // clipboard slot to, and exactly what client_core.validClipboard | ||
| 4139 | // refuses. Written verbatim it is `ESC]52;<NUL>;BEL` on a real tty. | ||
| 4140 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 4141 | .target = 0, | ||
| 4142 | .base64 = &.{}, | ||
| 4143 | } }); | ||
| 4144 | try std.testing.expectEqual(@as(usize, 0), out.items.len); | ||
| 4145 | |||
| 4146 | // The refusal is the whole value, not just its target: a legal target | ||
| 4147 | // carrying bytes outside the base64 alphabet is the injection this | ||
| 4148 | // check exists for. | ||
| 4149 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 4150 | .target = 'c', | ||
| 4151 | .base64 = "aGk=\x1b]0;pwned\x07", | ||
| 4152 | } }); | ||
| 4153 | try std.testing.expectEqual(@as(usize, 0), out.items.len); | ||
| 4154 | } | ||
| 4155 | |||
| 4156 | test "client: a clipboard effect at the cap retains its exact framing" { | ||
| 4157 | const alloc = std.testing.allocator; | ||
| 4158 | |||
| 4159 | const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max); | ||
| 4160 | defer alloc.free(at_cap); | ||
| 4161 | @memset(at_cap, 'A'); | ||
| 4162 | |||
| 4163 | // The boundary itself is ACCEPTED — stated because `>` and `>=` are one | ||
| 4164 | // keystroke apart and the wrong one silently truncates the largest copy | ||
| 4165 | // the daemon is willing to send. | ||
| 4166 | { | ||
| 4167 | var out: std.ArrayList(u8) = .empty; | ||
| 4168 | defer out.deinit(alloc); | ||
| 4169 | |||
| 4170 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 4171 | .target = 'c', | ||
| 4172 | .base64 = at_cap, | ||
| 4173 | } }); | ||
| 4174 | // "\x1b]52;c;" ++ payload ++ BEL | ||
| 4175 | try std.testing.expectEqual(at_cap.len + 8, out.items.len); | ||
| 4176 | } | ||
| 4177 | } | ||
| 4178 | |||
| 4179 | fn appendNothing( | ||
| 4180 | _: *std.ArrayList(u8), | ||
| 4181 | _: std.mem.Allocator, | ||
| 4182 | _: void, | ||
| 4183 | ) std.mem.Allocator.Error!void {} | ||
| 4184 | |||
| 4185 | test "client: side channels write nothing before terminal ownership" { | ||
| 4186 | const pipe = try std.posix.pipe(); | ||
| 4187 | defer std.posix.close(pipe[0]); | ||
| 4188 | var write_open = true; | ||
| 4189 | defer if (write_open) std.posix.close(pipe[1]); | ||
| 4190 | |||
| 4191 | try writeSideChannel( | ||
| 4192 | std.testing.allocator, | ||
| 4193 | pipe[1], | ||
| 4194 | false, | ||
| 4195 | client_core.Effect, | ||
| 4196 | .{ .bell = {} }, | ||
| 4197 | appendHostEffect, | ||
| 4198 | ); | ||
| 4199 | std.posix.close(pipe[1]); | ||
| 4200 | write_open = false; | ||
| 4201 | |||
| 4202 | var byte: [1]u8 = undefined; | ||
| 4203 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte)); | ||
| 4204 | } | ||
| 4205 | |||
| 4206 | test "client: an empty side-channel rendering writes nothing" { | ||
| 4207 | const pipe = try std.posix.pipe(); | ||
| 4208 | defer std.posix.close(pipe[0]); | ||
| 4209 | var write_open = true; | ||
| 4210 | defer if (write_open) std.posix.close(pipe[1]); | ||
| 4211 | |||
| 4212 | try writeSideChannel( | ||
| 4213 | std.testing.allocator, | ||
| 4214 | pipe[1], | ||
| 4215 | true, | ||
| 4216 | void, | ||
| 4217 | {}, | ||
| 4218 | appendNothing, | ||
| 4219 | ); | ||
| 4220 | std.posix.close(pipe[1]); | ||
| 4221 | write_open = false; | ||
| 4222 | |||
| 4223 | var byte: [1]u8 = undefined; | ||
| 4224 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte)); | ||
| 4225 | } | ||
| 4226 | |||
| 4227 | test "client: an allocation failure discards a partially built side channel" { | ||
| 4228 | const pipe = try std.posix.pipe(); | ||
| 4229 | defer std.posix.close(pipe[0]); | ||
| 4230 | |||
| 4231 | // The OSC introducer gets the first allocation. Growing for the payload | ||
| 4232 | // then fails both its resize and allocation fallback, after real escape | ||
| 4233 | // bytes exist in writeSideChannel's private buffer. | ||
| 4234 | var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ | ||
| 4235 | .fail_index = 1, | ||
| 4236 | .resize_fail_index = 0, | ||
| 4237 | }); | ||
| 4238 | var payload: [128]u8 = undefined; | ||
| 4239 | @memset(&payload, 'A'); | ||
| 4240 | const result = writeSideChannel( | ||
| 4241 | failing.allocator(), | ||
| 4242 | pipe[1], | ||
| 4243 | true, | ||
| 4244 | client_core.Effect, | ||
| 4245 | .{ .clipboard_set = .{ | ||
| 4246 | .target = 'c', | ||
| 4247 | .base64 = &payload, | ||
| 4248 | } }, | ||
| 4249 | appendHostEffect, | ||
| 4250 | ); | ||
| 4251 | std.posix.close(pipe[1]); | ||
| 4252 | |||
| 4253 | try std.testing.expectError(error.OutOfMemory, result); | ||
| 4254 | try std.testing.expectEqual(@as(usize, 1), failing.allocations); | ||
| 4255 | try std.testing.expect(failing.has_induced_failure); | ||
| 4256 | var byte: [1]u8 = undefined; | ||
| 4257 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte)); | ||
| 4258 | } | ||
| 4259 | |||
| 4260 | test "client: a chord in one read detaches and forwards what preceded it" { | ||
| 4261 | var f: PrefixFilter = .{}; | ||
| 4262 | var chunk = "ab\x1cd".*; | ||
| 4263 | const out = f.feed(&chunk); | ||
| 4264 | try std.testing.expectEqual(PrefixFilter.Action.detach, out.action); | ||
| 4265 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 4266 | } | ||
| 4267 | |||
| 4268 | test "client: a doubled prefix detaches" { | ||
| 4269 | var f: PrefixFilter = .{}; | ||
| 4270 | var chunk = "\x1c\x1c".*; | ||
| 4271 | const out = f.feed(&chunk); | ||
| 4272 | try std.testing.expectEqual(PrefixFilter.Action.detach, out.action); | ||
| 4273 | try std.testing.expectEqualStrings("", out.forward); | ||
| 4274 | } | ||
| 4275 | |||
| 4276 | test "client: a chord split across two reads is still one chord" { | ||
| 4277 | var f: PrefixFilter = .{}; | ||
| 4278 | var first = "ab\x1c".*; | ||
| 4279 | const a = f.feed(&first); | ||
| 4280 | try std.testing.expectEqual(PrefixFilter.Action.none, a.action); | ||
| 4281 | try std.testing.expectEqualStrings("ab", a.forward); | ||
| 4282 | var second = "dz".*; | ||
| 4283 | const b = f.feed(&second); | ||
| 4284 | try std.testing.expectEqual(PrefixFilter.Action.detach, b.action); | ||
| 4285 | try std.testing.expectEqualStrings("", b.forward); | ||
| 4286 | } | ||
| 4287 | |||
| 4288 | test "client: an unknown command key is swallowed with its prefix" { | ||
| 4289 | var f: PrefixFilter = .{}; | ||
| 4290 | var chunk = "a\x1cxb".*; | ||
| 4291 | const out = f.feed(&chunk); | ||
| 4292 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 4293 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 4294 | // Back to normal: the next `d` is an ordinary keystroke, not a command. | ||
| 4295 | var after = "d".*; | ||
| 4296 | const next = f.feed(&after); | ||
| 4297 | try std.testing.expectEqual(PrefixFilter.Action.none, next.action); | ||
| 4298 | try std.testing.expectEqualStrings("d", next.forward); | ||
| 4299 | } | ||
| 4300 | |||
| 4301 | // `l` has no meaning in a client yet (the switch swallows it) but the TABLE | ||
| 4302 | // must name it, because the wall's zoomed tile reads its chords out of this | ||
| 4303 | // same table. Split across reads for the same reason every other chord is: | ||
| 4304 | // a read boundary is not a chord boundary. | ||
| 4305 | test "client: Ctrl-\\ l is a chord in the table, whoever acts on it" { | ||
| 4306 | var f: PrefixFilter = .{}; | ||
| 4307 | var chunk = "ab\x1clcd".*; | ||
| 4308 | const out = f.feed(&chunk); | ||
| 4309 | try std.testing.expectEqual(PrefixFilter.Action.last_session, out.action); | ||
| 4310 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 4311 | |||
| 4312 | var g: PrefixFilter = .{}; | ||
| 4313 | var first = "x\x1c".*; | ||
| 4314 | try std.testing.expectEqual(PrefixFilter.Action.none, g.feed(&first).action); | ||
| 4315 | var second = "l".*; | ||
| 4316 | try std.testing.expectEqual(PrefixFilter.Action.last_session, g.feed(&second).action); | ||
| 4317 | } | ||
| 4318 | |||
| 4319 | test "client: bytes with no prefix pass through untouched" { | ||
| 4320 | var f: PrefixFilter = .{}; | ||
| 4321 | var chunk = "hello\x1b[A".*; | ||
| 4322 | const out = f.feed(&chunk); | ||
| 4323 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 4324 | try std.testing.expectEqualStrings("hello\x1b[A", out.forward); | ||
| 4325 | } | ||
| 4326 | |||
| 4327 | test "client: two chords in one buffer are consumed independently" { | ||
| 4328 | var f: PrefixFilter = .{}; | ||
| 4329 | var chunk = "\x1cxz\x1cq".*; | ||
| 4330 | const out = f.feed(&chunk); | ||
| 4331 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 4332 | try std.testing.expectEqualStrings("z", out.forward); | ||
| 4333 | var chunk2 = "\x1cx\x1cd".*; | ||
| 4334 | const out2 = f.feed(&chunk2); | ||
| 4335 | try std.testing.expectEqual(PrefixFilter.Action.detach, out2.action); | ||
| 4336 | try std.testing.expectEqualStrings("", out2.forward); | ||
| 4337 | } | ||
| 4338 | |||
| 4339 | test "client: a wheel report becomes a scroll and never reaches the pty" { | ||
| 4340 | var f: MouseFilter = .{}; | ||
| 4341 | var out: [64]u8 = undefined; | ||
| 4342 | |||
| 4343 | // Button 64 press: wheel up, into history. Button 65: wheel down. | ||
| 4344 | const up = f.feed("\x1b[<64;10;5M", &out); | ||
| 4345 | try std.testing.expectEqual(@as(i32, 1), up.wheel); | ||
| 4346 | try std.testing.expectEqualStrings("", up.forward); | ||
| 4347 | |||
| 4348 | const dn = f.feed("\x1b[<65;10;5M", &out); | ||
| 4349 | try std.testing.expectEqual(@as(i32, -1), dn.wheel); | ||
| 4350 | try std.testing.expectEqualStrings("", dn.forward); | ||
| 4351 | |||
| 4352 | // A terminal sends a burst when the wheel is spun; they add up rather | ||
| 4353 | // than the last one winning, or a fast spin would move one notch. | ||
| 4354 | const burst = f.feed("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<65;1;1M", &out); | ||
| 4355 | try std.testing.expectEqual(@as(i32, 2), burst.wheel); | ||
| 4356 | try std.testing.expectEqualStrings("", burst.forward); | ||
| 4357 | |||
| 4358 | // Ctrl+wheel is still a wheel: no zoom exists here to claim it. | ||
| 4359 | const ctrl = f.feed("\x1b[<80;1;1M", &out); | ||
| 4360 | try std.testing.expectEqual(@as(i32, 1), ctrl.wheel); | ||
| 4361 | } | ||
| 4362 | |||
| 4363 | test "client: alternate scroll spells its arrows the way the session reads them" { | ||
| 4364 | // Normal cursor keys: the CSI form. Application mode (DECCKM, what | ||
| 4365 | // `less` and every curses program set): the SS3 form. A client that | ||
| 4366 | // sent the CSI form to an application-mode reader would scroll nothing | ||
| 4367 | // and say nothing — measured on `less +G` before this existed. | ||
| 4368 | try std.testing.expectEqualStrings("\x1b[A", altScrollSeq(1, false)); | ||
| 4369 | try std.testing.expectEqualStrings("\x1b[B", altScrollSeq(-1, false)); | ||
| 4370 | try std.testing.expectEqualStrings("\x1bOA", altScrollSeq(1, true)); | ||
| 4371 | try std.testing.expectEqualStrings("\x1bOB", altScrollSeq(-1, true)); | ||
| 4372 | } | ||
| 4373 | |||
| 4374 | test "client: clicks, drags and releases are discarded rather than typed at the shell" { | ||
| 4375 | var f: MouseFilter = .{}; | ||
| 4376 | var out: [64]u8 = undefined; | ||
| 4377 | |||
| 4378 | // Left press, left release, drag (motion bit 32 with button 0), and the | ||
| 4379 | // wheel's horizontal cousins. Nobody asked for any of them — with no | ||
| 4380 | // application wanting the mouse there is nobody to send them to. | ||
| 4381 | for ([_][]const u8{ | ||
| 4382 | "\x1b[<0;40;12M", | ||
| 4383 | "\x1b[<0;40;12m", | ||
| 4384 | "\x1b[<32;41;12M", | ||
| 4385 | "\x1b[<66;1;1M", | ||
| 4386 | "\x1b[<67;1;1M", | ||
| 4387 | "\x1b[<64;1;1m", | ||
| 4388 | }) |report| { | ||
| 4389 | const r = f.feed(report, &out); | ||
| 4390 | try std.testing.expectEqual(@as(i32, 0), r.wheel); | ||
| 4391 | try std.testing.expectEqualStrings("", r.forward); | ||
| 4392 | } | ||
| 4393 | } | ||
| 4394 | |||
| 4395 | test "client: a wheel report split across reads is still one report" { | ||
| 4396 | var f: MouseFilter = .{}; | ||
| 4397 | var out: [64]u8 = undefined; | ||
| 4398 | |||
| 4399 | // The split is after the `ESC [ <` that starts the hold — the only | ||
| 4400 | // place the filter holds, deliberately (see MouseFilter). | ||
| 4401 | const a = f.feed("typed\x1b[<64;", &out); | ||
| 4402 | try std.testing.expectEqual(@as(i32, 0), a.wheel); | ||
| 4403 | try std.testing.expectEqualStrings("typed", a.forward); | ||
| 4404 | |||
| 4405 | const b = f.feed("10;5M", &out); | ||
| 4406 | try std.testing.expectEqual(@as(i32, 1), b.wheel); | ||
| 4407 | try std.testing.expectEqualStrings("", b.forward); | ||
| 4408 | } | ||
| 4409 | |||
| 4410 | test "client: keystrokes survive the mouse filter, in order and unheld" { | ||
| 4411 | var f: MouseFilter = .{}; | ||
| 4412 | var out: [64]u8 = undefined; | ||
| 4413 | |||
| 4414 | // A bare Escape is forwarded on the read it arrived in. This is the | ||
| 4415 | // property the filter gives up completeness for: held, it would strand | ||
| 4416 | // every Escape in vim until the next keystroke. | ||
| 4417 | const esc = f.feed("\x1b", &out); | ||
| 4418 | try std.testing.expectEqualStrings("\x1b", esc.forward); | ||
| 4419 | |||
| 4420 | // An arrow key is `ESC [ A`: it starts like a report and is not one. | ||
| 4421 | const arrow = f.feed("\x1b[A", &out); | ||
| 4422 | try std.testing.expectEqualStrings("\x1b[A", arrow.forward); | ||
| 4423 | |||
| 4424 | // Typing either side of a wheel notch keeps its order. | ||
| 4425 | const mixed = f.feed("ab\x1b[<64;1;1Mcd", &out); | ||
| 4426 | try std.testing.expectEqual(@as(i32, 1), mixed.wheel); | ||
| 4427 | try std.testing.expectEqualStrings("abcd", mixed.forward); | ||
| 4428 | |||
| 4429 | // The exact chunk the scroll block's `was_live` rule is about: a notch | ||
| 4430 | // and a keystroke in one read. The filter must hand back BOTH — the | ||
| 4431 | // notch to move the view and the `x` to reach the pty — because a | ||
| 4432 | // filter that dropped either would make that rule undecidable. | ||
| 4433 | const both = f.feed("\x1b[<64;1;1Mx", &out); | ||
| 4434 | try std.testing.expectEqual(@as(i32, 1), both.wheel); | ||
| 4435 | try std.testing.expectEqualStrings("x", both.forward); | ||
| 4436 | } | ||
| 4437 | |||
| 4438 | test "client: a candidate that turns out not to be a report is given back whole" { | ||
| 4439 | var f: MouseFilter = .{}; | ||
| 4440 | var out: [64]u8 = undefined; | ||
| 4441 | |||
| 4442 | // `ESC [ <` with a letter behind it is not a mouse report — nothing may | ||
| 4443 | // be swallowed on the strength of a guess. | ||
| 4444 | const broken = f.feed("\x1b[<12x", &out); | ||
| 4445 | try std.testing.expectEqual(@as(i32, 0), broken.wheel); | ||
| 4446 | try std.testing.expectEqualStrings("\x1b[<12x", broken.forward); | ||
| 4447 | |||
| 4448 | // Held bytes from a previous read come back ahead of this read's, in | ||
| 4449 | // the order they were typed. | ||
| 4450 | const held = f.feed("\x1b[<9", &out); | ||
| 4451 | try std.testing.expectEqualStrings("", held.forward); | ||
| 4452 | const rest = f.feed("q", &out); | ||
| 4453 | try std.testing.expectEqualStrings("\x1b[<9q", rest.forward); | ||
| 4454 | |||
| 4455 | // A candidate longer than any real report is abandoned, not held for | ||
| 4456 | // ever. | ||
| 4457 | var long: [MouseFilter.max_held * 2]u8 = undefined; | ||
| 4458 | @memcpy(long[0..3], "\x1b[<"); | ||
| 4459 | @memset(long[3..], '1'); | ||
| 4460 | var big_out: [long.len + MouseFilter.max_held]u8 = undefined; | ||
| 4461 | const over = f.feed(&long, &big_out); | ||
| 4462 | try std.testing.expectEqual(@as(i32, 0), over.wheel); | ||
| 4463 | try std.testing.expectEqualStrings(&long, over.forward); | ||
| 4464 | } | ||
| 4465 | |||
| 4466 | test "client: resetting the filter drops a half-read report" { | ||
| 4467 | var f: MouseFilter = .{}; | ||
| 4468 | var out: [64]u8 = undefined; | ||
| 4469 | |||
| 4470 | // What the handover to a mouse-hungry application does: the rest of the | ||
| 4471 | // report belongs to the application, so the head of it must not be | ||
| 4472 | // pushed back into its input. | ||
| 4473 | _ = f.feed("\x1b[<64;", &out); | ||
| 4474 | f.reset(); | ||
| 4475 | const after = f.feed("hi", &out); | ||
| 4476 | try std.testing.expectEqualStrings("hi", after.forward); | ||
| 4477 | } | ||
| 4478 | |||
| 4479 | test "client: Ctrl-\\ c asks for a new session and ends the chunk" { | ||
| 4480 | var f: PrefixFilter = .{}; | ||
| 4481 | var chunk = "ab\x1ccz".*; | ||
| 4482 | const out = f.feed(&chunk); | ||
| 4483 | try std.testing.expectEqual(PrefixFilter.Action.new_session, out.action); | ||
| 4484 | // "ab" was typed at the session we are leaving and has already been | ||
| 4485 | // sent; the "z" behind the chord was typed at it too and is dropped, | ||
| 4486 | // because the only sessions left to put it in are the wrong ones. | ||
| 4487 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 4488 | } | ||
| 4489 | |||
| 4490 | test "client: the new session's name is the lowest free integer" { | 2942 | test "client: the new session's name is the lowest free integer" { |
| 4491 | var buf: [proto.session_name_max]u8 = undefined; | 2943 | var buf: [proto.session_name_max]u8 = undefined; |
| 4492 | try std.testing.expectEqualStrings("0", nextFreeName(&buf, "")); | 2944 | try std.testing.expectEqualStrings("0", nextFreeName(&buf, "")); |
| @@ -4503,20 +2955,6 @@ test "client: the new session's name is the lowest free integer" { | |||
| 4503 | try std.testing.expectEqualStrings("2", nextFreeName(&buf, "1\n0")); | 2955 | try std.testing.expectEqualStrings("2", nextFreeName(&buf, "1\n0")); |
| 4504 | } | 2956 | } |
| 4505 | 2957 | ||
| 4506 | test "client: Ctrl-\\ n and Ctrl-\\ p step the session ring" { | ||
| 4507 | var f: PrefixFilter = .{}; | ||
| 4508 | var fwd = "ab\x1cnz".*; | ||
| 4509 | const n = f.feed(&fwd); | ||
| 4510 | try std.testing.expectEqual(PrefixFilter.Action.next_session, n.action); | ||
| 4511 | // Same reason `c` drops its tail: "z" was typed at the session we are | ||
| 4512 | // leaving, and the only sessions left to put it in are the wrong ones. | ||
| 4513 | try std.testing.expectEqualStrings("ab", n.forward); | ||
| 4514 | var back = "\x1cp".*; | ||
| 4515 | const p = f.feed(&back); | ||
| 4516 | try std.testing.expectEqual(PrefixFilter.Action.prev_session, p.action); | ||
| 4517 | try std.testing.expectEqualStrings("", p.forward); | ||
| 4518 | } | ||
| 4519 | |||
| 4520 | test "client: a ring step lands on the neighbour, wrapping at both ends" { | 2958 | test "client: a ring step lands on the neighbour, wrapping at both ends" { |
| 4521 | // Slot order, not sorted order — the list is quoted as the daemon | 2959 | // Slot order, not sorted order — the list is quoted as the daemon |
| 4522 | // would send it. | 2960 | // would send it. |
| @@ -4628,17 +3066,6 @@ test "client: the wall drops the tile the walling shell is standing in" { | |||
| 4628 | try std.testing.expect(!wallShowsSelf(target, "0", sock, "")); | 3066 | try std.testing.expect(!wallShowsSelf(target, "0", sock, "")); |
| 4629 | } | 3067 | } |
| 4630 | 3068 | ||
| 4631 | test "client: Ctrl-\\ w asks for the wall and ends the chunk" { | ||
| 4632 | var f: PrefixFilter = .{}; | ||
| 4633 | var chunk = "ab\x1cwz".*; | ||
| 4634 | const out = f.feed(&chunk); | ||
| 4635 | try std.testing.expectEqual(PrefixFilter.Action.wall, out.action); | ||
| 4636 | // The drop the user is most likely to notice, because unlike a switch | ||
| 4637 | // they come BACK to this session: "z" is gone, and the reason is that | ||
| 4638 | // it was typed before the wall took the terminal. | ||
| 4639 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 4640 | } | ||
| 4641 | |||
| 4642 | test "client: a session list travels by value, and an oversized one is refused" { | 3069 | test "client: a session list travels by value, and an oversized one is refused" { |
| 4643 | const list = "0\n1\ndev"; | 3070 | const list = "0\n1\ndev"; |
| 4644 | const held = SessionsText.of(list).?; | 3071 | const held = SessionsText.of(list).?; |
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1627 @@ | |||
| 1 | //! The session-interaction core: everything that happens between a user at | ||
| 2 | //! a terminal and one attached session, with the dialling left out. | ||
| 3 | //! | ||
| 4 | //! What lives here is the machinery a session needs once a transport is | ||
| 5 | //! already open — the `Ctrl-\` chord layer, the mouse/wheel splitter and | ||
| 6 | //! alternate scroll, speculative echo (offer, reconcile, paint), the side | ||
| 7 | //! channels a session drives on the host terminal (title, clipboard, bell, | ||
| 8 | //! modes), and the terminal ownership those depend on (raw mode, the | ||
| 9 | //! alternate screen, the teardown that puts both back). | ||
| 10 | //! | ||
| 11 | //! What deliberately does NOT live here is how a transport is BUILT or what | ||
| 12 | //! a chord MEANS. Targets, dialling, reconnect backoff and the handoff are | ||
| 13 | //! client.zig's; `PrefixFilter` answers "which action was typed" and every | ||
| 14 | //! driver decides for itself what that action does to its own world — a | ||
| 15 | //! plain client's `.detach` ends its session, a wall tile's unzooms it. | ||
| 16 | //! | ||
| 17 | //! Drivers are the CLI client (client.zig) and, from the wall's phase 3 | ||
| 18 | //! convergence on, a wall tile. Both hold their own transport and their own | ||
| 19 | //! replica; nothing here is a singleton and nothing here dials. | ||
| 20 | //! | ||
| 21 | //! The transport is taken as `anytype` throughout rather than by name. That | ||
| 22 | //! is a layering fact, not a generality wish: `Transport` is built out of | ||
| 23 | //! QUIC and the ssh handoff and therefore sits ABOVE this module, so the | ||
| 24 | //! only thing this module can say about it is the surface it uses — | ||
| 25 | //! `writeFrame(proto.MsgType, []const u8) !void`, and nothing else. | ||
| 26 | //! | ||
| 27 | //! Two invariants this module is the keeper of. The prediction overlay is a | ||
| 28 | //! display decision: it never writes to the replica, so the replica keeps | ||
| 29 | //! meaning exactly "what the daemon said". And `replica.zig` is the one | ||
| 30 | //! applier: everything here either reads a replica or paints on top of one. | ||
| 31 | |||
| 32 | const std = @import("std"); | ||
| 33 | const Engine = @import("engine").Engine; | ||
| 34 | const Replica = @import("replica").Replica; | ||
| 35 | const proto = @import("protocol"); | ||
| 36 | const predict = @import("predict"); | ||
| 37 | const client_core = @import("client_core"); | ||
| 38 | // Named `paint_mod` because paintOverlay holds a local ArrayList called | ||
| 39 | // `paint`, which a container-level `paint` would collide with. | ||
| 40 | const paint_mod = @import("paint"); | ||
| 41 | |||
| 42 | // Ctrl-\. In a live session it is the command prefix (see PrefixFilter); | ||
| 43 | // while dialling or reconnecting there is no session to command, so a bare | ||
| 44 | // press still means "give up". | ||
| 45 | pub const detach_key: u8 = 0x1c; | ||
| 46 | |||
| 47 | /// The attached client's keybinding layer: Ctrl-\ selects a command rather | ||
| 48 | /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new | ||
| 49 | /// session, `n` and `p` step to the next and previous one, `l` skips to the | ||
| 50 | /// last one visited, `w` shows the wall of this daemon's sessions; any other | ||
| 51 | /// key is dropped along with the prefix. Dropping is not a loss — a literal | ||
| 52 | /// 0x1c never reached the pty before this layer existed either. | ||
| 53 | /// | ||
| 54 | /// Only what is typed at an established session passes through here: the | ||
| 55 | /// keystrokes `attach` carried across the opening handshake go straight out | ||
| 56 | /// as input (see `carry`), because there was no session to command yet when | ||
| 57 | /// they were typed. | ||
| 58 | /// | ||
| 59 | /// Public because the CLI wall's ZOOMED tile needs the same layer over the | ||
| 60 | /// same keys (wallview.zig): a zoomed tile is a session on this terminal, | ||
| 61 | /// and a second copy of this table would be a twin that drifts. What each | ||
| 62 | /// action MEANS is the caller's — `.detach` leaves a client's session and | ||
| 63 | /// unzooms the wall's tile, `.next_session` steps the client's session ring | ||
| 64 | /// and moves the wall's zoom — but which byte spells it is one table. | ||
| 65 | pub const PrefixFilter = struct { | ||
| 66 | /// Callers switch on it, so a new variant is additive. | ||
| 67 | pub const Action = enum { none, detach, new_session, next_session, prev_session, last_session, wall }; | ||
| 68 | |||
| 69 | pub const Out = struct { forward: []const u8, action: Action }; | ||
| 70 | |||
| 71 | /// A prefix arrived at the end of a read and its command key has not | ||
| 72 | /// been typed yet. Held across reads so a chord split by a read | ||
| 73 | /// boundary is still one chord. | ||
| 74 | pending: bool = false, | ||
| 75 | |||
| 76 | /// Filters one raw stdin chunk in place — the layer only ever removes | ||
| 77 | /// bytes, so the survivors compact leftwards over the same buffer. | ||
| 78 | /// An action ends the chunk: whatever was typed behind it is dropped. | ||
| 79 | /// For `.detach` that is trivially right (the loop returns), and for a | ||
| 80 | /// switch it is the only honest answer — those bytes were typed at the | ||
| 81 | /// OLD session, so forwarding them to the new one would put them in the | ||
| 82 | /// wrong shell, and sending them back to the old one races the detach | ||
| 83 | /// that is already on its way. | ||
| 84 | /// | ||
| 85 | /// `.wall` is the variant where the loss is visible: the user comes | ||
| 86 | /// BACK to this same session, so bytes dropped behind `Ctrl-\ w` are | ||
| 87 | /// bytes they will look for and not find. The rule stays as it is | ||
| 88 | /// anyway — those bytes were typed before the wall took the terminal, | ||
| 89 | /// and delivering them after it gives them back would replay them into | ||
| 90 | /// a shell whose prompt has moved on. | ||
| 91 | pub fn feed(self: *PrefixFilter, buf: []u8) Out { | ||
| 92 | var kept: usize = 0; | ||
| 93 | for (buf) |b| { | ||
| 94 | if (self.pending) { | ||
| 95 | self.pending = false; | ||
| 96 | switch (b) { | ||
| 97 | 'd', detach_key => return .{ .forward = buf[0..kept], .action = .detach }, | ||
| 98 | 'c' => return .{ .forward = buf[0..kept], .action = .new_session }, | ||
| 99 | 'n' => return .{ .forward = buf[0..kept], .action = .next_session }, | ||
| 100 | 'p' => return .{ .forward = buf[0..kept], .action = .prev_session }, | ||
| 101 | // Not the same as the `else` arm `l` used to fall | ||
| 102 | // through to, and the difference is real: an unknown | ||
| 103 | // command key drops itself and lets the REST of the | ||
| 104 | // read through, while a chord ends the chunk and drops | ||
| 105 | // whatever was typed behind it. `l` is a chord now, so | ||
| 106 | // it behaves like `n` and `p` and not like `z`. The | ||
| 107 | // client has no meaning for the action and swallows it | ||
| 108 | // (see the session loop), but the bytes behind it are | ||
| 109 | // gone either way — which is the rule every chord | ||
| 110 | // already keeps, for the reason argued above. | ||
| 111 | 'l' => return .{ .forward = buf[0..kept], .action = .last_session }, | ||
| 112 | 'w' => return .{ .forward = buf[0..kept], .action = .wall }, | ||
| 113 | else => {}, | ||
| 114 | } | ||
| 115 | continue; | ||
| 116 | } | ||
| 117 | if (b == detach_key) { | ||
| 118 | self.pending = true; | ||
| 119 | continue; | ||
| 120 | } | ||
| 121 | buf[kept] = b; | ||
| 122 | kept += 1; | ||
| 123 | } | ||
| 124 | return .{ .forward = buf[0..kept], .action = .none }; | ||
| 125 | } | ||
| 126 | }; | ||
| 127 | |||
| 128 | /// One read of the session's stdin. The mouse filter's scratch is sized | ||
| 129 | /// from it, so they are one constant. | ||
| 130 | pub const stdin_chunk = 16 * 1024; | ||
| 131 | |||
| 132 | /// How many rows one wheel notch moves the scrollback view. Three is what | ||
| 133 | /// every terminal's own scrollback does per notch, so it is what a user's | ||
| 134 | /// hand already expects; a page per notch (the granularity the scroll KEYS | ||
| 135 | /// use) overshoots so far that finding a line means hunting for it. | ||
| 136 | pub const wheel_rows: u32 = 3; | ||
| 137 | |||
| 138 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones | ||
| 139 | /// into scrollback movement. Only runs while no application in the session | ||
| 140 | /// has asked for the mouse — when one has, its bytes are its own and this | ||
| 141 | /// filter is bypassed entirely (and reset, so a report split across that | ||
| 142 | /// transition cannot be half-eaten). | ||
| 143 | /// | ||
| 144 | /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is | ||
| 145 | /// the only form the client ever asks its terminal for (`client_mouse_setup`). | ||
| 146 | /// | ||
| 147 | /// What it deliberately does NOT do is hold a bare `ESC` or `ESC [` across | ||
| 148 | /// a read boundary waiting to see whether a mouse report follows. That | ||
| 149 | /// would be the complete parse, and it would cost the one thing a | ||
| 150 | /// multiplexer must never delay: a bare Escape typed in vim would sit here | ||
| 151 | /// until the next keystroke. So the hold starts at `ESC [ <` — three bytes | ||
| 152 | /// no keyboard produces — and a report split inside those three bytes | ||
| 153 | /// leaks through as input. A terminal writes a report with one write, and | ||
| 154 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical | ||
| 155 | /// one; a delayed Escape would be an every-session one. | ||
| 156 | pub const MouseFilter = struct { | ||
| 157 | /// Longest report worth holding: `ESC [ <` plus three parameters. A | ||
| 158 | /// candidate that outgrows it was never a mouse report. | ||
| 159 | pub const max_held = 24; | ||
| 160 | |||
| 161 | const Out = struct { | ||
| 162 | /// The bytes that were not mouse reports, in order. | ||
| 163 | forward: []const u8, | ||
| 164 | /// Net wheel notches: positive is up, into history. | ||
| 165 | wheel: i32, | ||
| 166 | }; | ||
| 167 | |||
| 168 | held: [max_held]u8 = undefined, | ||
| 169 | len: usize = 0, | ||
| 170 | |||
| 171 | pub fn reset(self: *MouseFilter) void { | ||
| 172 | self.len = 0; | ||
| 173 | } | ||
| 174 | |||
| 175 | /// Filter one raw stdin chunk into `out`, which must have room for | ||
| 176 | /// `in.len + max_held` — a candidate held from the previous read is | ||
| 177 | /// handed back ahead of this chunk's bytes when it turns out not to | ||
| 178 | /// have been a report after all. | ||
| 179 | pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { | ||
| 180 | var kept: usize = 0; | ||
| 181 | var wheel: i32 = 0; | ||
| 182 | for (in) |b| { | ||
| 183 | if (self.len > 0) { | ||
| 184 | if (b == 'M' or b == 'm') { | ||
| 185 | self.held[self.len] = b; | ||
| 186 | wheel += wheelNotches(self.held[0 .. self.len + 1]); | ||
| 187 | self.len = 0; | ||
| 188 | continue; | ||
| 189 | } | ||
| 190 | if ((std.ascii.isDigit(b) or b == ';') and self.len + 1 < max_held) { | ||
| 191 | self.held[self.len] = b; | ||
| 192 | self.len += 1; | ||
| 193 | continue; | ||
| 194 | } | ||
| 195 | // Not a report: give the held bytes back in the order they | ||
| 196 | // were typed, then let `b` take its chances below — it may | ||
| 197 | // be the ESC of the next candidate. | ||
| 198 | @memcpy(out[kept..][0..self.len], self.held[0..self.len]); | ||
| 199 | kept += self.len; | ||
| 200 | self.len = 0; | ||
| 201 | } | ||
| 202 | out[kept] = b; | ||
| 203 | kept += 1; | ||
| 204 | if (kept >= 3 and std.mem.eql(u8, out[kept - 3 .. kept], "\x1b[<")) { | ||
| 205 | kept -= 3; | ||
| 206 | @memcpy(self.held[0..3], "\x1b[<"); | ||
| 207 | self.len = 3; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | return .{ .forward = out[0..kept], .wheel = wheel }; | ||
| 211 | } | ||
| 212 | |||
| 213 | /// The wheel movement one complete SGR report means, or 0 for anything | ||
| 214 | /// else — a click, a drag, a release, a horizontal wheel, a button this | ||
| 215 | /// terminal invented. Discarding those is the point: with no | ||
| 216 | /// application asking for the mouse there is nobody to send them to, | ||
| 217 | /// and forwarding them would type `[<0;40;12M` into the user's shell. | ||
| 218 | fn wheelNotches(seq: []const u8) i32 { | ||
| 219 | // Wheel events are presses; a release cannot be one. | ||
| 220 | if (seq[seq.len - 1] != 'M') return 0; | ||
| 221 | var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); | ||
| 222 | const button = std.fmt.parseInt(u16, it.first(), 10) catch return 0; | ||
| 223 | // Bit 6 marks the wheel buttons, bit 5 marks motion (a drag with | ||
| 224 | // the wheel held is not a scroll). The low two bits pick which of | ||
| 225 | // the four: 0/1 are vertical, 2/3 horizontal and unhandled. The | ||
| 226 | // modifier bits (shift/meta/ctrl, 4/8/16) are ignored on purpose — | ||
| 227 | // Ctrl+wheel is a zoom nobody here implements, so it scrolls. | ||
| 228 | if (button & 0x40 == 0 or button & 0x20 != 0) return 0; | ||
| 229 | return switch (button & 0x03) { | ||
| 230 | 0 => 1, | ||
| 231 | 1 => -1, | ||
| 232 | else => 0, | ||
| 233 | }; | ||
| 234 | } | ||
| 235 | }; | ||
| 236 | |||
| 237 | pub var winch_flag = std.atomic.Value(bool).init(false); | ||
| 238 | |||
| 239 | pub fn onWinch(_: c_int) callconv(.c) void { | ||
| 240 | winch_flag.store(true, .release); | ||
| 241 | } | ||
| 242 | |||
| 243 | pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { | ||
| 244 | if (!std.posix.isatty(fd)) return null; | ||
| 245 | var ws: std.posix.winsize = undefined; | ||
| 246 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | ||
| 247 | // A pty can report 0x0 (e.g. `script` with piped stdin); a zero-sized | ||
| 248 | // grid is invalid for the engine. Treat it as "unknown". | ||
| 249 | if (ws.col < 2 or ws.row < 2) return null; | ||
| 250 | return .{ .cols = ws.col, .rows = ws.row }; | ||
| 251 | } | ||
| 252 | |||
| 253 | // ---- side channels ----------------------------------------------------- | ||
| 254 | // | ||
| 255 | // Everything below turns a typed semantic value into bytes for the host | ||
| 256 | // terminal. Untrusted wire validation belongs to client_core; these adapters | ||
| 257 | // only perform the native platform operation selected by that shared core. | ||
| 258 | |||
| 259 | /// Everything the client does TO the host terminal on the way in, in the | ||
| 260 | /// order it does it: push the title, enter the alternate screen, hide the | ||
| 261 | /// cursor, disable autowrap. Named rather than inline because it is one | ||
| 262 | /// half of a pair — `terminal_teardown` undoes each of these, and the pair | ||
| 263 | /// is pinned together in one test so neither half can drift alone. | ||
| 264 | /// | ||
| 265 | /// Written exactly once per process, under the same `alt_screen` gate that | ||
| 266 | /// admits the teardown; the argument for that gate is at the call site. | ||
| 267 | /// | ||
| 268 | /// The mouse enables are last and they are the client's OWN: with no mouse | ||
| 269 | /// reporting on, a host terminal answers the wheel by synthesising arrow | ||
| 270 | /// keys on the alternate screen (DEC 1007, "alternate scroll"), which land | ||
| 271 | /// in the session as input and move the shell's history instead of the | ||
| 272 | /// view. Asking for real wheel events is what makes the wheel scrollable at | ||
| 273 | /// all, and it turns 1007's synthesis off as a side effect. The session's | ||
| 274 | /// own modes arrive moments later in the first `term_modes` and level-set | ||
| 275 | /// these; this is what the wheel does until they do. | ||
| 276 | pub const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup; | ||
| 277 | |||
| 278 | /// The mouse modes the client asks its own terminal for when no application | ||
| 279 | /// in the session wants them: button presses (1000) reported in SGR (1006). | ||
| 280 | /// 1000 rather than 1002/1003 because the wheel is all we consume — motion | ||
| 281 | /// and drag reports would be bytes read and thrown away thousands of times | ||
| 282 | /// a session. | ||
| 283 | /// | ||
| 284 | /// The list, not the escape, is the fact: `client_mouse_setup` writes it at | ||
| 285 | /// startup and `appendMouseModes` level-sets the same modes on every sample, | ||
| 286 | /// so a mode spelled in one place and not the other would be one the client | ||
| 287 | /// turns on and then immediately turns off. | ||
| 288 | const client_mouse_capture = [_]u16{ 1000, 1006 }; | ||
| 289 | |||
| 290 | const client_mouse_setup = blk: { | ||
| 291 | var s: []const u8 = ""; | ||
| 292 | for (client_mouse_capture) |dec| s = s ++ std.fmt.comptimePrint("\x1b[?{d}h", .{dec}); | ||
| 293 | break :blk s; | ||
| 294 | }; | ||
| 295 | |||
| 296 | /// Whether `dec` is one of the modes the client asks for on its own behalf. | ||
| 297 | fn inClientCapture(comptime dec: u16) bool { | ||
| 298 | for (client_mouse_capture) |c| { | ||
| 299 | if (c == dec) return true; | ||
| 300 | } | ||
| 301 | return false; | ||
| 302 | } | ||
| 303 | |||
| 304 | /// Everything the client must undo on its way out, in one literal. mux | ||
| 305 | /// turns these on; leaving any of them set hands the user a terminal that | ||
| 306 | /// behaves oddly long after mux exited, with nothing on screen to explain | ||
| 307 | /// it. `?2004l` leads because it is the one a session asked for rather | ||
| 308 | /// than one the client needed for itself. | ||
| 309 | /// | ||
| 310 | /// Note what the `?2004l` assumes: it restores 2004 to the terminal's | ||
| 311 | /// power-on default rather than to whatever the outer program had, because | ||
| 312 | /// mux never asked the host what it had. That is correct rather than merely | ||
| 313 | /// tolerable, and by observation rather than by hope — a zsh running under | ||
| 314 | /// a pty writes `?2004h`, `?2004l`, `?2004h`, `?2004l`: it arms bracketed | ||
| 315 | /// paste when zle starts reading and DISARMS it before running each | ||
| 316 | /// command. readline does the same. So for the whole time mux runs as a | ||
| 317 | /// child of the shell that launched it, host 2004 is already off, and off | ||
| 318 | /// is exactly what we put back. A host that armed 2004 and then ran a child | ||
| 319 | /// without disarming would be restored wrongly — no shell in use here does. | ||
| 320 | /// | ||
| 321 | /// The title pop (`23;0t`) is the one entry here that restores the user's | ||
| 322 | /// OWN value rather than a power-on default — the terminal kept it on its | ||
| 323 | /// stack, because mux cannot read a title back to restore it by hand. | ||
| 324 | /// | ||
| 325 | /// It sits SECOND TO LAST, and that placement is load-bearing even though | ||
| 326 | /// the title stack and the alternate screen have nothing to do with each | ||
| 327 | /// other. `?1049l` must remain the final bytes a tty client writes: the | ||
| 328 | /// e2e doctored control for the pty capture (test/e2e.sh, tp1) appends | ||
| 329 | /// bytes after the capture's trailing alt-screen exit, and `render` replays | ||
| 330 | /// only up to the LAST one — so a teardown that stops ending there turns | ||
| 331 | /// that control into a no-op that can never fail. Measured, not guessed: | ||
| 332 | /// appending the pop after `?1049l` is what made that check fire. | ||
| 333 | /// | ||
| 334 | /// It is here because the question was ANSWERED, not assumed: the operator | ||
| 335 | /// ran the push/set/pop probe in a bare Alacritty window on 2026-08-15 and | ||
| 336 | /// the title returned (commit 217183c, and the design note it edits). A | ||
| 337 | /// terminal without the stack ignores both halves, which costs a title bar | ||
| 338 | /// left showing what the session set — the tmux behaviour, and the | ||
| 339 | /// fallback this would otherwise have shipped as. | ||
| 340 | pub const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; | ||
| 341 | |||
| 342 | /// Every mouse mode this client can ever have turned on, off. Built from | ||
| 343 | /// the wire table rather than typed out, because the set it has to undo is | ||
| 344 | /// exactly the set the daemon can ask it to mirror — a mode added there and | ||
| 345 | /// forgotten here is a terminal left reporting clicks into the user's shell | ||
| 346 | /// as escape sequences, long after mux exited. | ||
| 347 | const mouse_teardown = blk: { | ||
| 348 | var s: []const u8 = ""; | ||
| 349 | for (proto.mouse_modes) |m| s = s ++ std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}); | ||
| 350 | break :blk s; | ||
| 351 | }; | ||
| 352 | |||
| 353 | /// Render validated terminal state as the DECSET/DECRST writes it implies. | ||
| 354 | pub fn appendTermState( | ||
| 355 | out: *std.ArrayList(u8), | ||
| 356 | alloc: std.mem.Allocator, | ||
| 357 | state: client_core.State, | ||
| 358 | ) !void { | ||
| 359 | switch (state) { | ||
| 360 | .terminal_modes => |modes| { | ||
| 361 | try out.appendSlice( | ||
| 362 | alloc, | ||
| 363 | if (modes.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l", | ||
| 364 | ); | ||
| 365 | try appendMouseModes(out, alloc, modes); | ||
| 366 | }, | ||
| 367 | } | ||
| 368 | } | ||
| 369 | |||
| 370 | /// Who the wheel belongs to, written as the modes this terminal is asked | ||
| 371 | /// for. An application that asked for the mouse gets EXACTLY the modes it | ||
| 372 | /// asked for and every mouse byte verbatim (see `MouseFilter`'s call site); | ||
| 373 | /// otherwise the client keeps its own capture set and spends the wheel on | ||
| 374 | /// scrollback. | ||
| 375 | /// | ||
| 376 | /// A full level-set of all eight modes every time, not a diff: `term_modes` | ||
| 377 | /// is sampled state that repeats on every attach and reconnect, and the | ||
| 378 | /// terminal on the other end may be one this process never configured (a | ||
| 379 | /// reconnect, a `--via` that reconnected under us). Level-setting is | ||
| 380 | /// idempotent, so the repeats cost bytes and nothing else. | ||
| 381 | fn appendMouseModes( | ||
| 382 | out: *std.ArrayList(u8), | ||
| 383 | alloc: std.mem.Allocator, | ||
| 384 | modes: proto.TermModes, | ||
| 385 | ) !void { | ||
| 386 | const app = modes.appMouse(); | ||
| 387 | inline for (proto.mouse_modes) |m| { | ||
| 388 | // The client's own capture set comes from the one list that | ||
| 389 | // `client_mouse_setup` is built from, so the startup write and this | ||
| 390 | // level-set cannot disagree about what "ours" is. | ||
| 391 | const on = if (app) @field(modes, m.field) else comptime inClientCapture(m.dec); | ||
| 392 | try out.appendSlice(alloc, if (on) | ||
| 393 | comptime std.fmt.comptimePrint("\x1b[?{d}h", .{m.dec}) | ||
| 394 | else | ||
| 395 | comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec})); | ||
| 396 | } | ||
| 397 | } | ||
| 398 | |||
| 399 | /// Render one host effect onto the bytes destined for the terminal. | ||
| 400 | /// | ||
| 401 | /// The target and alphabet are re-checked here rather than trusted from the | ||
| 402 | /// effect. `ClipboardSet` is a plain struct, so Zig cannot make the | ||
| 403 | /// validating decoder its only constructor — and one caller already builds | ||
| 404 | /// an unvalidated one: `wasm_core.zig` default-initialises its borrowed | ||
| 405 | /// clipboard slot to `.{ .target = 0, .base64 = &.{} }`, which | ||
| 406 | /// `validClipboard` refuses. A linear scan over at most 64 KiB is free next | ||
| 407 | /// to the write it guards, and the alternative is `ESC]52;<NUL>;BEL` on a | ||
| 408 | /// real tty. | ||
| 409 | /// | ||
| 410 | /// Built whole before the first byte is appended, like every other builder | ||
| 411 | /// here: a rejection must not leave half an escape behind for a caller that | ||
| 412 | /// reuses one buffer across events. | ||
| 413 | pub fn appendHostEffect( | ||
| 414 | out: *std.ArrayList(u8), | ||
| 415 | alloc: std.mem.Allocator, | ||
| 416 | effect: client_core.Effect, | ||
| 417 | ) !void { | ||
| 418 | switch (effect) { | ||
| 419 | .clipboard_set => |clip| { | ||
| 420 | if (!client_core.validClipboard(clip.target, clip.base64)) return; | ||
| 421 | try out.appendSlice(alloc, "\x1b]52;"); | ||
| 422 | try out.append(alloc, clip.target); | ||
| 423 | try out.append(alloc, ';'); | ||
| 424 | try out.appendSlice(alloc, clip.base64); | ||
| 425 | try out.append(alloc, 0x07); | ||
| 426 | }, | ||
| 427 | .bell => try out.append(alloc, 0x07), | ||
| 428 | } | ||
| 429 | } | ||
| 430 | |||
| 431 | /// Render a term_title frame as the OSC 0 write it implies. | ||
| 432 | /// | ||
| 433 | /// Refuses any byte below 0x20 or the DEL at 0x7f. Such a byte terminates | ||
| 434 | /// the OSC early — BEL is the terminator itself, ESC begins the other one — | ||
| 435 | /// and everything after it lands on the user's screen as text they then | ||
| 436 | /// have to clear. Same reasoning as the base64 alphabet check on the | ||
| 437 | /// clipboard path, and the same all-or-nothing shape: nothing is appended | ||
| 438 | /// until every check has passed. | ||
| 439 | /// | ||
| 440 | /// Refuses an empty title too, which is not a parse question but the client | ||
| 441 | /// half of the daemon's policy (`sampleTermTitle`): `ESC]0;BEL` CLEARS the | ||
| 442 | /// host terminal's title, and mux will not do that to a title it never set. | ||
| 443 | /// Checked here as well as there because the peer is not necessarily this | ||
| 444 | /// version of muxd. | ||
| 445 | /// | ||
| 446 | /// OSC 0 rather than OSC 2, so the icon name moves with the title: that is | ||
| 447 | /// what the session's own applications write (both forms reach the engine | ||
| 448 | /// as one window-title operation), and mirroring it is the point. | ||
| 449 | /// | ||
| 450 | /// Restoring the user's original title on exit is NOT this function's job | ||
| 451 | /// and is not left undone: the terminal's own title stack carries it, via | ||
| 452 | /// the `22;0t` that leads the alt-screen entry and the `23;0t` that closes | ||
| 453 | /// `terminal_teardown`. See that constant for the observation that settled | ||
| 454 | /// it. Nothing here needs to remember the old title, which is just as well | ||
| 455 | /// — mux cannot read one back, and the engine cannot help either: ghostty's | ||
| 456 | /// terminal handler ignores title_push/title_pop outright, so the SESSION's | ||
| 457 | /// title stack does not exist to be mirrored. | ||
| 458 | pub fn appendTermTitle( | ||
| 459 | out: *std.ArrayList(u8), | ||
| 460 | alloc: std.mem.Allocator, | ||
| 461 | payload: []const u8, | ||
| 462 | ) !void { | ||
| 463 | if (payload.len == 0 or payload.len > proto.term_title_max) return; | ||
| 464 | for (payload) |b| if (b < 0x20 or b == 0x7f) return; | ||
| 465 | try out.appendSlice(alloc, "\x1b]0;"); | ||
| 466 | try out.appendSlice(alloc, payload); | ||
| 467 | try out.append(alloc, 0x07); | ||
| 468 | } | ||
| 469 | |||
| 470 | /// Write one side channel's rendering of a frame to the host terminal. | ||
| 471 | /// | ||
| 472 | /// Outside the paint's synchronized-update bracket: these are messages TO | ||
| 473 | /// the terminal, not part of the picture, and a sync bracket around one | ||
| 474 | /// would hold it until the next frame. Nothing is written when the builder | ||
| 475 | /// produced nothing — every builder here is all-or-nothing, so an empty | ||
| 476 | /// buffer is a refusal, and half an escape sequence on a real tty paints | ||
| 477 | /// garbage the user has to clear. | ||
| 478 | /// | ||
| 479 | /// `owns_terminal` is the caller's `alt_screen`, and it gates every channel | ||
| 480 | /// rather than any one of them. mux writes a side channel only once it has | ||
| 481 | /// taken the terminal over, because taking it over is also what arms the | ||
| 482 | /// teardown that puts it back: the title pop, and the `?2004l` for a | ||
| 483 | /// session that asked for bracketed paste and died without unasking. | ||
| 484 | /// | ||
| 485 | /// The hole this closes was the title's to find. `is_tty` is `isatty` of | ||
| 486 | /// STDIN — it gates raw mode and the alt-screen entry, both of which are | ||
| 487 | /// about input — while these writes go to STDOUT. With stdin redirected | ||
| 488 | /// and stdout still a terminal (`echo x | mux`, `mux < /dev/null` typed at | ||
| 489 | /// a prompt) `alt_screen` never becomes true, so mux would set the user's | ||
| 490 | /// title and never pop it, and turn bracketed paste on and never turn it | ||
| 491 | /// off. Every other side channel had the same shape; only the title made | ||
| 492 | /// it a broken promise, because the title is the one mux justified by | ||
| 493 | /// saying it could put things back. | ||
| 494 | /// | ||
| 495 | /// The cost, accepted deliberately: in that mode the session's title, | ||
| 496 | /// clipboard and bell go nowhere, even though a terminal is attached to | ||
| 497 | /// stdout and would have shown them. That matches what mux already does | ||
| 498 | /// there — no raw mode, no alternate screen, no hidden cursor — and the | ||
| 499 | /// alternative is a client that changes terminal state it has arranged no | ||
| 500 | /// way to change back. Gating here rather than at the three call sites so | ||
| 501 | /// a fourth channel cannot arrive without it. | ||
| 502 | /// | ||
| 503 | /// `append` is a DECLARED function type rather than `anytype`, because the | ||
| 504 | /// declaration is the specification of a side-channel builder: an output | ||
| 505 | /// buffer, an allocator, one value of the type it renders — and allocation | ||
| 506 | /// as the only way it may fail. Everything else it refuses, it refuses by | ||
| 507 | /// writing nothing, which is what makes "empty buffer means refusal" above | ||
| 508 | /// a rule rather than a hope. `anytype` accepts a builder that fails some | ||
| 509 | /// other way, and that error propagates out of `session()` and ends the | ||
| 510 | /// client: not something a stray clipboard byte gets to do. `Value` is | ||
| 511 | /// comptime for the same reason — it names the contract, and it lets each | ||
| 512 | /// caller's value coerce to the type its builder actually declares. | ||
| 513 | pub fn writeSideChannel( | ||
| 514 | alloc: std.mem.Allocator, | ||
| 515 | stdout_fd: std.posix.fd_t, | ||
| 516 | owns_terminal: bool, | ||
| 517 | comptime Value: type, | ||
| 518 | value: Value, | ||
| 519 | comptime append: fn (*std.ArrayList(u8), std.mem.Allocator, Value) std.mem.Allocator.Error!void, | ||
| 520 | ) !void { | ||
| 521 | if (!owns_terminal) return; | ||
| 522 | var esc: std.ArrayList(u8) = .empty; | ||
| 523 | defer esc.deinit(alloc); | ||
| 524 | try append(&esc, alloc, value); | ||
| 525 | if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items); | ||
| 526 | } | ||
| 527 | |||
| 528 | // ---- prediction -------------------------------------------------------- | ||
| 529 | // | ||
| 530 | // The overlay is a display decision and nothing else. It never writes to | ||
| 531 | // the replica, so the replica keeps meaning exactly "what the daemon said" | ||
| 532 | // and stays comparable to `muxd dump` at every instant. Everything below | ||
| 533 | // either reads the replica or paints on top of it. | ||
| 534 | |||
| 535 | /// What the replica shows at one cell — the `prev_ch` a prediction is | ||
| 536 | /// judged against later. | ||
| 537 | /// | ||
| 538 | /// Read at the PREDICTED cursor, not the replica's own: mid-burst those are | ||
| 539 | /// different cells, and reading the wrong one hands reconcile a `prev_ch` | ||
| 540 | /// that belongs to somebody else's cell, which turns "the frame has not | ||
| 541 | /// answered yet" into "we were contradicted" and flushes the queue. That is | ||
| 542 | /// the failure the real-Engine test below exists to catch. | ||
| 543 | fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { | ||
| 544 | const plain = replica.dumpPlain(alloc) catch return ' '; | ||
| 545 | defer alloc.free(plain); | ||
| 546 | const grid: predict.PlainGrid = .{ .text = plain, .cols = @intCast(replica.term.cols) }; | ||
| 547 | return grid.cellChar(at.y, at.x) orelse ' '; | ||
| 548 | } | ||
| 549 | |||
| 550 | /// Judge the overlay against the replica as it now stands. Call after the | ||
| 551 | /// replica has taken the frame, never before: the whole question is what | ||
| 552 | /// the authoritative state says now. | ||
| 553 | /// | ||
| 554 | /// Public, with `paintOverlay` and `offerKeystroke`, for the CLI wall's | ||
| 555 | /// zoomed tile (wallview.zig): typing at a zoomed tile is typing at a | ||
| 556 | /// session, and it gets the same speculation the client gives its own. | ||
| 557 | /// Shared rather than reimplemented — the overlay is the one place the | ||
| 558 | /// "never enters the replica" rule is kept, and a second implementation is | ||
| 559 | /// a second place to break it. | ||
| 560 | pub fn reconcileOverlay( | ||
| 561 | alloc: std.mem.Allocator, | ||
| 562 | overlay: *predict.Overlay, | ||
| 563 | replica: *Engine, | ||
| 564 | seq: u64, | ||
| 565 | now_ms: i64, | ||
| 566 | ) predict.Verdict { | ||
| 567 | const plain = replica.dumpPlain(alloc) catch return .none; | ||
| 568 | defer alloc.free(plain); | ||
| 569 | return overlay.reconcile( | ||
| 570 | predict.PlainGrid{ .text = plain, .cols = @intCast(replica.term.cols) }, | ||
| 571 | seq, | ||
| 572 | now_ms, | ||
| 573 | ); | ||
| 574 | } | ||
| 575 | |||
| 576 | /// Paint every pending prediction on top of whatever is on screen, and | ||
| 577 | /// leave the cursor where the typist believes it is. | ||
| 578 | /// | ||
| 579 | /// Idempotent and called after every authoritative paint as well as on each | ||
| 580 | /// keystroke, because a delta repaints whole rows: the row content the | ||
| 581 | /// daemon sent would otherwise wipe an underlined glyph whose prediction is | ||
| 582 | /// still outstanding, and the burst would flicker away one frame after it | ||
| 583 | /// was drawn. | ||
| 584 | pub fn paintOverlay( | ||
| 585 | alloc: std.mem.Allocator, | ||
| 586 | overlay: *predict.Overlay, | ||
| 587 | base: Engine.CursorPos, | ||
| 588 | tty: proto.Size, | ||
| 589 | out_fd: std.posix.fd_t, | ||
| 590 | ) void { | ||
| 591 | if (!overlay.confident or overlay.pendingCount() == 0) return; | ||
| 592 | var paint: std.ArrayList(u8) = .empty; | ||
| 593 | defer paint.deinit(alloc); | ||
| 594 | paint.appendSlice(alloc, paint_mod.sync_begin) catch return; | ||
| 595 | |||
| 596 | var i: usize = 0; | ||
| 597 | while (i < overlay.pendingCount()) : (i += 1) { | ||
| 598 | const cell = overlay.pendingAt(i).cell; | ||
| 599 | if (cell.row >= tty.rows or cell.col >= tty.cols) continue; | ||
| 600 | var b: [32]u8 = undefined; | ||
| 601 | // Underlined, so a prediction is visibly a prediction until the | ||
| 602 | // daemon's own row content replaces it. | ||
| 603 | const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{ | ||
| 604 | cell.row + 1, | ||
| 605 | cell.col + 1, | ||
| 606 | cell.ch, | ||
| 607 | }) catch continue; | ||
| 608 | paint.appendSlice(alloc, s) catch return; | ||
| 609 | // Counted here rather than at prediction time, because this is | ||
| 610 | // where a cell actually reaches the screen — including one queued | ||
| 611 | // while unconfident that a promotion has since made visible. The | ||
| 612 | // overlay counts it once however often this redraws it. | ||
| 613 | overlay.markPainted(i); | ||
| 614 | } | ||
| 615 | |||
| 616 | const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); | ||
| 617 | const cur = paint_mod.clampCursor(.{ .x = pc.x, .y = pc.y }, tty); | ||
| 618 | var cbuf: [32]u8 = undefined; | ||
| 619 | const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{ | ||
| 620 | cur.y + 1, | ||
| 621 | cur.x + 1, | ||
| 622 | }) catch return; | ||
| 623 | paint.appendSlice(alloc, tail) catch return; | ||
| 624 | proto.writeAllFd(out_fd, paint.items) catch {}; | ||
| 625 | } | ||
| 626 | |||
| 627 | /// Offer one chunk of typed bytes to the overlay. The chunk goes to the | ||
| 628 | /// daemon unchanged whatever happens here — prediction never alters what | ||
| 629 | /// the shell receives, only what the screen shows before it answers. | ||
| 630 | pub fn offerKeystroke( | ||
| 631 | alloc: std.mem.Allocator, | ||
| 632 | overlay: *predict.Overlay, | ||
| 633 | replica: *Engine, | ||
| 634 | chunk: []const u8, | ||
| 635 | tty: proto.Size, | ||
| 636 | out_fd: std.posix.fd_t, | ||
| 637 | ) void { | ||
| 638 | if (chunk.len != 1) { | ||
| 639 | // An escape sequence, a multi-byte character, or a paste. None is | ||
| 640 | // one cell's worth of change and M9 speculates about none of them. | ||
| 641 | // The decision is made here, so the count is recorded here — a | ||
| 642 | // paste's lead byte is printable, so handing it to predictAt would | ||
| 643 | // predict the paste's first character instead of refusing it. | ||
| 644 | overlay.recordSuppressed(); | ||
| 645 | return; | ||
| 646 | } | ||
| 647 | |||
| 648 | const base = replica.cursorPos(); | ||
| 649 | const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y }); | ||
| 650 | const out = overlay.predictAt(.{ | ||
| 651 | .cursor = at, | ||
| 652 | .ch = chunk[0], | ||
| 653 | .prev_ch = replicaCellChar(alloc, replica, at), | ||
| 654 | .now_ms = std.time.milliTimestamp(), | ||
| 655 | }); | ||
| 656 | switch (out) { | ||
| 657 | .display => paintOverlay(alloc, overlay, base, tty, out_fd), | ||
| 658 | // Queued but unearned, or refused outright: either way nothing is | ||
| 659 | // drawn, which is the entire safety property. | ||
| 660 | .hidden, .suppressed => {}, | ||
| 661 | } | ||
| 662 | } | ||
| 663 | |||
| 664 | /// The one machine-readable line `MUX_PREDICT_STATS=1` produces. A pure | ||
| 665 | /// function so the format the e2e greps for is pinned by a test rather than | ||
| 666 | /// by whatever the process happened to print. | ||
| 667 | fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 { | ||
| 668 | return std.fmt.bufPrint( | ||
| 669 | buf, | ||
| 670 | "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++ | ||
| 671 | " expired={d} abandoned={d} suppressed={d}", | ||
| 672 | .{ | ||
| 673 | c.made, c.displayed, c.confirmed, c.contradicted, | ||
| 674 | c.expired, c.abandoned, c.suppressed, | ||
| 675 | }, | ||
| 676 | ); | ||
| 677 | } | ||
| 678 | |||
| 679 | pub const predict_stats_len = 192; | ||
| 680 | |||
| 681 | pub fn dumpPredictStats(c: predict.Counters) void { | ||
| 682 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; | ||
| 683 | if (!std.mem.eql(u8, want, "1")) return; | ||
| 684 | var buf: [predict_stats_len]u8 = undefined; | ||
| 685 | const line = formatPredictStats(&buf, c) catch return; | ||
| 686 | std.debug.print("{s}\n", .{line}); | ||
| 687 | } | ||
| 688 | |||
| 689 | /// Turn wheel notches into the arrow keys an alt-screen application reads, | ||
| 690 | /// `wheel_rows` of them per notch so the wheel moves the same distance | ||
| 691 | /// whichever screen is up. | ||
| 692 | /// | ||
| 693 | /// Sent as input rather than predicted: `offerKeystroke` refuses anything | ||
| 694 | /// that is not a single byte anyway, and a guess painted at the cursor of a | ||
| 695 | /// full-screen application is a guess about a layout the client cannot see. | ||
| 696 | /// | ||
| 697 | /// Batched, because a spin arrives as one burst and one frame per arrow | ||
| 698 | /// would put a hundred frames on the wire for one flick of a finger. The | ||
| 699 | /// loop is what bounds the buffer rather than the burst. | ||
| 700 | pub fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { | ||
| 701 | const seq = altScrollSeq(wheel, app_cursor); | ||
| 702 | var buf: [alt_scroll_batch * 3]u8 = undefined; | ||
| 703 | var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows; | ||
| 704 | while (left > 0) { | ||
| 705 | const n = @min(left, alt_scroll_batch); | ||
| 706 | for (0..n) |i| @memcpy(buf[i * 3 ..][0..3], seq); | ||
| 707 | try transport.writeFrame(.input, buf[0 .. n * 3]); | ||
| 708 | left -= n; | ||
| 709 | } | ||
| 710 | } | ||
| 711 | |||
| 712 | /// Arrows per alternate-scroll frame. Twenty-one notches' worth, which no | ||
| 713 | /// hand produces in one read; the batching exists to bound the buffer, not | ||
| 714 | /// to pace anything. | ||
| 715 | const alt_scroll_batch: u32 = 64; | ||
| 716 | |||
| 717 | /// The arrow key one notch means, in the spelling this session reads. | ||
| 718 | /// | ||
| 719 | /// DECCKM decides what an arrow key IS, and getting it wrong is silent: | ||
| 720 | /// `less` puts the cursor keys in APPLICATION mode and reads `ESC O A`, so | ||
| 721 | /// `ESC [ A` arrives as an escape it ignores and the page does not move. | ||
| 722 | /// Measured on `less +G` — the normal spelling scrolled nothing at all, and | ||
| 723 | /// every curses program sets the same mode. | ||
| 724 | /// | ||
| 725 | /// Three bytes either way, which `sendAltScroll`'s buffer relies on. | ||
| 726 | fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 { | ||
| 727 | if (app_cursor) return if (wheel > 0) "\x1bOA" else "\x1bOB"; | ||
| 728 | return if (wheel > 0) "\x1b[A" else "\x1b[B"; | ||
| 729 | } | ||
| 730 | |||
| 731 | pub fn requestScrollPage( | ||
| 732 | transport: anytype, | ||
| 733 | rep: *const Replica, | ||
| 734 | rows_up: u32, | ||
| 735 | size: proto.Size, | ||
| 736 | ) !void { | ||
| 737 | // The view is `size.rows` rows starting `rows_up` above the live | ||
| 738 | // viewport top; the row math lives with the replica's history_rows | ||
| 739 | // (replica.zig). | ||
| 740 | const start = rep.scrollStart(rows_up); | ||
| 741 | try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); | ||
| 742 | } | ||
| 743 | |||
| 744 | test "interact: a chord in one read detaches and forwards what preceded it" { | ||
| 745 | var f: PrefixFilter = .{}; | ||
| 746 | var chunk = "ab\x1cd".*; | ||
| 747 | const out = f.feed(&chunk); | ||
| 748 | try std.testing.expectEqual(PrefixFilter.Action.detach, out.action); | ||
| 749 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 750 | } | ||
| 751 | |||
| 752 | test "interact: a doubled prefix detaches" { | ||
| 753 | var f: PrefixFilter = .{}; | ||
| 754 | var chunk = "\x1c\x1c".*; | ||
| 755 | const out = f.feed(&chunk); | ||
| 756 | try std.testing.expectEqual(PrefixFilter.Action.detach, out.action); | ||
| 757 | try std.testing.expectEqualStrings("", out.forward); | ||
| 758 | } | ||
| 759 | |||
| 760 | test "interact: a chord split across two reads is still one chord" { | ||
| 761 | var f: PrefixFilter = .{}; | ||
| 762 | var first = "ab\x1c".*; | ||
| 763 | const a = f.feed(&first); | ||
| 764 | try std.testing.expectEqual(PrefixFilter.Action.none, a.action); | ||
| 765 | try std.testing.expectEqualStrings("ab", a.forward); | ||
| 766 | var second = "dz".*; | ||
| 767 | const b = f.feed(&second); | ||
| 768 | try std.testing.expectEqual(PrefixFilter.Action.detach, b.action); | ||
| 769 | try std.testing.expectEqualStrings("", b.forward); | ||
| 770 | } | ||
| 771 | |||
| 772 | test "interact: an unknown command key is swallowed with its prefix" { | ||
| 773 | var f: PrefixFilter = .{}; | ||
| 774 | var chunk = "a\x1cxb".*; | ||
| 775 | const out = f.feed(&chunk); | ||
| 776 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 777 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 778 | // Back to normal: the next `d` is an ordinary keystroke, not a command. | ||
| 779 | var after = "d".*; | ||
| 780 | const next = f.feed(&after); | ||
| 781 | try std.testing.expectEqual(PrefixFilter.Action.none, next.action); | ||
| 782 | try std.testing.expectEqualStrings("d", next.forward); | ||
| 783 | } | ||
| 784 | |||
| 785 | // `l` has no meaning in a client yet (the switch swallows it) but the TABLE | ||
| 786 | // must name it, because the wall's zoomed tile reads its chords out of this | ||
| 787 | // same table. Split across reads for the same reason every other chord is: | ||
| 788 | // a read boundary is not a chord boundary. | ||
| 789 | test "interact: Ctrl-\\ l is a chord in the table, whoever acts on it" { | ||
| 790 | var f: PrefixFilter = .{}; | ||
| 791 | var chunk = "ab\x1clcd".*; | ||
| 792 | const out = f.feed(&chunk); | ||
| 793 | try std.testing.expectEqual(PrefixFilter.Action.last_session, out.action); | ||
| 794 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 795 | |||
| 796 | var g: PrefixFilter = .{}; | ||
| 797 | var first = "x\x1c".*; | ||
| 798 | try std.testing.expectEqual(PrefixFilter.Action.none, g.feed(&first).action); | ||
| 799 | var second = "l".*; | ||
| 800 | try std.testing.expectEqual(PrefixFilter.Action.last_session, g.feed(&second).action); | ||
| 801 | } | ||
| 802 | |||
| 803 | test "interact: bytes with no prefix pass through untouched" { | ||
| 804 | var f: PrefixFilter = .{}; | ||
| 805 | var chunk = "hello\x1b[A".*; | ||
| 806 | const out = f.feed(&chunk); | ||
| 807 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 808 | try std.testing.expectEqualStrings("hello\x1b[A", out.forward); | ||
| 809 | } | ||
| 810 | |||
| 811 | test "interact: two chords in one buffer are consumed independently" { | ||
| 812 | var f: PrefixFilter = .{}; | ||
| 813 | var chunk = "\x1cxz\x1cq".*; | ||
| 814 | const out = f.feed(&chunk); | ||
| 815 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 816 | try std.testing.expectEqualStrings("z", out.forward); | ||
| 817 | var chunk2 = "\x1cx\x1cd".*; | ||
| 818 | const out2 = f.feed(&chunk2); | ||
| 819 | try std.testing.expectEqual(PrefixFilter.Action.detach, out2.action); | ||
| 820 | try std.testing.expectEqualStrings("", out2.forward); | ||
| 821 | } | ||
| 822 | |||
| 823 | test "interact: a wheel report becomes a scroll and never reaches the pty" { | ||
| 824 | var f: MouseFilter = .{}; | ||
| 825 | var out: [64]u8 = undefined; | ||
| 826 | |||
| 827 | // Button 64 press: wheel up, into history. Button 65: wheel down. | ||
| 828 | const up = f.feed("\x1b[<64;10;5M", &out); | ||
| 829 | try std.testing.expectEqual(@as(i32, 1), up.wheel); | ||
| 830 | try std.testing.expectEqualStrings("", up.forward); | ||
| 831 | |||
| 832 | const dn = f.feed("\x1b[<65;10;5M", &out); | ||
| 833 | try std.testing.expectEqual(@as(i32, -1), dn.wheel); | ||
| 834 | try std.testing.expectEqualStrings("", dn.forward); | ||
| 835 | |||
| 836 | // A terminal sends a burst when the wheel is spun; they add up rather | ||
| 837 | // than the last one winning, or a fast spin would move one notch. | ||
| 838 | const burst = f.feed("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<65;1;1M", &out); | ||
| 839 | try std.testing.expectEqual(@as(i32, 2), burst.wheel); | ||
| 840 | try std.testing.expectEqualStrings("", burst.forward); | ||
| 841 | |||
| 842 | // Ctrl+wheel is still a wheel: no zoom exists here to claim it. | ||
| 843 | const ctrl = f.feed("\x1b[<80;1;1M", &out); | ||
| 844 | try std.testing.expectEqual(@as(i32, 1), ctrl.wheel); | ||
| 845 | } | ||
| 846 | |||
| 847 | test "interact: alternate scroll spells its arrows the way the session reads them" { | ||
| 848 | // Normal cursor keys: the CSI form. Application mode (DECCKM, what | ||
| 849 | // `less` and every curses program set): the SS3 form. A client that | ||
| 850 | // sent the CSI form to an application-mode reader would scroll nothing | ||
| 851 | // and say nothing — measured on `less +G` before this existed. | ||
| 852 | try std.testing.expectEqualStrings("\x1b[A", altScrollSeq(1, false)); | ||
| 853 | try std.testing.expectEqualStrings("\x1b[B", altScrollSeq(-1, false)); | ||
| 854 | try std.testing.expectEqualStrings("\x1bOA", altScrollSeq(1, true)); | ||
| 855 | try std.testing.expectEqualStrings("\x1bOB", altScrollSeq(-1, true)); | ||
| 856 | } | ||
| 857 | |||
| 858 | test "interact: clicks, drags and releases are discarded rather than typed at the shell" { | ||
| 859 | var f: MouseFilter = .{}; | ||
| 860 | var out: [64]u8 = undefined; | ||
| 861 | |||
| 862 | // Left press, left release, drag (motion bit 32 with button 0), and the | ||
| 863 | // wheel's horizontal cousins. Nobody asked for any of them — with no | ||
| 864 | // application wanting the mouse there is nobody to send them to. | ||
| 865 | for ([_][]const u8{ | ||
| 866 | "\x1b[<0;40;12M", | ||
| 867 | "\x1b[<0;40;12m", | ||
| 868 | "\x1b[<32;41;12M", | ||
| 869 | "\x1b[<66;1;1M", | ||
| 870 | "\x1b[<67;1;1M", | ||
| 871 | "\x1b[<64;1;1m", | ||
| 872 | }) |report| { | ||
| 873 | const r = f.feed(report, &out); | ||
| 874 | try std.testing.expectEqual(@as(i32, 0), r.wheel); | ||
| 875 | try std.testing.expectEqualStrings("", r.forward); | ||
| 876 | } | ||
| 877 | } | ||
| 878 | |||
| 879 | test "interact: a wheel report split across reads is still one report" { | ||
| 880 | var f: MouseFilter = .{}; | ||
| 881 | var out: [64]u8 = undefined; | ||
| 882 | |||
| 883 | // The split is after the `ESC [ <` that starts the hold — the only | ||
| 884 | // place the filter holds, deliberately (see MouseFilter). | ||
| 885 | const a = f.feed("typed\x1b[<64;", &out); | ||
| 886 | try std.testing.expectEqual(@as(i32, 0), a.wheel); | ||
| 887 | try std.testing.expectEqualStrings("typed", a.forward); | ||
| 888 | |||
| 889 | const b = f.feed("10;5M", &out); | ||
| 890 | try std.testing.expectEqual(@as(i32, 1), b.wheel); | ||
| 891 | try std.testing.expectEqualStrings("", b.forward); | ||
| 892 | } | ||
| 893 | |||
| 894 | test "interact: keystrokes survive the mouse filter, in order and unheld" { | ||
| 895 | var f: MouseFilter = .{}; | ||
| 896 | var out: [64]u8 = undefined; | ||
| 897 | |||
| 898 | // A bare Escape is forwarded on the read it arrived in. This is the | ||
| 899 | // property the filter gives up completeness for: held, it would strand | ||
| 900 | // every Escape in vim until the next keystroke. | ||
| 901 | const esc = f.feed("\x1b", &out); | ||
| 902 | try std.testing.expectEqualStrings("\x1b", esc.forward); | ||
| 903 | |||
| 904 | // An arrow key is `ESC [ A`: it starts like a report and is not one. | ||
| 905 | const arrow = f.feed("\x1b[A", &out); | ||
| 906 | try std.testing.expectEqualStrings("\x1b[A", arrow.forward); | ||
| 907 | |||
| 908 | // Typing either side of a wheel notch keeps its order. | ||
| 909 | const mixed = f.feed("ab\x1b[<64;1;1Mcd", &out); | ||
| 910 | try std.testing.expectEqual(@as(i32, 1), mixed.wheel); | ||
| 911 | try std.testing.expectEqualStrings("abcd", mixed.forward); | ||
| 912 | |||
| 913 | // The exact chunk the scroll block's `was_live` rule is about: a notch | ||
| 914 | // and a keystroke in one read. The filter must hand back BOTH — the | ||
| 915 | // notch to move the view and the `x` to reach the pty — because a | ||
| 916 | // filter that dropped either would make that rule undecidable. | ||
| 917 | const both = f.feed("\x1b[<64;1;1Mx", &out); | ||
| 918 | try std.testing.expectEqual(@as(i32, 1), both.wheel); | ||
| 919 | try std.testing.expectEqualStrings("x", both.forward); | ||
| 920 | } | ||
| 921 | |||
| 922 | test "interact: a candidate that turns out not to be a report is given back whole" { | ||
| 923 | var f: MouseFilter = .{}; | ||
| 924 | var out: [64]u8 = undefined; | ||
| 925 | |||
| 926 | // `ESC [ <` with a letter behind it is not a mouse report — nothing may | ||
| 927 | // be swallowed on the strength of a guess. | ||
| 928 | const broken = f.feed("\x1b[<12x", &out); | ||
| 929 | try std.testing.expectEqual(@as(i32, 0), broken.wheel); | ||
| 930 | try std.testing.expectEqualStrings("\x1b[<12x", broken.forward); | ||
| 931 | |||
| 932 | // Held bytes from a previous read come back ahead of this read's, in | ||
| 933 | // the order they were typed. | ||
| 934 | const held = f.feed("\x1b[<9", &out); | ||
| 935 | try std.testing.expectEqualStrings("", held.forward); | ||
| 936 | const rest = f.feed("q", &out); | ||
| 937 | try std.testing.expectEqualStrings("\x1b[<9q", rest.forward); | ||
| 938 | |||
| 939 | // A candidate longer than any real report is abandoned, not held for | ||
| 940 | // ever. | ||
| 941 | var long: [MouseFilter.max_held * 2]u8 = undefined; | ||
| 942 | @memcpy(long[0..3], "\x1b[<"); | ||
| 943 | @memset(long[3..], '1'); | ||
| 944 | var big_out: [long.len + MouseFilter.max_held]u8 = undefined; | ||
| 945 | const over = f.feed(&long, &big_out); | ||
| 946 | try std.testing.expectEqual(@as(i32, 0), over.wheel); | ||
| 947 | try std.testing.expectEqualStrings(&long, over.forward); | ||
| 948 | } | ||
| 949 | |||
| 950 | test "interact: resetting the filter drops a half-read report" { | ||
| 951 | var f: MouseFilter = .{}; | ||
| 952 | var out: [64]u8 = undefined; | ||
| 953 | |||
| 954 | // What the handover to a mouse-hungry application does: the rest of the | ||
| 955 | // report belongs to the application, so the head of it must not be | ||
| 956 | // pushed back into its input. | ||
| 957 | _ = f.feed("\x1b[<64;", &out); | ||
| 958 | f.reset(); | ||
| 959 | const after = f.feed("hi", &out); | ||
| 960 | try std.testing.expectEqualStrings("hi", after.forward); | ||
| 961 | } | ||
| 962 | |||
| 963 | test "interact: Ctrl-\\ c asks for a new session and ends the chunk" { | ||
| 964 | var f: PrefixFilter = .{}; | ||
| 965 | var chunk = "ab\x1ccz".*; | ||
| 966 | const out = f.feed(&chunk); | ||
| 967 | try std.testing.expectEqual(PrefixFilter.Action.new_session, out.action); | ||
| 968 | // "ab" was typed at the session we are leaving and has already been | ||
| 969 | // sent; the "z" behind the chord was typed at it too and is dropped, | ||
| 970 | // because the only sessions left to put it in are the wrong ones. | ||
| 971 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 972 | } | ||
| 973 | |||
| 974 | test "interact: Ctrl-\\ n and Ctrl-\\ p step the session ring" { | ||
| 975 | var f: PrefixFilter = .{}; | ||
| 976 | var fwd = "ab\x1cnz".*; | ||
| 977 | const n = f.feed(&fwd); | ||
| 978 | try std.testing.expectEqual(PrefixFilter.Action.next_session, n.action); | ||
| 979 | // Same reason `c` drops its tail: "z" was typed at the session we are | ||
| 980 | // leaving, and the only sessions left to put it in are the wrong ones. | ||
| 981 | try std.testing.expectEqualStrings("ab", n.forward); | ||
| 982 | var back = "\x1cp".*; | ||
| 983 | const p = f.feed(&back); | ||
| 984 | try std.testing.expectEqual(PrefixFilter.Action.prev_session, p.action); | ||
| 985 | try std.testing.expectEqualStrings("", p.forward); | ||
| 986 | } | ||
| 987 | |||
| 988 | test "interact: Ctrl-\\ w asks for the wall and ends the chunk" { | ||
| 989 | var f: PrefixFilter = .{}; | ||
| 990 | var chunk = "ab\x1cwz".*; | ||
| 991 | const out = f.feed(&chunk); | ||
| 992 | try std.testing.expectEqual(PrefixFilter.Action.wall, out.action); | ||
| 993 | // The drop the user is most likely to notice, because unlike a switch | ||
| 994 | // they come BACK to this session: "z" is gone, and the reason is that | ||
| 995 | // it was typed before the wall took the terminal. | ||
| 996 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 997 | } | ||
| 998 | |||
| 999 | fn devNull() !std.posix.fd_t { | ||
| 1000 | return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); | ||
| 1001 | } | ||
| 1002 | |||
| 1003 | test "prediction: prev_ch is read at the predicted cursor, not the replica's" { | ||
| 1004 | const alloc = std.testing.allocator; | ||
| 1005 | const null_fd = try devNull(); | ||
| 1006 | defer std.posix.close(null_fd); | ||
| 1007 | |||
| 1008 | // A real engine, fed real VT bytes — the one part of the prediction | ||
| 1009 | // contract no test inside predict.zig can reach, because that module | ||
| 1010 | // has never heard of an engine. | ||
| 1011 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1012 | defer replica.deinit(); | ||
| 1013 | // Content with the cursor parked ON a character and a DIFFERENT | ||
| 1014 | // character in the cell after it. That difference is the whole test: | ||
| 1015 | // with nothing pending the replica's cursor and the predicted one agree, | ||
| 1016 | // and mid-burst they do not. | ||
| 1017 | replica.feed("abcXY\x1b[1;4H"); | ||
| 1018 | try std.testing.expectEqual(@as(u16, 3), replica.cursorPos().x); | ||
| 1019 | |||
| 1020 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1021 | defer ov.deinit(); | ||
| 1022 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1023 | ov.noteSeq(1); | ||
| 1024 | |||
| 1025 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1026 | offerKeystroke(alloc, &ov, replica, "d", tty, null_fd); | ||
| 1027 | offerKeystroke(alloc, &ov, replica, "e", tty, null_fd); | ||
| 1028 | |||
| 1029 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 1030 | try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch); | ||
| 1031 | // The second keystroke lands in the cell AFTER the first prediction, | ||
| 1032 | // and takes that cell's content as its prev_ch. | ||
| 1033 | try std.testing.expectEqual(@as(u16, 4), ov.pendingAt(1).cell.col); | ||
| 1034 | try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch); | ||
| 1035 | |||
| 1036 | // A frame that changed neither cell: the daemon has not seen the | ||
| 1037 | // keystrokes yet, so it has said nothing about them and both | ||
| 1038 | // predictions must survive it. Read prev_ch from the wrong cell and | ||
| 1039 | // this is where it shows — the second cell holds 'Y', which is neither | ||
| 1040 | // the prediction nor the 'X' a cursor-based read would have recorded, | ||
| 1041 | // so the frame reads as a contradiction and the burst is flushed. | ||
| 1042 | try std.testing.expectEqual( | ||
| 1043 | predict.Verdict.none, | ||
| 1044 | reconcileOverlay(alloc, &ov, replica, 2, 0), | ||
| 1045 | ); | ||
| 1046 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 1047 | try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); | ||
| 1048 | |||
| 1049 | // And when the daemon does answer, they confirm against the real grid. | ||
| 1050 | replica.feed("\x1b[1;4Hde"); | ||
| 1051 | try std.testing.expectEqual( | ||
| 1052 | predict.Verdict.confirmed, | ||
| 1053 | reconcileOverlay(alloc, &ov, replica, 3, 0), | ||
| 1054 | ); | ||
| 1055 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 1056 | try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed); | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | test "prediction: a burst advances the predicted cursor one cell per keystroke" { | ||
| 1060 | const alloc = std.testing.allocator; | ||
| 1061 | const null_fd = try devNull(); | ||
| 1062 | defer std.posix.close(null_fd); | ||
| 1063 | |||
| 1064 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1065 | defer replica.deinit(); | ||
| 1066 | |||
| 1067 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1068 | defer ov.deinit(); | ||
| 1069 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1070 | |||
| 1071 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1072 | for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, tty, null_fd); | ||
| 1073 | |||
| 1074 | // The replica's own cursor has not moved — the daemon has answered | ||
| 1075 | // nothing — so every one of these came from the overlay. | ||
| 1076 | try std.testing.expectEqual(@as(u16, 0), replica.cursorPos().x); | ||
| 1077 | try std.testing.expectEqual(@as(usize, 5), ov.pendingCount()); | ||
| 1078 | for ("hello", 0..) |ch, i| { | ||
| 1079 | const p = ov.pendingAt(i); | ||
| 1080 | try std.testing.expectEqual(@as(u16, @intCast(i)), p.cell.col); | ||
| 1081 | try std.testing.expectEqual(ch, p.cell.ch); | ||
| 1082 | try std.testing.expectEqual(@as(u8, ' '), p.prev_ch); | ||
| 1083 | } | ||
| 1084 | try std.testing.expectEqual( | ||
| 1085 | predict.CursorPos{ .x = 5, .y = 0 }, | ||
| 1086 | ov.predictedCursor(.{ .x = 0, .y = 0 }), | ||
| 1087 | ); | ||
| 1088 | } | ||
| 1089 | |||
| 1090 | test "prediction paints underlined, and parks the cursor past what it drew" { | ||
| 1091 | const alloc = std.testing.allocator; | ||
| 1092 | const p = try std.posix.pipe(); | ||
| 1093 | defer std.posix.close(p[0]); | ||
| 1094 | |||
| 1095 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1096 | defer replica.deinit(); | ||
| 1097 | replica.feed("\x1b[1;4H"); // cursor at column 3 (0-based) | ||
| 1098 | |||
| 1099 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1100 | defer ov.deinit(); | ||
| 1101 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1102 | |||
| 1103 | offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]); | ||
| 1104 | std.posix.close(p[1]); | ||
| 1105 | |||
| 1106 | var out: std.ArrayList(u8) = .empty; | ||
| 1107 | defer out.deinit(alloc); | ||
| 1108 | var rbuf: [4096]u8 = undefined; | ||
| 1109 | while (true) { | ||
| 1110 | const n = try std.posix.read(p[0], &rbuf); | ||
| 1111 | if (n == 0) break; | ||
| 1112 | try out.appendSlice(alloc, rbuf[0..n]); | ||
| 1113 | } | ||
| 1114 | |||
| 1115 | // Drawn at the predicted cell, underlined so a speculation is visibly | ||
| 1116 | // one, and with the SGR closed again so it cannot bleed into the rest. | ||
| 1117 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[1;4H\x1b[4mz\x1b[0m") != null); | ||
| 1118 | // Cursor left one past it: the typist's next character goes there, and | ||
| 1119 | // if it did not the shell's own cursor would appear to lag a column | ||
| 1120 | // behind everything they typed. | ||
| 1121 | try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[1;5H\x1b[?25h\x1b[?2026l")); | ||
| 1122 | // Wrapped in one synchronized update, so no terminal ever shows the | ||
| 1123 | // half-drawn state. | ||
| 1124 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h")); | ||
| 1125 | } | ||
| 1126 | |||
| 1127 | test "prediction: nothing is drawn for a context that has not earned it" { | ||
| 1128 | const alloc = std.testing.allocator; | ||
| 1129 | const p = try std.posix.pipe(); | ||
| 1130 | defer std.posix.close(p[0]); | ||
| 1131 | |||
| 1132 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1133 | defer replica.deinit(); | ||
| 1134 | |||
| 1135 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1136 | defer ov.deinit(); | ||
| 1137 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned | ||
| 1138 | |||
| 1139 | offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]); | ||
| 1140 | std.posix.close(p[1]); | ||
| 1141 | |||
| 1142 | // Queued, so it can be judged and earn the next one its visibility... | ||
| 1143 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 1144 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 1145 | |||
| 1146 | // ...and not one byte went to the terminal. The effect, not the counter: | ||
| 1147 | // this is the assertion that a password prompt depends on. | ||
| 1148 | var rbuf: [64]u8 = undefined; | ||
| 1149 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf)); | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | test "prediction: a chunk that is not one printable byte is never speculated about" { | ||
| 1153 | const alloc = std.testing.allocator; | ||
| 1154 | const null_fd = try devNull(); | ||
| 1155 | defer std.posix.close(null_fd); | ||
| 1156 | |||
| 1157 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1158 | defer replica.deinit(); | ||
| 1159 | |||
| 1160 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1161 | defer ov.deinit(); | ||
| 1162 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 1163 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1164 | |||
| 1165 | // An arrow key: three bytes, and predicting its lead byte would paint an | ||
| 1166 | // escape character on the screen. | ||
| 1167 | offerKeystroke(alloc, &ov, replica, "\x1b[A", tty, null_fd); | ||
| 1168 | // A multi-byte character, whose display width we do not know. | ||
| 1169 | offerKeystroke(alloc, &ov, replica, "é", tty, null_fd); | ||
| 1170 | // And a lone control byte, which goes down the single-byte path and is | ||
| 1171 | // refused there. | ||
| 1172 | offerKeystroke(alloc, &ov, replica, "\r", tty, null_fd); | ||
| 1173 | |||
| 1174 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 1175 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 1176 | try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed); | ||
| 1177 | |||
| 1178 | // A paste: several printable bytes in one read. This is the shape whose | ||
| 1179 | // lead byte would sail through the printability check, so the length | ||
| 1180 | // guard is the only thing refusing it — and M9 refuses it, because a | ||
| 1181 | // paste can carry newlines and bracketed-paste markers that are not one | ||
| 1182 | // cell's worth of change each. | ||
| 1183 | offerKeystroke(alloc, &ov, replica, "abc", tty, null_fd); | ||
| 1184 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 1185 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 1186 | // Counted like every other refusal. The decision is the client's — the | ||
| 1187 | // overlay never sees the chunk — but the counter is about decisions, | ||
| 1188 | // not about which side of the interface made them. | ||
| 1189 | try std.testing.expectEqual(@as(u64, 4), ov.counters.suppressed); | ||
| 1190 | } | ||
| 1191 | |||
| 1192 | test "prediction: a repaint never reveals what was never shown" { | ||
| 1193 | const alloc = std.testing.allocator; | ||
| 1194 | const p = try std.posix.pipe(); | ||
| 1195 | defer std.posix.close(p[0]); | ||
| 1196 | |||
| 1197 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1198 | defer replica.deinit(); | ||
| 1199 | const null_fd = try devNull(); | ||
| 1200 | defer std.posix.close(null_fd); | ||
| 1201 | |||
| 1202 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1203 | defer ov.deinit(); | ||
| 1204 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident | ||
| 1205 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1206 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); | ||
| 1207 | offerKeystroke(alloc, &ov, replica, "b", tty, null_fd); | ||
| 1208 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 1209 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 1210 | |||
| 1211 | // Every authoritative paint is followed by re-laying the overlay on top, | ||
| 1212 | // because a delta's row content wipes anything drawn over it. That | ||
| 1213 | // repaint is a second, quieter chance to show a prediction that was | ||
| 1214 | // never displayed in the first place — so it asks the same question the | ||
| 1215 | // keystroke path did, and gets the same answer. | ||
| 1216 | paintOverlay(alloc, &ov, replica.cursorPos(), tty, p[1]); | ||
| 1217 | std.posix.close(p[1]); | ||
| 1218 | |||
| 1219 | var rbuf: [64]u8 = undefined; | ||
| 1220 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf)); | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | test "prediction: a promotion mid-burst counts the cell it makes visible" { | ||
| 1224 | const alloc = std.testing.allocator; | ||
| 1225 | const p = try std.posix.pipe(); | ||
| 1226 | defer std.posix.close(p[0]); | ||
| 1227 | const null_fd = try devNull(); | ||
| 1228 | defer std.posix.close(null_fd); | ||
| 1229 | |||
| 1230 | const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1231 | defer replica.deinit(); | ||
| 1232 | |||
| 1233 | var ov = predict.Overlay.init(alloc, 80, 24); | ||
| 1234 | defer ov.deinit(); | ||
| 1235 | ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned | ||
| 1236 | const tty = proto.Size{ .cols = 80, .rows = 24 }; | ||
| 1237 | |||
| 1238 | // One confirm banked, one short of promotion. | ||
| 1239 | offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); | ||
| 1240 | replica.feed("a"); | ||
| 1241 | try std.testing.expectEqual( | ||
| 1242 | predict.Verdict.confirmed, | ||
| 1243 | reconcileOverlay(alloc, &ov, replica, 1, 0), | ||
| 1244 | ); | ||
| 1245 | |||
| 1246 | // Two more typed while still invisible, and the promoting confirmation | ||
| 1247 | // lands while the second of them is outstanding. | ||
| 1248 | offerKeystroke(alloc, &ov, replica, "b", tty, null_fd); | ||
| 1249 | offerKeystroke(alloc, &ov, replica, "c", tty, null_fd); | ||
| 1250 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 1251 | replica.feed("b"); | ||
| 1252 | try std.testing.expectEqual( | ||
| 1253 | predict.Verdict.confirmed, | ||
| 1254 | reconcileOverlay(alloc, &ov, replica, 2, 0), | ||
| 1255 | ); | ||
| 1256 | try std.testing.expect(ov.confident); | ||
| 1257 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 1258 | // Nothing has been drawn yet: it was queued invisible and no repaint | ||
| 1259 | // has happened since the promotion. | ||
| 1260 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 1261 | |||
| 1262 | // The post-frame re-lay is where it reaches the screen — nobody typed | ||
| 1263 | // anything to make that happen, so counting only at prediction time | ||
| 1264 | // would lose it. | ||
| 1265 | paintOverlay(alloc, &ov, replica.cursorPos(), tty, p[1]); | ||
| 1266 | std.posix.close(p[1]); | ||
| 1267 | try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); | ||
| 1268 | |||
| 1269 | var out: std.ArrayList(u8) = .empty; | ||
| 1270 | defer out.deinit(alloc); | ||
| 1271 | var rbuf: [4096]u8 = undefined; | ||
| 1272 | while (true) { | ||
| 1273 | const n = try std.posix.read(p[0], &rbuf); | ||
| 1274 | if (n == 0) break; | ||
| 1275 | try out.appendSlice(alloc, rbuf[0..n]); | ||
| 1276 | } | ||
| 1277 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[4mc\x1b[0m") != null); | ||
| 1278 | } | ||
| 1279 | |||
| 1280 | test "the predict stats line is one greppable row of counters" { | ||
| 1281 | var buf: [predict_stats_len]u8 = undefined; | ||
| 1282 | const line = try formatPredictStats(&buf, .{ | ||
| 1283 | .made = 5, | ||
| 1284 | .displayed = 4, | ||
| 1285 | .confirmed = 3, | ||
| 1286 | .contradicted = 2, | ||
| 1287 | .expired = 1, | ||
| 1288 | .abandoned = 7, | ||
| 1289 | .suppressed = 6, | ||
| 1290 | }); | ||
| 1291 | // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename | ||
| 1292 | // or a reorder is a broken suite rather than a cosmetic change. The | ||
| 1293 | // units differ between them — see predict.Counters — which is exactly | ||
| 1294 | // why every one of them is on the line rather than a chosen few. | ||
| 1295 | try std.testing.expectEqualStrings( | ||
| 1296 | "predict made=5 displayed=4 confirmed=3 contradicted=2" ++ | ||
| 1297 | " expired=1 abandoned=7 suppressed=6", | ||
| 1298 | line, | ||
| 1299 | ); | ||
| 1300 | } | ||
| 1301 | |||
| 1302 | /// Stands in a caller's buffer before a refusal, so "wrote nothing" is | ||
| 1303 | /// distinguishable from "never writes anything". Not base64, not part of | ||
| 1304 | /// any escape the builder emits. | ||
| 1305 | const refusal_sentinel: u8 = 0xfe; | ||
| 1306 | |||
| 1307 | test "interact: a validated clipboard effect becomes an OSC 52 write" { | ||
| 1308 | const alloc = std.testing.allocator; | ||
| 1309 | var out: std.ArrayList(u8) = .empty; | ||
| 1310 | defer out.deinit(alloc); | ||
| 1311 | |||
| 1312 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 1313 | .target = 'c', | ||
| 1314 | .base64 = "aGk=", | ||
| 1315 | } }); | ||
| 1316 | // BEL rather than ESC-backslash: it is what most emitters in the wild | ||
| 1317 | // use, and every terminal that accepts one accepts it. | ||
| 1318 | try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items); | ||
| 1319 | } | ||
| 1320 | |||
| 1321 | test "interact: a validated bell effect becomes a BEL" { | ||
| 1322 | const alloc = std.testing.allocator; | ||
| 1323 | var out: std.ArrayList(u8) = .empty; | ||
| 1324 | defer out.deinit(alloc); | ||
| 1325 | |||
| 1326 | try appendHostEffect(&out, alloc, .bell); | ||
| 1327 | try std.testing.expectEqualStrings("\x07", out.items); | ||
| 1328 | } | ||
| 1329 | |||
| 1330 | test "interact: xterm Pc targets retain their exact OSC 52 spelling" { | ||
| 1331 | const alloc = std.testing.allocator; | ||
| 1332 | |||
| 1333 | for ([_]u8{ 'c', 'p', 'q', 's', '0', '7' }) |target| { | ||
| 1334 | var out: std.ArrayList(u8) = .empty; | ||
| 1335 | defer out.deinit(alloc); | ||
| 1336 | |||
| 1337 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 1338 | .target = target, | ||
| 1339 | .base64 = "aGk=", | ||
| 1340 | } }); | ||
| 1341 | const want = [_]u8{ 0x1b, ']', '5', '2', ';', target, ';', 'a', 'G', 'k', '=', 0x07 }; | ||
| 1342 | try std.testing.expectEqualSlices(u8, &want, out.items); | ||
| 1343 | } | ||
| 1344 | } | ||
| 1345 | |||
| 1346 | test "interact: terminal mode state turns bracketed paste on and off on the host" { | ||
| 1347 | const alloc = std.testing.allocator; | ||
| 1348 | var out: std.ArrayList(u8) = .empty; | ||
| 1349 | defer out.deinit(alloc); | ||
| 1350 | |||
| 1351 | // The mouse level-set follows every mode sample, so the paste bytes are | ||
| 1352 | // asserted as a prefix and the mouse half gets its own test below. | ||
| 1353 | try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = true } }); | ||
| 1354 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004h")); | ||
| 1355 | |||
| 1356 | out.clearRetainingCapacity(); | ||
| 1357 | try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = false } }); | ||
| 1358 | try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004l")); | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | test "interact: with no application asking, the client keeps the mouse for the wheel" { | ||
| 1362 | const alloc = std.testing.allocator; | ||
| 1363 | var out: std.ArrayList(u8) = .empty; | ||
| 1364 | defer out.deinit(alloc); | ||
| 1365 | |||
| 1366 | // A format mode alone is not a claim on the mouse: nothing asked for an | ||
| 1367 | // event, so 1000+1006 stay ours and 1005 goes back off. | ||
| 1368 | try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_utf8 = true }); | ||
| 1369 | try std.testing.expectEqualStrings( | ||
| 1370 | "\x1b[?9l\x1b[?1000h\x1b[?1002l\x1b[?1003l" ++ | ||
| 1371 | "\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l", | ||
| 1372 | out.items, | ||
| 1373 | ); | ||
| 1374 | } | ||
| 1375 | |||
| 1376 | test "interact: an application that asked for the mouse gets exactly the modes it asked for" { | ||
| 1377 | const alloc = std.testing.allocator; | ||
| 1378 | var out: std.ArrayList(u8) = .empty; | ||
| 1379 | defer out.deinit(alloc); | ||
| 1380 | |||
| 1381 | // vim's `set mouse=a`. 1000 is on because vim asked, not because we | ||
| 1382 | // want it, and 1002 proves the difference: our own set never asks for | ||
| 1383 | // drag reports. | ||
| 1384 | try appendMouseModes(&out, alloc, .{ | ||
| 1385 | .bracketed_paste = false, | ||
| 1386 | .mouse_normal = true, | ||
| 1387 | .mouse_button = true, | ||
| 1388 | .mouse_sgr = true, | ||
| 1389 | }); | ||
| 1390 | try std.testing.expectEqualStrings( | ||
| 1391 | "\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003l" ++ | ||
| 1392 | "\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l", | ||
| 1393 | out.items, | ||
| 1394 | ); | ||
| 1395 | |||
| 1396 | // An application on the legacy format gets the legacy format: leaving | ||
| 1397 | // our own 1006 on would spell every click in a shape it cannot parse. | ||
| 1398 | out.clearRetainingCapacity(); | ||
| 1399 | try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_normal = true }); | ||
| 1400 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1006l") != null); | ||
| 1401 | } | ||
| 1402 | |||
| 1403 | test "interact: a title becomes an OSC 0 write, and empty or control bytes are refused" { | ||
| 1404 | const alloc = std.testing.allocator; | ||
| 1405 | var out: std.ArrayList(u8) = .empty; | ||
| 1406 | defer out.deinit(alloc); | ||
| 1407 | |||
| 1408 | try appendTermTitle(&out, alloc, "vim"); | ||
| 1409 | try std.testing.expectEqualStrings("\x1b]0;vim\x07", out.items); | ||
| 1410 | |||
| 1411 | // Seeded, not merely emptied: `len == 0` on a buffer that started empty | ||
| 1412 | // also passes for a builder that appends nothing ever. | ||
| 1413 | out.clearRetainingCapacity(); | ||
| 1414 | try out.append(alloc, refusal_sentinel); | ||
| 1415 | // A BEL inside the title would terminate the OSC early and paint the | ||
| 1416 | // rest — here a shell command — on the user's screen as text. | ||
| 1417 | try appendTermTitle(&out, alloc, "vim\x07rm -rf"); | ||
| 1418 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 1419 | |||
| 1420 | // ESC is the other terminator half (ST), and DEL is the control byte | ||
| 1421 | // that is not below 0x20 — both are refused by the same check. | ||
| 1422 | out.clearRetainingCapacity(); | ||
| 1423 | try out.append(alloc, refusal_sentinel); | ||
| 1424 | try appendTermTitle(&out, alloc, "vim\x1b]52;c;AAAA\x07"); | ||
| 1425 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 1426 | |||
| 1427 | out.clearRetainingCapacity(); | ||
| 1428 | try out.append(alloc, refusal_sentinel); | ||
| 1429 | try appendTermTitle(&out, alloc, "vim\x7f"); | ||
| 1430 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 1431 | |||
| 1432 | // Empty is refused rather than written: `ESC]0;BEL` would CLEAR the | ||
| 1433 | // host terminal's title, and no daemon of any version has a reason to | ||
| 1434 | // ask for that. See sampleTermTitle for the daemon half of this policy. | ||
| 1435 | out.clearRetainingCapacity(); | ||
| 1436 | try out.append(alloc, refusal_sentinel); | ||
| 1437 | try appendTermTitle(&out, alloc, ""); | ||
| 1438 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 1439 | } | ||
| 1440 | |||
| 1441 | test "interact: the title cap is a cap, not an off-by-one" { | ||
| 1442 | const alloc = std.testing.allocator; | ||
| 1443 | var out: std.ArrayList(u8) = .empty; | ||
| 1444 | defer out.deinit(alloc); | ||
| 1445 | |||
| 1446 | const at_cap = try alloc.alloc(u8, proto.term_title_max); | ||
| 1447 | defer alloc.free(at_cap); | ||
| 1448 | @memset(at_cap, 'x'); | ||
| 1449 | try appendTermTitle(&out, alloc, at_cap); | ||
| 1450 | // "\x1b]0;" is four bytes and the BEL is one. | ||
| 1451 | try std.testing.expectEqual(proto.term_title_max + 5, out.items.len); | ||
| 1452 | |||
| 1453 | const over = try alloc.alloc(u8, proto.term_title_max + 1); | ||
| 1454 | defer alloc.free(over); | ||
| 1455 | @memset(over, 'x'); | ||
| 1456 | out.clearRetainingCapacity(); | ||
| 1457 | try out.append(alloc, refusal_sentinel); | ||
| 1458 | try appendTermTitle(&out, alloc, over); | ||
| 1459 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 1460 | } | ||
| 1461 | |||
| 1462 | test "interact: the exit teardown unsets every mode mux turned on, and pops the title" { | ||
| 1463 | // A multiplexer that leaves your terminal in a mode it enabled is worse | ||
| 1464 | // than one that pastes badly, so the teardown string is pinned as a | ||
| 1465 | // literal rather than assembled from the constants it writes. | ||
| 1466 | try std.testing.expectEqualStrings( | ||
| 1467 | "\x1b[?2004l" ++ | ||
| 1468 | "\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l" ++ | ||
| 1469 | "\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" ++ | ||
| 1470 | "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l", | ||
| 1471 | terminal_teardown, | ||
| 1472 | ); | ||
| 1473 | // The pop is worthless — worse, it pops a stranger's title — without | ||
| 1474 | // the push that pairs with it, and the two live far apart: the push is | ||
| 1475 | // a literal inside the frame loop's alt-screen entry. Pinned here | ||
| 1476 | // together so deleting either one fails, rather than quietly leaving | ||
| 1477 | // the terminal one push deep forever or one pop too many. | ||
| 1478 | try std.testing.expectEqualStrings( | ||
| 1479 | "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h", | ||
| 1480 | terminal_setup, | ||
| 1481 | ); | ||
| 1482 | // Every mode the setup turns on has an `l` for it in the teardown. The | ||
| 1483 | // mouse half is the one that can drift, because the daemon can ask for | ||
| 1484 | // modes this string never mentions. | ||
| 1485 | inline for (proto.mouse_modes) |m| { | ||
| 1486 | try std.testing.expect(std.mem.indexOf( | ||
| 1487 | u8, | ||
| 1488 | terminal_teardown, | ||
| 1489 | comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}), | ||
| 1490 | ) != null); | ||
| 1491 | } | ||
| 1492 | } | ||
| 1493 | |||
| 1494 | test "interact: an unvalidated clipboard effect writes nothing" { | ||
| 1495 | const alloc = std.testing.allocator; | ||
| 1496 | var out: std.ArrayList(u8) = .empty; | ||
| 1497 | defer out.deinit(alloc); | ||
| 1498 | |||
| 1499 | // Exactly the value wasm_core.zig default-initialises its borrowed | ||
| 1500 | // clipboard slot to, and exactly what client_core.validClipboard | ||
| 1501 | // refuses. Written verbatim it is `ESC]52;<NUL>;BEL` on a real tty. | ||
| 1502 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 1503 | .target = 0, | ||
| 1504 | .base64 = &.{}, | ||
| 1505 | } }); | ||
| 1506 | try std.testing.expectEqual(@as(usize, 0), out.items.len); | ||
| 1507 | |||
| 1508 | // The refusal is the whole value, not just its target: a legal target | ||
| 1509 | // carrying bytes outside the base64 alphabet is the injection this | ||
| 1510 | // check exists for. | ||
| 1511 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 1512 | .target = 'c', | ||
| 1513 | .base64 = "aGk=\x1b]0;pwned\x07", | ||
| 1514 | } }); | ||
| 1515 | try std.testing.expectEqual(@as(usize, 0), out.items.len); | ||
| 1516 | } | ||
| 1517 | |||
| 1518 | test "interact: a clipboard effect at the cap retains its exact framing" { | ||
| 1519 | const alloc = std.testing.allocator; | ||
| 1520 | |||
| 1521 | const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max); | ||
| 1522 | defer alloc.free(at_cap); | ||
| 1523 | @memset(at_cap, 'A'); | ||
| 1524 | |||
| 1525 | // The boundary itself is ACCEPTED — stated because `>` and `>=` are one | ||
| 1526 | // keystroke apart and the wrong one silently truncates the largest copy | ||
| 1527 | // the daemon is willing to send. | ||
| 1528 | { | ||
| 1529 | var out: std.ArrayList(u8) = .empty; | ||
| 1530 | defer out.deinit(alloc); | ||
| 1531 | |||
| 1532 | try appendHostEffect(&out, alloc, .{ .clipboard_set = .{ | ||
| 1533 | .target = 'c', | ||
| 1534 | .base64 = at_cap, | ||
| 1535 | } }); | ||
| 1536 | // "\x1b]52;c;" ++ payload ++ BEL | ||
| 1537 | try std.testing.expectEqual(at_cap.len + 8, out.items.len); | ||
| 1538 | } | ||
| 1539 | } | ||
| 1540 | |||
| 1541 | fn appendNothing( | ||
| 1542 | _: *std.ArrayList(u8), | ||
| 1543 | _: std.mem.Allocator, | ||
| 1544 | _: void, | ||
| 1545 | ) std.mem.Allocator.Error!void {} | ||
| 1546 | |||
| 1547 | test "interact: side channels write nothing before terminal ownership" { | ||
| 1548 | const pipe = try std.posix.pipe(); | ||
| 1549 | defer std.posix.close(pipe[0]); | ||
| 1550 | var write_open = true; | ||
| 1551 | defer if (write_open) std.posix.close(pipe[1]); | ||
| 1552 | |||
| 1553 | try writeSideChannel( | ||
| 1554 | std.testing.allocator, | ||
| 1555 | pipe[1], | ||
| 1556 | false, | ||
| 1557 | client_core.Effect, | ||
| 1558 | .{ .bell = {} }, | ||
| 1559 | appendHostEffect, | ||
| 1560 | ); | ||
| 1561 | std.posix.close(pipe[1]); | ||
| 1562 | write_open = false; | ||
| 1563 | |||
| 1564 | var byte: [1]u8 = undefined; | ||
| 1565 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte)); | ||
| 1566 | } | ||
| 1567 | |||
| 1568 | test "interact: an empty side-channel rendering writes nothing" { | ||
| 1569 | const pipe = try std.posix.pipe(); | ||
| 1570 | defer std.posix.close(pipe[0]); | ||
| 1571 | var write_open = true; | ||
| 1572 | defer if (write_open) std.posix.close(pipe[1]); | ||
| 1573 | |||
| 1574 | try writeSideChannel( | ||
| 1575 | std.testing.allocator, | ||
| 1576 | pipe[1], | ||
| 1577 | true, | ||
| 1578 | void, | ||
| 1579 | {}, | ||
| 1580 | appendNothing, | ||
| 1581 | ); | ||
| 1582 | std.posix.close(pipe[1]); | ||
| 1583 | write_open = false; | ||
| 1584 | |||
| 1585 | var byte: [1]u8 = undefined; | ||
| 1586 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte)); | ||
| 1587 | } | ||
| 1588 | |||
| 1589 | test "interact: an allocation failure discards a partially built side channel" { | ||
| 1590 | const pipe = try std.posix.pipe(); | ||
| 1591 | defer std.posix.close(pipe[0]); | ||
| 1592 | |||
| 1593 | // The OSC introducer gets the first allocation. Growing for the payload | ||
| 1594 | // then fails both its resize and allocation fallback, after real escape | ||
| 1595 | // bytes exist in writeSideChannel's private buffer. | ||
| 1596 | var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ | ||
| 1597 | .fail_index = 1, | ||
| 1598 | .resize_fail_index = 0, | ||
| 1599 | }); | ||
| 1600 | var payload: [128]u8 = undefined; | ||
| 1601 | @memset(&payload, 'A'); | ||
| 1602 | const result = writeSideChannel( | ||
| 1603 | failing.allocator(), | ||
| 1604 | pipe[1], | ||
| 1605 | true, | ||
| 1606 | client_core.Effect, | ||
| 1607 | .{ .clipboard_set = .{ | ||
| 1608 | .target = 'c', | ||
| 1609 | .base64 = &payload, | ||
| 1610 | } }, | ||
| 1611 | appendHostEffect, | ||
| 1612 | ); | ||
| 1613 | std.posix.close(pipe[1]); | ||
| 1614 | |||
| 1615 | try std.testing.expectError(error.OutOfMemory, result); | ||
| 1616 | try std.testing.expectEqual(@as(usize, 1), failing.allocations); | ||
| 1617 | try std.testing.expect(failing.has_induced_failure); | ||
| 1618 | var byte: [1]u8 = undefined; | ||
| 1619 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte)); | ||
| 1620 | } | ||
| 1621 | |||
| 1622 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 1623 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 1624 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 1625 | test { | ||
| 1626 | std.testing.refAllDeclsRecursive(@This()); | ||
| 1627 | } | ||
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -89,6 +89,9 @@ const Engine = @import("engine").Engine; | |||
| 89 | const Replica = @import("replica").Replica; | 89 | const Replica = @import("replica").Replica; |
| 90 | const paint = @import("paint"); | 90 | const paint = @import("paint"); |
| 91 | const predict = @import("predict"); | 91 | const predict = @import("predict"); |
| 92 | // The chord table and the prediction hooks a zoomed tile shares with the | ||
| 93 | // client: one interaction core, not a second copy (interact.zig). | ||
| 94 | const interact = @import("interact"); | ||
| 92 | 95 | ||
| 93 | pub const Resolved = struct { | 96 | pub const Resolved = struct { |
| 94 | target: client.Target, | 97 | target: client.Target, |
| @@ -475,7 +478,7 @@ fn paintTile(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, overlay: *predict | |||
| 475 | // client.zig's reason verbatim: the rows just drawn have | 478 | // client.zig's reason verbatim: the rows just drawn have |
| 476 | // overwritten predictions that are still outstanding, and one | 479 | // overwritten predictions that are still outstanding, and one |
| 477 | // frame of flicker is exactly what prediction exists to avoid. | 480 | // frame of flicker is exactly what prediction exists to avoid. |
| 478 | client.paintOverlay(alloc, overlay, eng.cursorPos(), t.shared.size, t.shared.out_fd); | 481 | interact.paintOverlay(alloc, overlay, eng.cursorPos(), t.shared.size, t.shared.out_fd); |
| 479 | }, | 482 | }, |
| 480 | } | 483 | } |
| 481 | return true; | 484 | return true; |
| @@ -716,7 +719,7 @@ fn pumpTile(t: *Tile) void { | |||
| 716 | // an OVERLAY — it never enters the replica. | 719 | // an OVERLAY — it never enters the replica. |
| 717 | t.shared.paint_mu.lock(); | 720 | t.shared.paint_mu.lock(); |
| 718 | if (paintModeLocked(t) == .full) { | 721 | if (paintModeLocked(t) == .full) { |
| 719 | client.offerKeystroke(alloc, &overlay, eng, keys, t.shared.size, t.shared.out_fd); | 722 | interact.offerKeystroke(alloc, &overlay, eng, keys, t.shared.size, t.shared.out_fd); |
| 720 | } | 723 | } |
| 721 | t.shared.paint_mu.unlock(); | 724 | t.shared.paint_mu.unlock(); |
| 722 | } | 725 | } |
| @@ -800,7 +803,7 @@ fn pumpTile(t: *Tile) void { | |||
| 800 | overlay.flush(); | 803 | overlay.flush(); |
| 801 | overlay.noteSeq(rep.last_seq); | 804 | overlay.noteSeq(rep.last_seq); |
| 802 | } else { | 805 | } else { |
| 803 | _ = client.reconcileOverlay( | 806 | _ = interact.reconcileOverlay( |
| 804 | alloc, | 807 | alloc, |
| 805 | &overlay, | 808 | &overlay, |
| 806 | eng, | 809 | eng, |
| @@ -879,7 +882,7 @@ pub const ZoomMove = union(enum) { | |||
| 879 | /// mean "stay here", and re-zooming in place would clear the screen and | 882 | /// mean "stay here", and re-zooming in place would clear the screen and |
| 880 | /// repaint it to no visible effect. | 883 | /// repaint it to no visible effect. |
| 881 | pub fn zoomChord( | 884 | pub fn zoomChord( |
| 882 | action: client.PrefixFilter.Action, | 885 | action: interact.PrefixFilter.Action, |
| 883 | cur: usize, | 886 | cur: usize, |
| 884 | present: []const bool, | 887 | present: []const bool, |
| 885 | last: ?usize, | 888 | last: ?usize, |
| @@ -1182,7 +1185,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 { | |||
| 1182 | // reaches a session — that is what makes an unzoomed tile claim | 1185 | // reaches a session — that is what makes an unzoomed tile claim |
| 1183 | // nothing); zoomed IN the terminal belongs to the session and only the | 1186 | // nothing); zoomed IN the terminal belongs to the session and only the |
| 1184 | // `Ctrl-\` chord layer is held back. | 1187 | // `Ctrl-\` chord layer is held back. |
| 1185 | var prefix: client.PrefixFilter = .{}; | 1188 | var prefix: interact.PrefixFilter = .{}; |
| 1186 | // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads | 1189 | // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads |
| 1187 | // it, and no lock guards it, because nothing else writes it. | 1190 | // it, and no lock guards it, because nothing else writes it. |
| 1188 | var last_zoom: ?usize = null; | 1191 | var last_zoom: ?usize = null; |
| @@ -1431,12 +1434,12 @@ test "zoomChord: `l` goes back, and unzooms when there is nowhere to go" { | |||
| 1431 | } | 1434 | } |
| 1432 | 1435 | ||
| 1433 | test "zoomChord: the chords come out of the client's own table" { | 1436 | test "zoomChord: the chords come out of the client's own table" { |
| 1434 | // Not a twin table: the bytes are filtered by client.PrefixFilter and | 1437 | // Not a twin table: the bytes are filtered by interact.PrefixFilter and |
| 1435 | // only their MEANING is decided here. Fed as a real client would feed | 1438 | // only their MEANING is decided here. Fed as a real client would feed |
| 1436 | // it — split across reads, because a read boundary is not a chord | 1439 | // it — split across reads, because a read boundary is not a chord |
| 1437 | // boundary — so a drift in either half fails here. | 1440 | // boundary — so a drift in either half fails here. |
| 1438 | const pt = allPresent(2); | 1441 | const pt = allPresent(2); |
| 1439 | var f: client.PrefixFilter = .{}; | 1442 | var f: interact.PrefixFilter = .{}; |
| 1440 | var first = "vi\x1c".*; | 1443 | var first = "vi\x1c".*; |
| 1441 | const a = f.feed(&first); | 1444 | const a = f.feed(&first); |
| 1442 | try std.testing.expectEqualStrings("vi", a.forward); | 1445 | try std.testing.expectEqualStrings("vi", a.forward); |