a73x

a38eb053

feat: make web paste respect terminal mode

a73x   2026-08-18 12:27

Commit message
feat: make web paste respect terminal mode

web/mux.js
Old New
@@ -12,7 +12,10 @@
12 const MSG = { 12 const MSG = {
13 attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05, 13 attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05,
14 snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87, 14 snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87,
15 pty_mode: 0x88, 15 pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f,
16 };
17 const CLIENT_ACTION = {
18 ignored: 0, terminalModes: 1, clipboard: 2, bell: 3,
16 }; 19 };
17 const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; 20 const ENV_FRAME = 0x00, ENV_CONTROL = 0x01;
18 21
@@ -296,16 +299,10 @@ class Tile {
296 this.sendFrame(MSG.input, this.outBytes()); 299 this.sendFrame(MSG.input, this.outBytes());
297 } 300 }
298 } 301 }
299 // ONE wrap around the WHOLE paste, however many messages it takes: 302 // At most ONE wrap around the WHOLE paste, however many messages it
300 // begin, N unwrapped chunks, end. keymap.zig's pasteInto says "the wrap 303 // takes: optional begin, N unwrapped chunks, optional end. The WASM
301 // and nothing else"; wrapping each 32 KiB chunk (which is what this did) 304 // semantic core owns the sampled mode-2004 policy; the shell sends only
302 // put a paste-END in the middle of the pasted text, and an application 305 // the marker frames it returns.
303 // that acts on the marker acts on it right there.
304 //
305 // The wrap is UNCONDITIONAL — a known v1 limitation. The daemon's
306 // pty_mode frame carries icanon/echo and not mode 2004, so nothing here
307 // can tell whether the application asked for brackets; one that did not
308 // sees the literal markers.
309 sendPaste(text) { 306 sendPaste(text) {
310 if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes()); 307 if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes());
311 // Closed even when a chunk fails to STAGE — that is the case this 308 // Closed even when a chunk fails to STAGE — that is the case this
@@ -418,12 +415,21 @@ class Tile {
418 if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); 415 if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll();
419 return; 416 return;
420 } 417 }
418 case MSG.term_modes:
419 case MSG.term_event: {
420 if (!this.stage(payload)) return;
421 const action = this.core.mux_client_frame(type, payload.length);
422 if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();
423 return;
424 }
421 case MSG.pty_mode: // no prediction in the web client (spec non-goal) 425 case MSG.pty_mode: // no prediction in the web client (spec non-goal)
422 default: 426 default:
423 return; 427 return;
424 } 428 }
425 } 429 }
426 430
431 onClipboardEffect() {}
432
427 // --- painting --- 433 // --- painting ---
428 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it 434 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it
429 // must be drawn small. Two ways to do that, and only one is legible: 435 // must be drawn small. Two ways to do that, and only one is legible:
web/verify.js
Old New
@@ -293,6 +293,61 @@ async function main() {
293 // export renamed on the Zig side is a TypeError in the browser and 293 // export renamed on the Zig side is a TypeError in the browser and
294 // nowhere else, and the wasm builds fine without it. 294 // nowhere else, and the wasm builds fine without it.
295 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); 295 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
296
297 // The browser must route sampled terminal state and host effects through
298 // the same semantic core as the CLI. Pin the real page source here: this
299 // verifier otherwise exercises only the WASM half of that handshake.
300 const msgDecl = shell.match(/const MSG\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? '';
301 check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true);
302 check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true);
303
304 const actionDecl = shell.match(/const CLIENT_ACTION\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? '';
305 check(
306 'shell pins shared client action ABI',
307 /\bignored\s*:\s*0\b/.test(actionDecl)
308 && /\bterminalModes\s*:\s*1\b/.test(actionDecl)
309 && /\bclipboard\s*:\s*2\b/.test(actionDecl)
310 && /\bbell\s*:\s*3\b/.test(actionDecl),
311 true,
312 );
313
314 const semanticCases = shell.match(
315 /case MSG\.term_modes:\s*case MSG\.term_event:\s*\{([\s\S]*?)\n\s*\}\s*case MSG\.pty_mode:/,
316 )?.[1] ?? '';
317 check('shell shares one semantic frame route', semanticCases.length > 0, true);
318 check(
319 'shell stages semantic payload before WASM',
320 /if\s*\(\s*!this\.stage\(payload\)\s*\)\s*return\s*;/.test(semanticCases),
321 true,
322 );
323 check(
324 'shell passes semantic type and length to WASM',
325 /(?:const|let)\s+action\s*=\s*this\.core\.mux_client_frame\(type,\s*payload\.length\)\s*;/.test(semanticCases),
326 true,
327 );
328 check(
329 'shell dispatches only the clipboard action',
330 /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases)
331 && (semanticCases.match(/this\.onClipboardEffect\(\)/g) ?? []).length === 1,
332 true,
333 );
334 check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false);
335 check(
336 'shell has no duplicate bracketed-paste state',
337 /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(shell),
338 false,
339 );
340 check('shell clipboard effect is an empty placeholder', /onClipboardEffect\s*\(\s*\)\s*\{\s*\}/.test(shell), true);
341
342 const sendPaste = shell.match(/sendPaste\(text\)\s*\{([\s\S]*?)\n\s*\}\n\s*sendResizeIfDiffers/)?.[1] ?? '';
343 check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1);
344 check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1);
345 check(
346 'shell paste wraps the whole sendText with optional markers',
347 /if\s*\(this\.core\.mux_paste_begin\(\)\s*>\s*0\)[\s\S]*?try\s*\{\s*this\.sendText\(text\)\s*;\s*\}\s*finally\s*\{\s*if\s*\(this\.core\.mux_paste_end\(\)\s*>\s*0\)/.test(sendPaste),
348 true,
349 );
350
296 const called = [...new Set( 351 const called = [...new Set(
297 [...shell.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), 352 [...shell.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]),
298 )].sort(); 353 )].sort();