a38eb053
feat: make web paste respect terminal mode
a73x 2026-08-18 12:27
Commit message
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(); |