a73x

abe12351

refactor: interact.zig owns what happens at an open session

a73x   2026-08-20 01:06

Commit message
refactor: interact.zig owns what happens at an open session

client.zig held two jobs in one file: BUILDING a link to a session
(targets, dialling, the ssh handoff, reconnect) and everything that
happens once one is open (the Ctrl-\ chord table, the wheel splitter and
alternate scroll, speculative echo, the side channels, terminal
ownership). The second half is what a wall tile needs too — the spec's
phase 3 promotes a tile into it — and wallview already reached across
for four of its pieces by name.

So the second half moves out whole, comments and tests with it. Nothing
changes about what any of it does; the call sites in client.zig gain an
`interact.` and lose nothing else.

The transport is `anytype` in the two functions that write frames, and
that is a layering fact rather than a generality wish: Transport is
built out of QUIC and the handoff, so it sits above this module and the
only thing the module can say about it is the surface it uses.

The graph therefore gains a stratum — client 2->3, webhub/wallview 3->4,
mux/webhub_main 4->5 — which is exactly the edit the table's FROZEN note
asks for when a real re-stratification happens.

A handful of decls are pub only because session() still calls them from
outside; the next commit takes that back.

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);