a73x

14cef649

fix(web): the page never ran; unzoom was unreachable; a paste wrapped every chunk

a73x   2026-08-13 12:21

Commit message
fix(web): the page never ran; unzoom was unreachable; a paste wrapped every chunk

`WebAssembly.instantiate` has two return shapes and the shell picked the
wrong one: given BYTES it resolves to {module, instance}, but given an
already-compiled Module — which is what compileStreaming hands back — it
resolves to the Instance itself. `const { instance } = ...` was therefore
undefined, every tile threw on `.exports`, and the rejection went
nowhere because nothing was reading it. Adding the .catch on tile.start()
(a MINOR item in the review) is what surfaced it: driving the real page
under headless Chrome printed the TypeError, and every tile had been
'gone' from the first frame. verify.js passes bytes, so it takes the
other shape and was right all along — which is exactly why no test saw
this.

With the page actually running, the rest of the round:

  - Unzoom was unreachable. The zoomed tile was `inset: 0; z-index: 10`
    over a `z-index: 5` shade, so the shade's click handler could never
    fire and the tile covered the viewport. The tile is now inset
    (3vh 4vw) at z-index 20 with the shade at 10: the visible ring of
    shade around it IS the unzoom control. Escape is deliberately NOT
    bound — it belongs to the application, and vim needs it. The zoomed
    grid is measured from the element's own box, so the CSS stays the
    single source of the geometry. Verified over CDP: elementFromPoint at
    the viewport corner hits #shade, and clicking it unzooms.
  - Wall tiles drew at full logical size into a big bitmap and let CSS
    squash it. They now draw THROUGH a scaled context, with the backing
    store at CSS pixels × devicePixelRatio, so glyphs rasterize once at
    the size they are actually seen.
  - Composed IME text was being sent as a bracketed PASTE. It is typing,
    and it now goes out as plain bytes (mux_text_encode) — bracketing it
    would make vim skip paste mode's indentation and a shell's
    bracketed-paste guard refuse to run it.
  - A chunked paste wrapped EVERY 32 KiB chunk, putting a paste-end in
    the middle of the pasted text. mux_paste_begin/mux_paste_end are now
    separate exports and the shell sends begin + N unwrapped chunks +
    end: one wrap around the whole paste, which is what keymap.zig's
    pasteInto has always said it was. mux_paste_encode is retired — one
    way to spell a paste, not two.

    KNOWN LIMITATION, deliberate for v1: the wrap is unconditional. The
    daemon's pty_mode frame carries icanon/echo and not mode 2004, so
    nothing on this side can tell whether the application asked for
    brackets; one that did not sees the literal markers. Teaching
    pty_mode to carry the bit is a daemon protocol change and is
    deferred.

  - The browser leg reconnects on its own, on client.zig's nextBackoffMs
    schedule (0, then 200ms doubling to a 2s cap, no retry cap). Nothing
    was reconnecting this socket, so a restarted hub left every tile
    permanently 'gone' with a reload as the only cure. The badge reads
    'reconnecting' while retrying and 'gone' only once the retries are
    also failing. Verified live: up → kill hub → reconnecting → gone →
    restart hub → up, with no reload.
  - mux_apply_frame < 0 tears down and re-inits the core rather than
    painting on, and a frame over the staging cap re-attaches instead of
    returning silently.
  - gotState follows Replica.state_since_attach: a delta's ARRIVAL alone
    proves the attach was admitted, so exit_status after a bad delta
    reads as the shell exiting rather than "session full".

verify.js gains the new exports and, more usefully, reads mux.js and
asserts every mux_* the shell calls is a real export — the pair that has
to agree, and the one a renamed export breaks with nothing else noticing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/wasm_core.zig
Old New
@@ -330,18 +330,50 @@ export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 {
330 return @intCast(seq.len); 330 return @intCast(seq.len);
331 } 331 }
332 332
333 /// Wrap `len` staged bytes in bracketed paste into the output buffer. 333 /// `len` staged bytes to the output buffer, UNCHANGED. Two callers, and
334 /// The shell chunks pastes at 32 KiB, so the wrap always fits the 64 KiB 334 /// the distinction between them is the point:
335 /// output buffer; -2 if the host ignored that contract. 335 ///
336 export fn mux_paste_encode(len: u32) i32 { 336 /// - An IME's compositionend hands over finished text, and finished
337 if (len > input_buf.len) return -2; 337 /// text is TYPING. Bracketing it would tell the application a human
338 const total = keymap.paste_begin.len + len + keymap.paste_end.len; 338 /// did not write it — vim would skip paste mode's indentation, a
339 if (total > output_buf.len) return -2; 339 /// shell's bracketed-paste guard would refuse to run it — so composed
340 /// text goes out raw, exactly as the keystrokes it stands in for.
341 /// - The body chunks of a real paste, between the markers below.
342 ///
343 /// -2 if the host staged more than either buffer holds.
344 export fn mux_text_encode(len: u32) i32 {
345 if (len > input_buf.len or len > output_buf.len) return -2;
346 @memcpy(output_buf[0..len], input_buf[0..len]);
347 output_len = len;
348 return @intCast(len);
349 }
350
351 /// The bracketed-paste markers, each on its own, because a paste too big
352 /// for one message is still ONE paste: the host sends begin, then N
353 /// unwrapped chunks through mux_text_encode, then end.
354 ///
355 /// keymap.pasteInto's contract is "the wrap and nothing else" around the
356 /// WHOLE paste, and this is the code finally matching that doc. Wrapping
357 /// each chunk instead — which is what the shell used to do — put a
358 /// paste-END in the middle of the pasted text, and an application that
359 /// acts on the marker acts on it right there: vim leaves paste mode
360 /// 32 KiB in and re-indents the rest.
361 ///
362 /// UNCONDITIONAL, a known v1 limitation: the daemon's pty_mode frame
363 /// carries only icanon/echo, so the hub cannot know whether the
364 /// application asked for mode 2004. An application that did not ask sees
365 /// the literal markers. Teaching pty_mode to carry the bit is a daemon
366 /// protocol change and is deferred.
367 export fn mux_paste_begin() i32 {
340 @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); 368 @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin);
341 @memcpy(output_buf[keymap.paste_begin.len..][0..len], input_buf[0..len]); 369 output_len = @intCast(keymap.paste_begin.len);
342 @memcpy(output_buf[keymap.paste_begin.len + len ..][0..keymap.paste_end.len], keymap.paste_end); 370 return @intCast(keymap.paste_begin.len);
343 output_len = @intCast(total); 371 }
344 return @intCast(total); 372
373 export fn mux_paste_end() i32 {
374 @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end);
375 output_len = @intCast(keymap.paste_end.len);
376 return @intCast(keymap.paste_end.len);
345 } 377 }
346 378
347 // --------------------------------------------------------------------- 379 // ---------------------------------------------------------------------
web/index.html
Old New
@@ -32,15 +32,23 @@
32 .badge.up { background: #16351f; color: #6fce8a; } 32 .badge.up { background: #16351f; color: #6fce8a; }
33 .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; } 33 .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; }
34 .badge.scroll { background: #1a2c3d; color: #6ab0e0; } 34 .badge.scroll { background: #1a2c3d; color: #6ab0e0; }
35 .tile canvas { display: block; width: 100%; height: auto; background: #000; } 35 /* mux.js sets width/height in CSS pixels and sizes the backing store to
36 /* The zoomed tile: same element, promoted. 1:1 pixels, keys go here. */ 36 that times devicePixelRatio, so glyphs rasterize at device resolution
37 instead of being a downscaled bitmap. No width:100% here: it would
38 stretch the backing store back out and undo exactly that. */
39 .tile canvas { display: block; background: #000; }
40 /* The zoomed tile: same element, promoted. Keys go here.
41 INSET, not inset:0 — the ring of shade left visible around it is the
42 only way back out (clicking it unzooms), and a tile covering the
43 viewport made unzoom unreachable. Escape is deliberately NOT bound:
44 it belongs to the application, and vim needs it. */
37 .tile.zoomed { 45 .tile.zoomed {
38 position: fixed; inset: 0; z-index: 10; border-radius: 0; cursor: default; 46 position: fixed; inset: 3vh 4vw; z-index: 20; cursor: default;
39 border: none;
40 } 47 }
41 .tile.zoomed canvas { width: auto; height: auto; margin: 0 auto; } 48 .tile.zoomed canvas { margin: 0 auto; }
49 /* Below the zoomed tile, above the wall: the click target for unzoom. */
42 #shade { 50 #shade {
43 display: none; position: fixed; inset: 0; z-index: 5; background: #000a; 51 display: none; position: fixed; inset: 0; z-index: 10; background: #000a;
44 } 52 }
45 #shade.on { display: block; } 53 #shade.on { display: block; }
46 /* IME target: focusable, invisible, never display:none (that kills IME). */ 54 /* IME target: focusable, invisible, never display:none (that kills IME). */
web/mux.js
Old New
@@ -29,6 +29,21 @@ const KEY = {
29 // 64 KiB inbound bound (webhub.zig ws_buffer_len). 29 // 64 KiB inbound bound (webhub.zig ws_buffer_len).
30 const PASTE_CHUNK = 32 * 1024; 30 const PASTE_CHUNK = 32 * 1024;
31 31
32 // The reconnect schedule, and it is NOT a new one: this is client.zig's
33 // nextBackoffMs (src/client.zig — "The reconnect pacing, M7's numbers")
34 // spelled in JS because the page has no Zig. Iteration zero waits not at
35 // all, then 200ms doubling to a 2s cap, and no retry cap ever. The hub
36 // paces its daemon leg by that function; the browser leg pacing itself
37 // differently would make one flapping link behave two ways depending on
38 // which half broke. Change both or neither.
39 const nextBackoffMs = (prev) => (prev === 0 ? 200 : Math.min(prev * 2, 2000));
40
41 // How many straight failures to OPEN before a tile reads 'gone' rather
42 // than 'reconnecting'. Four is where the schedule hits its cap
43 // (0+200+400+800 = 1.4s of trying), which is long enough that restarting
44 // the hub under a live page never flashes 'gone'.
45 const GONE_AFTER_FAILURES = 4;
46
32 const FONT = '14px ui-monospace, monospace'; 47 const FONT = '14px ui-monospace, monospace';
33 const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000'; 48 const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000';
34 49
@@ -72,6 +87,10 @@ class Tile {
72 this.zoomed = false; 87 this.zoomed = false;
73 this.scrollPages = 0; 88 this.scrollPages = 0;
74 this.gotState = false; // JS mirror of the CLI's state_since_attach 89 this.gotState = false; // JS mirror of the CLI's state_since_attach
90 this.wsBackoffMs = 0; // browser-leg reconnect, client.zig's schedule
91 this.wsFailures = 0; // straight failures to OPEN, for the badge
92 this.wsOpened = false; // did THIS socket ever open?
93 this.drawScale = 0; // logical→CSS factor the backing store is sized for
75 94
76 this.el = document.createElement('div'); 95 this.el = document.createElement('div');
77 this.el.className = 'tile'; 96 this.el.className = 'tile';
@@ -93,16 +112,63 @@ class Tile {
93 } 112 }
94 113
95 async start() { 114 async start() {
96 const { instance } = await WebAssembly.instantiate(compiledCore, {}); 115 // NOT `const { instance }`: WebAssembly.instantiate has two return
116 // shapes, and which one you get depends on what you passed. Given
117 // BYTES it resolves to {module, instance}; given an already-compiled
118 // Module — which is what compiledCore is — it resolves to the
119 // Instance itself. Destructuring `instance` off an Instance yields
120 // undefined, and the TypeError lands inside an async method whose
121 // rejection nobody was reading. (verify.js passes bytes, so it takes
122 // the other shape and is right to destructure.)
123 const instance = await WebAssembly.instantiate(compiledCore, {});
97 this.core = instance.exports; 124 this.core = instance.exports;
98 if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; } 125 if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; }
99 this.sizeCanvas(); 126 this.sizeCanvas();
127 this.connect();
128 }
100 129
130 // The browser leg's own reconnect. The hub reconnects its daemon leg
131 // and narrates it; nothing was reconnecting THIS socket, so a restarted
132 // hub (or a laptop that slept) left every tile permanently 'gone' with
133 // a reload as the only cure. The wasm core survives across this, so the
134 // re-attach on `up` still quotes have_seq/have_epoch and resumes.
135 connect() {
136 this.wsOpened = false;
101 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); 137 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`);
102 this.ws.binaryType = 'arraybuffer'; 138 this.ws.binaryType = 'arraybuffer';
139 this.ws.onopen = () => {
140 this.wsOpened = true;
141 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule
142 this.wsFailures = 0;
143 // The hub narrates from here on: connecting → up.
144 this.setBadge('connecting', 'connecting');
145 };
103 this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data)); 146 this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data));
104 this.ws.onclose = () => this.setBadge('gone', 'gone'); 147 // onerror always precedes onclose; the badge is decided in one place.
105 this.ws.onerror = () => this.setBadge('gone', 'gone'); 148 this.ws.onerror = () => {};
149 this.ws.onclose = () => {
150 this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
151 const dead = this.wsFailures >= GONE_AFTER_FAILURES;
152 this.setBadge(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
153 const wait = this.wsBackoffMs;
154 this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs);
155 setTimeout(() => this.connect(), wait);
156 };
157 }
158
159 // The core is unusable — re-init and re-attach from nothing. Everything
160 // it held is gone, so the attach quotes (0,0) and the daemon answers
161 // with a snapshot.
162 resetCore(why) {
163 console.warn(`mux tile ${this.idx}: re-initializing the core (${why})`);
164 if (this.core.mux_init(80, 24) !== 0) {
165 this.setBadge('gone', 'core failed');
166 return;
167 }
168 this.gotState = false;
169 this.scrollPages = 0;
170 this.drawScale = 0; // force the backing store to be re-sized
171 this.sendAttach(true);
106 } 172 }
107 173
108 // --- wasm memory access, always through fresh views --- 174 // --- wasm memory access, always through fresh views ---
@@ -142,15 +208,40 @@ class Tile {
142 const n = this.core.mux_key_encode(keyId, cp, mods); 208 const n = this.core.mux_key_encode(keyId, cp, mods);
143 if (n > 0) this.sendFrame(MSG.input, this.outBytes()); 209 if (n > 0) this.sendFrame(MSG.input, this.outBytes());
144 } 210 }
145 sendPaste(text) { 211 // Plain UTF-8 bytes, no wrap. This is TYPING: an IME's compositionend
212 // hands over finished text, and finished text is what the user typed,
213 // however they reached it. Bracketing it would announce a paste to the
214 // application — vim would skip paste mode's indentation, a shell's
215 // bracketed-paste guard would refuse to run it.
216 sendText(text) {
146 const bytes = new TextEncoder().encode(text); 217 const bytes = new TextEncoder().encode(text);
147 for (let off = 0; off < bytes.length; off += PASTE_CHUNK) { 218 for (let off = 0; off < bytes.length; off += PASTE_CHUNK) {
148 const chunk = bytes.subarray(off, off + PASTE_CHUNK); 219 const chunk = bytes.subarray(off, off + PASTE_CHUNK);
149 if (!this.stage(chunk)) return; 220 if (!this.stage(chunk)) return;
150 if (this.core.mux_paste_encode(chunk.length) > 0) 221 if (this.core.mux_text_encode(chunk.length) > 0)
151 this.sendFrame(MSG.input, this.outBytes()); 222 this.sendFrame(MSG.input, this.outBytes());
152 } 223 }
153 } 224 }
225 // ONE wrap around the WHOLE paste, however many messages it takes:
226 // begin, N unwrapped chunks, end. keymap.zig's pasteInto says "the wrap
227 // and nothing else"; wrapping each 32 KiB chunk (which is what this did)
228 // put a paste-END in the middle of the pasted text, and an application
229 // that acts on the marker acts on it right there.
230 //
231 // The wrap is UNCONDITIONAL — a known v1 limitation. The daemon's
232 // pty_mode frame carries icanon/echo and not mode 2004, so nothing here
233 // can tell whether the application asked for brackets; one that did not
234 // sees the literal markers.
235 sendPaste(text) {
236 if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes());
237 // Unconditionally closed, including when a chunk fails to stage:
238 // leaving the application in paste mode is worse than a short paste.
239 try {
240 this.sendText(text);
241 } finally {
242 if (this.core.mux_paste_end() > 0) this.sendFrame(MSG.input, this.outBytes());
243 }
244 }
154 sendResizeIfDiffers() { 245 sendResizeIfDiffers() {
155 // Only the zoomed tile, and only when its computed size actually 246 // Only the zoomed tile, and only when its computed size actually
156 // differs from the session's grid (spec: passive wall, latest-wins 247 // differs from the session's grid (spec: passive wall, latest-wins
@@ -164,8 +255,19 @@ class Tile {
164 dv.setUint16(2, rows, true); 255 dv.setUint16(2, rows, true);
165 this.sendFrame(MSG.resize, p); 256 this.sendFrame(MSG.resize, p);
166 } 257 }
167 zoomCols() { return Math.max(2, Math.floor(window.innerWidth / METRICS.w)); } 258 // The zoomed tile's OWN box, not the window: index.html insets it
168 zoomRows() { return Math.max(2, Math.floor((window.innerHeight - 26) / METRICS.h)); } 259 // (3vh 4vw) so a ring of shade stays visible and clickable, and that
260 // ring is the only way out of zoom. Measuring the element rather than
261 // re-deriving the inset here means the CSS stays the single source of
262 // the geometry — change the inset and the grid follows.
263 zoomBox() {
264 const r = this.el.getBoundingClientRect();
265 const hdr = this.el.querySelector('header').getBoundingClientRect().height;
266 // border-box: the rect includes the 1px border on each side.
267 return { w: Math.max(0, r.width - 2), h: Math.max(0, r.height - hdr - 2) };
268 }
269 zoomCols() { return Math.max(2, Math.floor(this.zoomBox().w / METRICS.w)); }
270 zoomRows() { return Math.max(2, Math.floor(this.zoomBox().h / METRICS.h)); }
169 271
170 // --- wire in --- 272 // --- wire in ---
171 onMessage(bytes) { 273 onMessage(bytes) {
@@ -185,7 +287,20 @@ class Tile {
185 switch (type) { 287 switch (type) {
186 case MSG.snapshot: 288 case MSG.snapshot:
187 case MSG.delta: { 289 case MSG.delta: {
188 if (!this.stage(payload)) return; 290 // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone
291 // proves the attach was admitted — decodable or not — while a
292 // short snapshot proves nothing. Without this an undecodable
293 // delta followed by exit_status read as "session full" instead of
294 // as the shell exiting.
295 if (type === MSG.delta) this.gotState = true;
296 if (!this.stage(payload)) {
297 // Over the core's 256 KiB staging cap. Silently returning left
298 // the replica permanently behind the session; a re-attach at
299 // (0,0) costs one snapshot and is correct.
300 console.warn(`mux tile ${this.idx}: frame of ${payload.length} B over the staging cap, re-attaching`);
301 this.sendAttach(true);
302 return;
303 }
189 const r = this.core.mux_apply_frame(type, payload.length); 304 const r = this.core.mux_apply_frame(type, payload.length);
190 if (r === 0) { 305 if (r === 0) {
191 this.gotState = true; 306 this.gotState = true;
@@ -193,7 +308,13 @@ class Tile {
193 if (this.scrollPages === 0) this.paintLive(); 308 if (this.scrollPages === 0) this.paintLive();
194 return; 309 return;
195 } 310 }
196 if (r === 1) this.sendAttach(true); // RESYNC: quote (0,0) 311 if (r === 1) { this.sendAttach(true); return; } // RESYNC: quote (0,0)
312 // Negative is the core refusing: -1 uninitialized or readout
313 // buffers left stale by a failed grid move (wasm_core.zig
314 // documents that one as fatal-re-init), -2 over the cap, -3 a
315 // payload it will not replay. None of them leave a replica that
316 // can be trusted to keep painting.
317 this.resetCore(`mux_apply_frame(0x${type.toString(16)}) = ${r}`);
197 return; 318 return;
198 } 319 }
199 case MSG.exit_status: { 320 case MSG.exit_status: {
@@ -217,14 +338,49 @@ class Tile {
217 } 338 }
218 339
219 // --- painting --- 340 // --- painting ---
341 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it
342 // must be drawn small. Two ways to do that, and only one is legible:
343 // draw at full logical size into a big bitmap and let CSS squash it
344 // (every glyph resampled, twice over on a HiDPI screen), or draw
345 // THROUGH a scaled context so the glyphs rasterize once, at device
346 // resolution, at the size they will actually be seen. This is the
347 // second. Everything below paints in logical cell coordinates and the
348 // transform does the rest.
220 sizeCanvas() { 349 sizeCanvas() {
221 const cols = this.core.mux_cols(), rows = this.core.mux_rows(); 350 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
222 const w = cols * METRICS.w, h = rows * METRICS.h; 351 const logicalW = cols * METRICS.w, logicalH = rows * METRICS.h;
223 if (this.canvas.width !== w || this.canvas.height !== h) { 352 const dpr = window.devicePixelRatio || 1;
224 this.canvas.width = w; 353 // The width the layout gives this tile. The zoomed tile computed its
225 this.canvas.height = h; 354 // grid FROM that box, so it lands at scale 1; a wall tile scales down.
226 this.core.mux_mark_all_dirty(); 355 // Never up: stretching a terminal is not a feature.
356 const availW = this.zoomed ? this.zoomBox().w : this.el.clientWidth;
357 const scale = availW > 0 ? Math.min(1, availW / logicalW) : 1;
358 const cssW = logicalW * scale, cssH = logicalH * scale;
359 // The backing store is CSS pixels × devicePixelRatio: the resolution
360 // the screen can actually show, and no more.
361 const bw = Math.max(1, Math.round(cssW * dpr));
362 const bh = Math.max(1, Math.round(cssH * dpr));
363 if (this.canvas.width !== bw || this.canvas.height !== bh || this.drawScale !== scale) {
364 this.canvas.width = bw;
365 this.canvas.height = bh;
366 this.canvas.style.width = `${cssW}px`;
367 this.canvas.style.height = `${cssH}px`;
368 this.drawScale = scale;
369 this.core.mux_mark_all_dirty(); // assigning width cleared the bitmap
227 } 370 }
371 // Re-applied every paint, not just on resize: assigning canvas.width
372 // resets the transform, and a paint that skipped this would draw one
373 // grid's worth of cells into the top-left corner of the bitmap.
374 this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
375 }
376 // The tile's CSS width moved (a window resize reflows the wall grid):
377 // re-fit and repaint from what the core already holds. No frame is
378 // requested and nothing is sent — a wall tile is passive.
379 reflow() {
380 if (!this.core) return;
381 this.core.mux_mark_all_dirty();
382 if (this.scrollPages === 0) this.paintLive();
383 else this.paintScroll();
228 } 384 }
229 paintLive() { 385 paintLive() {
230 this.sizeCanvas(); 386 this.sizeCanvas();
@@ -343,18 +499,27 @@ function zoom(tile) {
343 shade.classList.add('on'); 499 shade.classList.add('on');
344 ime.focus(); 500 ime.focus();
345 tile.sendResizeIfDiffers(); 501 tile.sendResizeIfDiffers();
502 // Re-fit now rather than at the next frame: an idle session sends
503 // nothing, and a tile that zoomed but kept its wall-sized canvas would
504 // sit there postage-stamped until something happened to be typed.
505 tile.reflow();
346 } 506 }
347 function unzoom() { 507 function unzoom() {
348 if (!zoomedTile) return; 508 if (!zoomedTile) return;
349 zoomedTile.exitScroll(); 509 const was = zoomedTile;
350 zoomedTile.zoomed = false; 510 was.exitScroll();
351 zoomedTile.el.classList.remove('zoomed'); 511 was.zoomed = false;
512 was.el.classList.remove('zoomed');
352 zoomedTile = null; 513 zoomedTile = null;
353 shade.classList.remove('on'); 514 shade.classList.remove('on');
354 ime.blur(); 515 ime.blur();
355 // Unzoom sends NOTHING: the session stays attached, the grid stays 516 // Unzoom sends NOTHING: the session stays attached, the grid stays
356 // where it is (spec). The tile keeps painting as a wall tile. 517 // where it is (spec). The tile keeps painting as a wall tile — at the
518 // wall's scale, which is the one thing that does have to be redone.
519 was.reflow();
357 } 520 }
521 // The visible ring of shade around the inset tile IS the unzoom control.
522 // Escape is deliberately not bound: it belongs to the application.
358 shade.addEventListener('click', unzoom); 523 shade.addEventListener('click', unzoom);
359 524
360 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). 525 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
@@ -387,10 +552,18 @@ document.addEventListener('paste', (ev) => {
387 if (text) zoomedTile.sendPaste(text); 552 if (text) zoomedTile.sendPaste(text);
388 }); 553 });
389 ime.addEventListener('compositionend', (ev) => { 554 ime.addEventListener('compositionend', (ev) => {
390 if (zoomedTile && ev.data) zoomedTile.sendPaste(ev.data); 555 // sendText, NOT sendPaste: composed text is typing (see sendText).
556 if (zoomedTile && ev.data) zoomedTile.sendText(ev.data);
391 ime.value = ''; 557 ime.value = '';
392 }); 558 });
393 window.addEventListener('resize', () => zoomedTile?.sendResizeIfDiffers()); 559 const tiles = [];
560 window.addEventListener('resize', () => {
561 // The zoomed tile may claim a new grid; every wall tile just re-fits to
562 // the width the reflowed grid gave it.
563 zoomedTile?.sendResizeIfDiffers();
564 for (const t of tiles) if (t !== zoomedTile) t.reflow();
565 zoomedTile?.reflow();
566 });
394 567
395 // --- boot --- 568 // --- boot ---
396 (async function boot() { 569 (async function boot() {
@@ -399,6 +572,13 @@ window.addEventListener('resize', () => zoomedTile?.sendResizeIfDiffers());
399 const wall = document.getElementById('wall'); 572 const wall = document.getElementById('wall');
400 for (let i = 0; i < labels.length; i++) { 573 for (let i = 0; i < labels.length; i++) {
401 const tile = new Tile(i, labels[i], wall); 574 const tile = new Tile(i, labels[i], wall);
402 tile.start(); 575 tiles.push(tile);
576 // start() is async: instantiate or mux_init can fail, and an unhandled
577 // rejection left the tile stuck on 'connecting' with the reason only
578 // in the console's rejection noise.
579 tile.start().catch((err) => {
580 tile.setBadge('gone', 'gone');
581 console.error(`mux tile ${i} (${labels[i]}): start failed`, err);
582 });
403 } 583 }
404 })(); 584 })();
web/verify.js
Old New
@@ -177,10 +177,31 @@ async function main() {
177 check('key ctrl-up seq', outBytes().toString('latin1'), '\x1b[1;5A'); 177 check('key ctrl-up seq', outBytes().toString('latin1'), '\x1b[1;5A');
178 check('key unknown', e.mux_key_encode(99, 0, 0), -3); 178 check('key unknown', e.mux_key_encode(99, 0, 0), -3);
179 179
180 // --- paste wrap --- 180 // --- text vs paste: the markers are separate exports on purpose ---
181 // Composed IME text is TYPING and goes out raw; a paste is the same
182 // bytes with ONE wrap around the whole of it, however many chunks it
183 // takes. Reassembling here is what proves a chunked paste carries
184 // exactly one begin and one end.
181 stage(Buffer.from('two\nlines')); 185 stage(Buffer.from('two\nlines'));
182 check('paste len', e.mux_paste_encode(9), 9 + 12); 186 check('text len', e.mux_text_encode(9), 9);
183 check('paste bytes', outBytes().toString('latin1'), '\x1b[200~two\nlines\x1b[201~'); 187 check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines');
188
189 check('paste begin len', e.mux_paste_begin(), 6);
190 const pasteWire = [outBytes().toString('latin1')];
191 for (const chunk of ['two\n', 'lines']) {
192 stage(Buffer.from(chunk));
193 check(`paste chunk ${chunk.length}`, e.mux_text_encode(chunk.length), chunk.length);
194 pasteWire.push(outBytes().toString('latin1'));
195 }
196 check('paste end len', e.mux_paste_end(), 6);
197 pasteWire.push(outBytes().toString('latin1'));
198 check('paste wire', pasteWire.join(''), '\x1b[200~two\nlines\x1b[201~');
199 // The markers themselves, so a drift in keymap.zig's spelling is caught
200 // here and not by an application quietly re-indenting.
201 check('paste begin bytes', pasteWire[0], '\x1b[200~');
202 check('paste end bytes', pasteWire[pasteWire.length - 1], '\x1b[201~');
203 // The staging cap is the refusal, not a truncation.
204 check('text over cap', e.mux_text_encode(e.mux_input_cap() + 1), -2);
184 205
185 // --- scroll scratch: never touches the live replica --- 206 // --- scroll scratch: never touches the live replica ---
186 check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates 207 check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates
@@ -207,6 +228,19 @@ async function main() {
207 // the daemon sends in one frame for the wall's grids. 228 // the daemon sends in one frame for the wall's grids.
208 check('input cap', e.mux_input_cap(), 256 * 1024); 229 check('input cap', e.mux_input_cap(), 256 * 1024);
209 230
231 // --- the shell's ACTUAL call list, read out of mux.js ---
232 // Everything above pins exports this file happens to name. This pins
233 // the ones the page names, which is the pair that has to agree: an
234 // export renamed on the Zig side is a TypeError in the browser and
235 // nowhere else, and the wasm builds fine without it.
236 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
237 const called = [...new Set(
238 [...shell.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]),
239 )].sort();
240 check('shell calls something', called.length > 0, true);
241 const absent = called.filter((n) => typeof e[n] !== 'function');
242 check(`shell calls only real exports (${called.length} of them)`, absent.join(','), '');
243
210 // --- deinit / re-init --- 244 // --- deinit / re-init ---
211 e.mux_deinit(); 245 e.mux_deinit();
212 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); 246 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1);