a73x

cfc2e619

feat: add retained web terminal selection

a73x   2026-08-18 12:27

Commit message
feat: add retained web terminal selection

web/index.html
Old New
@@ -53,7 +53,7 @@
53 .tile.zoomed { 53 .tile.zoomed {
54 position: fixed; inset: 3vh 4vw; z-index: 20; cursor: default; 54 position: fixed; inset: 3vh 4vw; z-index: 20; cursor: default;
55 } 55 }
56 .tile.zoomed canvas { margin: 0 auto; } 56 .tile.zoomed canvas { margin: 0 auto; cursor: text; touch-action: none; }
57 /* Below the zoomed tile, above the wall: the click target for unzoom. */ 57 /* Below the zoomed tile, above the wall: the click target for unzoom. */
58 #shade { 58 #shade {
59 display: none; position: fixed; inset: 0; z-index: 10; background: #000a; 59 display: none; position: fixed; inset: 0; z-index: 10; background: #000a;
web/mux.js
Old New
@@ -11,11 +11,12 @@
11 // Wire bytes (protocol.zig MsgType / webhub.zig envelope). 11 // Wire bytes (protocol.zig MsgType / webhub.zig envelope).
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 selection_req: 0x0b,
14 snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87, 15 snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87,
15 pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, 16 pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90,
16 }; 17 };
17 const CLIENT_ACTION = { 18 const CLIENT_ACTION = {
18 ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, 19 ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4,
19 }; 20 };
20 const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; 21 const ENV_FRAME = 0x00, ENV_CONTROL = 0x01;
21 22
@@ -130,6 +131,12 @@ class Tile {
130 this.pendingClipboard = null; 131 this.pendingClipboard = null;
131 this.clipboardVersion = 0; 132 this.clipboardVersion = 0;
132 this.clipboardWriteActive = false; 133 this.clipboardWriteActive = false;
134 this.viewStartRow = 0;
135 this.selection = null; // {anchor, active, requestId, text}
136 this.nextSelectionId = 0;
137 this.drag = null;
138 this.lastPointerY = null;
139 this.selectionScrollTimer = null;
133 140
134 this.el = document.createElement('div'); 141 this.el = document.createElement('div');
135 this.el.className = 'tile'; 142 this.el.className = 'tile';
@@ -154,6 +161,12 @@ class Tile {
154 if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } 161 if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); }
155 // Wall tiles don't scroll (spec): the event falls through to the page. 162 // Wall tiles don't scroll (spec): the event falls through to the page.
156 }, { passive: false }); 163 }, { passive: false });
164 this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev));
165 this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev));
166 this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev));
167 this.canvas.addEventListener('pointercancel', (ev) => {
168 if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
169 });
157 } 170 }
158 171
159 async start() { 172 async start() {
@@ -435,16 +448,21 @@ class Tile {
435 } 448 }
436 case MSG.scrollback_chunk: { 449 case MSG.scrollback_chunk: {
437 if (this.scrollPages === 0 || payload.length < 6) return; 450 if (this.scrollPages === 0 || payload.length < 6) return;
451 this.viewStartRow = new DataView(
452 payload.buffer, payload.byteOffset, 6,
453 ).getUint32(0, true);
438 const rows = payload.subarray(6); // echoed start+count stripped 454 const rows = payload.subarray(6); // echoed start+count stripped
439 if (!this.stage(rows)) return; 455 if (!this.stage(rows)) return;
440 if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll(); 456 if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll();
441 return; 457 return;
442 } 458 }
443 case MSG.term_modes: 459 case MSG.term_modes:
444 case MSG.term_event: { 460 case MSG.term_event:
461 case MSG.selection_reply: {
445 if (!this.stage(payload)) return; 462 if (!this.stage(payload)) return;
446 const action = this.core.mux_client_frame(type, payload.length); 463 const action = this.core.mux_client_frame(type, payload.length);
447 if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); 464 if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();
465 else if (action === CLIENT_ACTION.selection) this.onSelectionReply();
448 return; 466 return;
449 } 467 }
450 case MSG.pty_mode: // no prediction in the web client (spec non-goal) 468 case MSG.pty_mode: // no prediction in the web client (spec non-goal)
@@ -520,6 +538,104 @@ class Tile {
520 return this.tryClipboardWrite(this.clipboardVersion, false); 538 return this.tryClipboardWrite(this.clipboardVersion, false);
521 } 539 }
522 540
541 onSelectionReply() {
542 if (!this.selection) return;
543 // A Zig u32 result reaches JavaScript as a signed WASM i32.
544 if ((this.core.mux_selection_id() >>> 0) !== this.selection.requestId) return;
545 if (this.core.mux_selection_status() !== 0) {
546 this.selection.text = null;
547 this.copyButton.className = 'copy-request on error';
548 this.copyButton.textContent = 'Selection unavailable';
549 return;
550 }
551 const ptr = this.core.mux_selection_ptr();
552 const len = this.core.mux_selection_len();
553 // Selection text borrows the staging buffer. Snapshot it before any
554 // other WASM call or asynchronous clipboard operation can replace it.
555 const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
556 try {
557 this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
558 } catch (_) {
559 this.selection.text = null;
560 }
561 }
562
563 // --- retained selection ---
564 cellAtPointer(ev, clampY = true) {
565 const rect = this.canvas.getBoundingClientRect();
566 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
567 const x = Math.max(0, Math.min(cols - 1,
568 Math.floor((ev.clientX - rect.left) / this.drawScale / METRICS.w)));
569 let y = Math.floor((ev.clientY - rect.top) / this.drawScale / METRICS.h);
570 if (clampY) y = Math.max(0, Math.min(rows - 1, y));
571 return { col: x, viewRow: y, row: this.viewStartRow + y };
572 }
573
574 orderedSelection() {
575 if (!this.selection) return null;
576 const a = this.selection.anchor, b = this.selection.active;
577 if (a.row < b.row || (a.row === b.row && a.col <= b.col)) return [a, b];
578 return [b, a];
579 }
580
581 clearSelection(repaint = true) {
582 this.selection = null;
583 this.drag = null;
584 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
585 this.selectionScrollTimer = null;
586 if (repaint && this.core) this.reflow();
587 }
588
589 beginSelection(ev) {
590 if (!this.zoomed || ev.button !== 0) return;
591 ev.preventDefault();
592 const point = this.cellAtPointer(ev);
593 this.clearSelection(false);
594 this.selection = { anchor: point, active: point, requestId: 0, text: null };
595 this.drag = { pointerId: ev.pointerId, moved: false };
596 this.lastPointerY = ev.clientY;
597 this.canvas.setPointerCapture(ev.pointerId);
598 this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120);
599 this.reflow();
600 }
601
602 moveSelection(ev) {
603 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
604 ev.preventDefault();
605 this.lastPointerY = ev.clientY;
606 const point = this.cellAtPointer(ev);
607 if (point.row !== this.selection.active.row || point.col !== this.selection.active.col)
608 this.drag.moved = true;
609 this.selection.active = point;
610 this.reflow();
611 }
612
613 endSelection(ev) {
614 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
615 ev.preventDefault();
616 const moved = this.drag.moved;
617 if (this.canvas.hasPointerCapture(ev.pointerId)) this.canvas.releasePointerCapture(ev.pointerId);
618 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
619 this.selectionScrollTimer = null;
620 this.drag = null;
621 if (!moved) { this.clearSelection(); return; }
622
623 this.nextSelectionId = (this.nextSelectionId + 1) >>> 0;
624 if (this.nextSelectionId === 0) this.nextSelectionId = 1;
625 this.selection.requestId = this.nextSelectionId;
626 const a = this.selection.anchor, b = this.selection.active;
627 const n = this.core.mux_selection_request(
628 this.nextSelectionId, a.row, a.col, b.row, b.col,
629 );
630 if (n === 16) this.sendFrame(MSG.selection_req, this.outBytes());
631 else this.clearSelection();
632 }
633
634 // Task 6 gives this timer its scroll policy. Keeping the callback valid
635 // here makes the pointer lifecycle complete without changing viewport
636 // behavior in this slice.
637 autoScrollSelection() {}
638
523 // --- painting --- 639 // --- painting ---
524 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it 640 // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it
525 // must be drawn small. Two ways to do that, and only one is legible: 641 // must be drawn small. Two ways to do that, and only one is legible:
@@ -566,16 +682,19 @@ class Tile {
566 else this.paintScroll(); 682 else this.paintScroll();
567 } 683 }
568 paintLive() { 684 paintLive() {
685 this.viewStartRow = this.core.mux_history_rows();
569 this.sizeCanvas(); 686 this.sizeCanvas();
570 const n = this.core.mux_read_viewport(); 687 const n = this.core.mux_read_viewport();
571 for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i)); 688 for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i));
572 this.paintCursor(); 689 this.paintCursor();
690 this.paintSelection();
573 } 691 }
574 paintScroll() { 692 paintScroll() {
575 this.sizeCanvas(); 693 this.sizeCanvas();
576 this.core.mux_read_scroll_viewport(); 694 this.core.mux_read_scroll_viewport();
577 const rows = this.core.mux_rows(); 695 const rows = this.core.mux_rows();
578 for (let y = 0; y < rows; y++) this.paintRow(y); 696 for (let y = 0; y < rows; y++) this.paintRow(y);
697 this.paintSelection();
579 } 698 }
580 paintRow(y) { 699 paintRow(y) {
581 const cols = this.core.mux_cols(); 700 const cols = this.core.mux_cols();
@@ -625,6 +744,25 @@ class Tile {
625 this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); 744 this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h);
626 } 745 }
627 746
747 paintSelection() {
748 const ordered = this.orderedSelection();
749 if (!ordered) return;
750 const [start, end] = ordered;
751 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
752 this.ctx.fillStyle = '#6ab0e055';
753 for (let y = 0; y < rows; y++) {
754 const screenRow = this.viewStartRow + y;
755 if (screenRow < start.row || screenRow > end.row) continue;
756 const first = screenRow === start.row ? start.col : 0;
757 const last = screenRow === end.row ? end.col : cols - 1;
758 if (last < first) continue;
759 this.ctx.fillRect(
760 first * METRICS.w, y * METRICS.h,
761 (last - first + 1) * METRICS.w, METRICS.h,
762 );
763 }
764 }
765
628 // --- scrollback --- 766 // --- scrollback ---
629 onWheel(ev) { 767 onWheel(ev) {
630 const rows = this.core.mux_rows(); 768 const rows = this.core.mux_rows();
@@ -644,6 +782,7 @@ class Tile {
644 } 782 }
645 this.renderBadge(); 783 this.renderBadge();
646 const start = this.core.mux_scroll_start(this.scrollPages, rows); 784 const start = this.core.mux_scroll_start(this.scrollPages, rows);
785 this.viewStartRow = start;
647 const p = new Uint8Array(6); 786 const p = new Uint8Array(6);
648 const dv = new DataView(p.buffer); 787 const dv = new DataView(p.buffer);
649 dv.setUint32(0, start, true); 788 dv.setUint32(0, start, true);
web/verify.js
Old New
@@ -336,6 +336,8 @@ function browserShell(source) {
336 this.value = ''; 336 this.value = '';
337 this.clientWidth = 640; 337 this.clientWidth = 640;
338 this.type = ''; 338 this.type = '';
339 this.rect = { left: 0, top: 0, width: 640, height: 480 };
340 this.capturedPointers = new Set();
339 } 341 }
340 set innerHTML(value) { 342 set innerHTML(value) {
341 this._innerHTML = value; 343 this._innerHTML = value;
@@ -378,7 +380,10 @@ function browserShell(source) {
378 } 380 }
379 addEventListener(type, fn) { this.listeners.set(type, fn); } 381 addEventListener(type, fn) { this.listeners.set(type, fn); }
380 dispatchEvent(type, event) { return this.listeners.get(type)?.(event); } 382 dispatchEvent(type, event) { return this.listeners.get(type)?.(event); }
381 getBoundingClientRect() { return { width: 640, height: 480 }; } 383 getBoundingClientRect() { return this.rect; }
384 setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); }
385 hasPointerCapture(pointerId) { return this.capturedPointers.has(pointerId); }
386 releasePointerCapture(pointerId) { this.capturedPointers.delete(pointerId); }
382 getContext() { 387 getContext() {
383 return { 388 return {
384 measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), 389 measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }),
@@ -403,12 +408,15 @@ function browserShell(source) {
403 dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, 408 dispatchEvent(type, event) { return documentListeners.get(type)?.(event); },
404 }; 409 };
405 const timers = []; 410 const timers = [];
411 const intervals = [];
406 const navigator = { clipboard: undefined }; 412 const navigator = { clipboard: undefined };
407 const context = vm.createContext({ 413 const context = vm.createContext({
408 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, 414 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder,
409 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, 415 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document,
410 location: { host: 'verify.invalid' }, navigator, 416 location: { host: 'verify.invalid' }, navigator,
411 setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, 417 setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
418 setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; },
419 clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; },
412 window: { addEventListener() {}, devicePixelRatio: 1 }, 420 window: { addEventListener() {}, devicePixelRatio: 1 },
413 WebSocket: class { static OPEN = 1; }, 421 WebSocket: class { static OPEN = 1; },
414 atob: globalThis.atob, 422 atob: globalThis.atob,
@@ -418,7 +426,7 @@ function browserShell(source) {
418 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + 426 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' +
419 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' + 427 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' +
420 '};').runInContext(context); 428 '};').runInContext(context);
421 return { ...context.__verify, context, document, elements, navigator, timers }; 429 return { ...context.__verify, context, document, elements, navigator, timers, intervals };
422 } 430 }
423 431
424 async function flushPromises() { 432 async function flushPromises() {
@@ -702,6 +710,305 @@ async function verifyClipboardShell(shell, html) {
702 check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true); 710 check('page gives failed copy a distinct style', errorRule.test(executableHtmlCss), true);
703 } 711 }
704 712
713 function verifySelectionShell(shell, html) {
714 const h = browserShell(shell);
715 const wall = h.document.createElement('div');
716 const tile = new h.Tile(3, 'selection', wall, '');
717 const initialState = tile.viewStartRow === 0
718 && tile.selection === null
719 && tile.nextSelectionId === 0
720 && tile.drag === null
721 && tile.lastPointerY === null
722 && tile.selectionScrollTimer === null;
723 check('tile initializes retained selection state', initialState, true);
724 check(
725 'tile binds all pointer selection events',
726 ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']
727 .every((name) => tile.canvas.listeners.has(name)),
728 true,
729 );
730
731 const executableHtmlCss = executableCss(html);
732 const selectionCanvasRule = /\.tile\.zoomed\s+canvas\s*\{(?=[^}]*\bcursor\s*:\s*text\s*;)(?=[^}]*\btouch-action\s*:\s*none\s*;)[^}]*\}/s;
733 const commentedSelectionRule = executableCss(`/*
734 .tile.zoomed canvas { cursor: text; touch-action: none; }
735 */`);
736 check('CSS masker rejects commented selection-canvas decoy', selectionCanvasRule.test(commentedSelectionRule), false);
737 check('zoomed canvas uses a text cursor and owns touch dragging', selectionCanvasRule.test(executableHtmlCss), true);
738
739 const selectionMethods = [
740 'cellAtPointer', 'orderedSelection', 'clearSelection', 'paintSelection',
741 'beginSelection', 'moveSelection', 'endSelection', 'onSelectionReply',
742 ];
743 check(
744 'tile exposes retained-selection behavior',
745 selectionMethods.every((name) => typeof tile[name] === 'function'),
746 true,
747 );
748 if (!initialState || !selectionMethods.every((name) => typeof tile[name] === 'function')) return;
749
750 const makeTile = () => {
751 const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), '');
752 const memory = { buffer: new ArrayBuffer(1024) };
753 let requestResult = 16;
754 let result = { id: 0, status: 3, ptr: 96, len: 0 };
755 const requestCalls = [];
756 selected.core = {
757 memory,
758 mux_cols: () => 10,
759 mux_rows: () => 5,
760 mux_history_rows: () => 30,
761 mux_mark_all_dirty() {},
762 mux_read_viewport: () => 0,
763 mux_read_scroll_viewport: () => 5,
764 mux_dirty_row: (i) => i,
765 mux_cursor_x: () => 0,
766 mux_cursor_y: () => 0,
767 mux_viewport_ptr: () => 512,
768 mux_input_cap: () => 1024,
769 mux_input_ptr: () => 0,
770 mux_output_ptr: () => 256,
771 mux_output_len: () => requestResult === 16 ? 16 : 0,
772 mux_scroll_start: (pages, rows) => 30 - pages * rows,
773 mux_scroll_feed: () => 0,
774 mux_client_frame: (type) => type === 0x90 ? 4 : 0,
775 mux_selection_id: () => result.id | 0,
776 mux_selection_status: () => result.status,
777 mux_selection_ptr: () => result.ptr,
778 mux_selection_len: () => result.len,
779 mux_selection_request: (id, ar, ac, br, bc) => {
780 requestCalls.push([id >>> 0, ar, ac, br, bc]);
781 if (requestResult !== 16) return requestResult;
782 const view = new DataView(memory.buffer, 256, 16);
783 view.setUint32(0, id, true);
784 view.setUint32(4, ar, true);
785 view.setUint16(8, ac, true);
786 view.setUint32(10, br, true);
787 view.setUint16(14, bc, true);
788 return 16;
789 },
790 };
791 selected.zoomed = true;
792 selected.drawScale = 1;
793 selected.canvas.rect = { left: 10, top: 20, width: 80, height: 70 };
794 const sent = [];
795 selected.sendFrame = (type, payload) => sent.push({ type, payload: Uint8Array.from(payload) });
796 let reflows = 0;
797 selected.reflow = () => { reflows++; };
798 return {
799 tile: selected, memory, requestCalls, sent,
800 setRequestResult(value) { requestResult = value; },
801 setResult(id, status, textBytes) {
802 const bytes = Uint8Array.from(textBytes);
803 new Uint8Array(memory.buffer).set(bytes, 96);
804 result = { id, status, ptr: 96, len: bytes.length };
805 },
806 reflows: () => reflows,
807 };
808 };
809 const pointer = (pointerId, col, viewRow, button = 0) => {
810 let prevented = false;
811 return {
812 pointerId, button,
813 clientX: 10 + col * 8 + 1,
814 clientY: 20 + viewRow * 14 + 1,
815 preventDefault() { prevented = true; },
816 wasPrevented: () => prevented,
817 };
818 };
819
820 const coordinate = makeTile();
821 coordinate.tile.viewStartRow = 40;
822 check(
823 'pointer coordinates include the retained viewport start',
824 JSON.stringify(coordinate.tile.cellAtPointer(pointer(1, 3, 2))),
825 JSON.stringify({ col: 3, viewRow: 2, row: 42 }),
826 );
827 check(
828 'pointer coordinates clamp outside the visible grid',
829 JSON.stringify(coordinate.tile.cellAtPointer({ clientX: -50, clientY: 999 })),
830 JSON.stringify({ col: 0, viewRow: 4, row: 44 }),
831 );
832
833 const ignored = makeTile();
834 ignored.tile.zoomed = false;
835 ignored.tile.canvas.dispatchEvent('pointerdown', pointer(1, 2, 2));
836 ignored.tile.zoomed = true;
837 ignored.tile.canvas.dispatchEvent('pointerdown', pointer(2, 2, 2, 1));
838 check('unzoomed and non-left pointer downs are ignored', ignored.tile.selection, null);
839 check('ignored pointer downs capture nothing', ignored.tile.canvas.capturedPointers.size, 0);
840
841 const forward = makeTile();
842 forward.tile.viewStartRow = 30;
843 const down = pointer(7, 2, 1);
844 const move = pointer(7, 5, 3);
845 const up = pointer(7, 5, 3);
846 forward.tile.canvas.dispatchEvent('pointerdown', down);
847 check('left drag start prevents native canvas selection', down.wasPrevented(), true);
848 check('left drag captures its pointer', forward.tile.canvas.hasPointerCapture(7), true);
849 check('left drag starts the 120ms scroll timer', h.intervals.at(-1)?.ms, 120);
850 forward.tile.canvas.dispatchEvent('pointermove', move);
851 check('drag move prevents native pointer behavior', move.wasPrevented(), true);
852 check(
853 'forward drag retains absolute anchor and active geometry',
854 JSON.stringify(forward.tile.orderedSelection()),
855 JSON.stringify([{ col: 2, viewRow: 1, row: 31 }, { col: 5, viewRow: 3, row: 33 }]),
856 );
857 forward.tile.canvas.dispatchEvent('pointerup', up);
858 check('drag release prevents native pointer behavior', up.wasPrevented(), true);
859 check('drag release gives up pointer capture', forward.tile.canvas.hasPointerCapture(7), false);
860 check('drag release clears the scroll timer', forward.tile.selectionScrollTimer, null);
861 check('drag release sends exactly one frame', forward.sent.length, 1);
862 check('drag release uses selection-request wire type', forward.sent[0]?.type, 0x0b);
863 check(
864 'drag release passes forward daemon coordinates once',
865 JSON.stringify(forward.requestCalls),
866 JSON.stringify([[1, 31, 2, 33, 5]]),
867 );
868 check(
869 'drag release sends the exact 16-byte request',
870 Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'),
871 '010000001f0000000200210000000500',
872 );
873
874 const reverse = makeTile();
875 reverse.tile.viewStartRow = 50;
876 reverse.tile.canvas.dispatchEvent('pointerdown', pointer(8, 7, 4));
877 reverse.tile.canvas.dispatchEvent('pointermove', pointer(8, 1, 0));
878 check(
879 'reverse drag normalizes retained geometry for painting',
880 JSON.stringify(reverse.tile.orderedSelection()),
881 JSON.stringify([{ col: 1, viewRow: 0, row: 50 }, { col: 7, viewRow: 4, row: 54 }]),
882 );
883 reverse.tile.canvas.dispatchEvent('pointerup', pointer(8, 1, 0));
884 check(
885 'reverse request preserves anchor and active direction for daemon extraction',
886 JSON.stringify(reverse.requestCalls),
887 JSON.stringify([[1, 54, 7, 50, 1]]),
888 );
889
890 const clickOnly = makeTile();
891 clickOnly.tile.selection = { anchor: { row: 1, col: 1 }, active: { row: 2, col: 2 }, requestId: 9, text: 'old' };
892 clickOnly.tile.canvas.dispatchEvent('pointerdown', pointer(9, 4, 2));
893 clickOnly.tile.canvas.dispatchEvent('pointerup', pointer(9, 4, 2));
894 check('click without movement clears retained selection', clickOnly.tile.selection, null);
895 check('click without movement sends no request', clickOnly.sent.length, 0);
896
897 const cancelled = makeTile();
898 cancelled.tile.canvas.dispatchEvent('pointerdown', pointer(10, 2, 1));
899 const cancelTimer = cancelled.tile.selectionScrollTimer;
900 cancelled.tile.canvas.dispatchEvent('pointercancel', { pointerId: 10 });
901 check('pointer cancel clears retained selection', cancelled.tile.selection, null);
902 check('pointer cancel clears drag state', cancelled.tile.drag, null);
903 check('pointer cancel stops the scroll timer', h.intervals[cancelTimer - 1]?.cleared, true);
904 check('pointer cancel sends no request', cancelled.sent.length, 0);
905
906 const refused = makeTile();
907 refused.setRequestResult(-3);
908 refused.tile.canvas.dispatchEvent('pointerdown', pointer(11, 1, 1));
909 refused.tile.canvas.dispatchEvent('pointermove', pointer(11, 2, 2));
910 refused.tile.canvas.dispatchEvent('pointerup', pointer(11, 2, 2));
911 check('failed selection request clears retained selection', refused.tile.selection, null);
912 check('failed selection request sends no frame', refused.sent.length, 0);
913
914 const scrolling = makeTile();
915 scrolling.tile.scrollPages = 0;
916 scrolling.tile.onWheel({ deltaY: -1 });
917 check('wheel request immediately updates displayed start row', scrolling.tile.viewStartRow, 25);
918 check('wheel sends scrollback fetch', scrolling.sent[0]?.type, 0x05);
919 let paintedScroll = 0;
920 scrolling.tile.paintScroll = () => { paintedScroll++; };
921 const chunk = new Uint8Array(6 + 3);
922 new DataView(chunk.buffer).setUint32(0, 17, true);
923 new DataView(chunk.buffer).setUint16(4, 5, true);
924 chunk.set([1, 2, 3], 6);
925 const envelope = new Uint8Array(6 + chunk.length);
926 envelope[0] = 0;
927 envelope[1] = 0x85;
928 new DataView(envelope.buffer).setUint32(2, chunk.length, true);
929 envelope.set(chunk, 6);
930 scrolling.tile.onMessage(envelope);
931 check('scrollback echo is authoritative for displayed start row', scrolling.tile.viewStartRow, 17);
932 check('scrollback echo paints the history viewport', paintedScroll, 1);
933
934 const painted = makeTile();
935 const order = [];
936 painted.tile.sizeCanvas = () => {};
937 painted.tile.paintRow = (row) => { order.push(`row${row}`); };
938 painted.tile.paintCursor = () => { order.push('cursor'); };
939 painted.tile.paintSelection = () => { order.push('selection'); };
940 painted.tile.core.mux_read_viewport = () => 2;
941 painted.tile.paintLive();
942 check('live paint follows current history start', painted.tile.viewStartRow, 30);
943 check('live selection overlay paints after cells and cursor', order.join('|'), 'row0|row1|cursor|selection');
944 order.length = 0;
945 painted.tile.viewStartRow = 12;
946 painted.tile.paintScroll();
947 check('history paint retains echoed start row', painted.tile.viewStartRow, 12);
948 check('history selection overlay paints after cells', order.join('|'), 'row0|row1|row2|row3|row4|selection');
949
950 const overlay = makeTile();
951 const fills = [];
952 overlay.tile.ctx = {
953 fillStyle: '',
954 fillRect(x, y, width, height) { fills.push([x, y, width, height, this.fillStyle]); },
955 };
956 overlay.tile.viewStartRow = 100;
957 overlay.tile.selection = {
958 anchor: { row: 103, col: 4 }, active: { row: 101, col: 2 }, requestId: 1, text: null,
959 };
960 overlay.tile.paintSelection();
961 check(
962 'retained overlay paints reverse multi-row geometry in history',
963 JSON.stringify(fills),
964 JSON.stringify([
965 [16, 14, 64, 14, '#6ab0e055'],
966 [0, 28, 80, 14, '#6ab0e055'],
967 [0, 42, 40, 14, '#6ab0e055'],
968 ]),
969 );
970
971 const replies = makeTile();
972 const highId = 0xfedcba98;
973 replies.tile.selection = {
974 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: highId, text: null,
975 };
976 const authoritative = Buffer.from('daemon ☃', 'utf8');
977 replies.setResult(highId, 0, authoritative);
978 replies.tile.onSelectionReply();
979 new Uint8Array(replies.memory.buffer).fill(0, 96, 96 + authoritative.length);
980 check('matching high-bit reply binds with unsigned ID comparison', replies.tile.selection.text, 'daemon ☃');
981
982 replies.tile.selection.text = 'keep';
983 replies.setResult(123, 0, Buffer.from('stale'));
984 replies.tile.onSelectionReply();
985 check('stale semantic reply cannot replace retained text', replies.tile.selection.text, 'keep');
986
987 replies.setResult(highId, 2, []);
988 replies.tile.onSelectionReply();
989 check('failed matching reply clears authoritative text', replies.tile.selection.text, null);
990 check('failed matching reply reports selection unavailable', `${replies.tile.copyButton.className}|${replies.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable');
991
992 replies.tile.selection.text = 'previous';
993 replies.setResult(highId, 0, [0xff]);
994 replies.tile.onSelectionReply();
995 check('browser rejects invalid UTF-8 selection text defensively', replies.tile.selection.text, null);
996
997 const routed = makeTile();
998 routed.tile.selection = {
999 anchor: { row: 0, col: 0 }, active: { row: 0, col: 1 }, requestId: 77, text: null,
1000 };
1001 routed.setResult(77, 0, Buffer.from('routed'));
1002 const replyPayload = Uint8Array.from([77, 0, 0, 0, 0, ...Buffer.from('routed')]);
1003 const replyEnvelope = new Uint8Array(6 + replyPayload.length);
1004 replyEnvelope[0] = 0;
1005 replyEnvelope[1] = 0x90;
1006 new DataView(replyEnvelope.buffer).setUint32(2, replyPayload.length, true);
1007 replyEnvelope.set(replyPayload, 6);
1008 routed.tile.onMessage(replyEnvelope);
1009 check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed');
1010 }
1011
705 // --- wire builders (layouts golden-pinned in protocol.zig) --- 1012 // --- wire builders (layouts golden-pinned in protocol.zig) ---
706 function snapshotPayload({ seq, history, cols, rows, epoch }, state) { 1013 function snapshotPayload({ seq, history, cols, rows, epoch }, state) {
707 const stateBytes = Buffer.from(state, 'utf8'); 1014 const stateBytes = Buffer.from(state, 'utf8');
@@ -1129,6 +1436,7 @@ async function main() {
1129 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); 1436 const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8');
1130 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); 1437 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
1131 await verifyClipboardShell(shell, html); 1438 await verifyClipboardShell(shell, html);
1439 verifySelectionShell(shell, html);
1132 1440
1133 // Mutation fixture for the source checks below. A correct-looking route 1441 // Mutation fixture for the source checks below. A correct-looking route
1134 // in either kind of comment must be invisible, while live literal 1442 // in either kind of comment must be invisible, while live literal
@@ -1162,11 +1470,12 @@ async function main() {
1162 check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true); 1470 check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true);
1163 1471
1164 const completeDecoy = [ 1472 const completeDecoy = [
1165 'const MSG = { term_modes: 0x8d, term_event: 0x8f };', 1473 'const MSG = { term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90 };',
1166 'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 };', 1474 'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4 };',
1167 'case MSG.term_modes: case MSG.term_event: { if (!this.stage(payload)) return;', 1475 'case MSG.term_modes: case MSG.term_event: case MSG.selection_reply: { if (!this.stage(payload)) return;',
1168 'const action = this.core.mux_client_frame(type, payload.length);', 1476 'const action = this.core.mux_client_frame(type, payload.length);',
1169 'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); return; }', 1477 'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();',
1478 'else if (action === CLIENT_ACTION.selection) this.onSelectionReply(); return; }',
1170 'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);', 1479 'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);',
1171 'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }', 1480 'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }',
1172 'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);', 1481 'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);',
@@ -1184,7 +1493,7 @@ async function main() {
1184 check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0); 1493 check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0);
1185 check( 1494 check(
1186 'literal decoys add no semantic route body', 1495 'literal decoys add no semantic route body',
1187 balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length, 1496 balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g).length,
1188 0, 1497 0,
1189 ); 1498 );
1190 check( 1499 check(
@@ -1233,7 +1542,8 @@ async function main() {
1233 1542
1234 const neighborFixture = executableJsTokens([ 1543 const neighborFixture = executableJsTokens([
1235 'case MSG.term_modes:', 1544 'case MSG.term_modes:',
1236 'case MSG.term_event: { if (ready) { route(); } }', 1545 'case MSG.term_event:',
1546 'case MSG.selection_reply: { if (ready) { route(); } }',
1237 'case MSG.unrelated: { return; }', 1547 'case MSG.unrelated: { return; }',
1238 'case MSG.pty_mode: return;', 1548 'case MSG.pty_mode: return;',
1239 'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }', 1549 'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }',
@@ -1242,7 +1552,7 @@ async function main() {
1242 ].join('\n')); 1552 ].join('\n'));
1243 check( 1553 check(
1244 'semantic extraction ignores a later unrelated case', 1554 'semantic extraction ignores a later unrelated case',
1245 balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length, 1555 balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g).length,
1246 1, 1556 1,
1247 ); 1557 );
1248 check( 1558 check(
@@ -1266,15 +1576,19 @@ async function main() {
1266 const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : ''; 1576 const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : '';
1267 check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1); 1577 check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1);
1268 check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1); 1578 check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1);
1579 check('shell has one selection_req property', (msgDecl.match(/\bselection_req\s*:/g) ?? []).length, 1);
1580 check('shell has one selection_reply property', (msgDecl.match(/\bselection_reply\s*:/g) ?? []).length, 1);
1269 check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); 1581 check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true);
1270 check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); 1582 check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true);
1583 check('shell declares selection wire codes', /\bselection_req\s*:\s*0x0b\b/.test(msgDecl)
1584 && /\bselection_reply\s*:\s*0x90\b/.test(msgDecl), true);
1271 1585
1272 const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []; 1586 const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? [];
1273 const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g); 1587 const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g);
1274 check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1); 1588 check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1);
1275 check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1); 1589 check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1);
1276 const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : ''; 1590 const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : '';
1277 for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell']) { 1591 for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell', 'selection']) {
1278 check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1); 1592 check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1);
1279 } 1593 }
1280 check( 1594 check(
@@ -1282,16 +1596,18 @@ async function main() {
1282 /\bignored\s*:\s*0\b/.test(actionDecl) 1596 /\bignored\s*:\s*0\b/.test(actionDecl)
1283 && /\bterminalModes\s*:\s*1\b/.test(actionDecl) 1597 && /\bterminalModes\s*:\s*1\b/.test(actionDecl)
1284 && /\bclipboard\s*:\s*2\b/.test(actionDecl) 1598 && /\bclipboard\s*:\s*2\b/.test(actionDecl)
1285 && /\bbell\s*:\s*3\b/.test(actionDecl), 1599 && /\bbell\s*:\s*3\b/.test(actionDecl)
1600 && /\bselection\s*:\s*4\b/.test(actionDecl),
1286 true, 1601 true,
1287 ); 1602 );
1288 1603
1289 const semanticRoutes = balancedBodiesAfter( 1604 const semanticRoutes = balancedBodiesAfter(
1290 executableShell, 1605 executableShell,
1291 /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g, 1606 /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*case\s+MSG\.selection_reply\s*:\s*\{/g,
1292 ); 1607 );
1293 check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1); 1608 check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1);
1294 check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1); 1609 check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1);
1610 check('shell has one live selection_reply case', (executableShell.match(/case\s+MSG\.selection_reply\s*:/g) ?? []).length, 1);
1295 check('shell shares exactly one semantic frame route', semanticRoutes.length, 1); 1611 check('shell shares exactly one semantic frame route', semanticRoutes.length, 1);
1296 const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : ''; 1612 const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : '';
1297 check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); 1613 check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1);
@@ -1307,9 +1623,11 @@ async function main() {
1307 ); 1623 );
1308 check('shell calls the semantic WASM entry exactly once', (semanticCases.match(/\.mux_client_frame\s*\(/g) ?? []).length, 1); 1624 check('shell calls the semantic WASM entry exactly once', (semanticCases.match(/\.mux_client_frame\s*\(/g) ?? []).length, 1);
1309 check( 1625 check(
1310 'shell dispatches only the clipboard action', 1626 'shell dispatches clipboard and selection semantic actions',
1311 /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases) 1627 /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases)
1312 && (semanticCases.match(/this\.onClipboardEffect\(\)/g) ?? []).length === 1, 1628 && /else\s+if\s*\(\s*action\s*===\s*CLIENT_ACTION\.selection\s*\)\s*this\.onSelectionReply\(\)\s*;/.test(semanticCases)
1629 && (semanticCases.match(/this\.onClipboardEffect\(\)/g) ?? []).length === 1
1630 && (semanticCases.match(/this\.onSelectionReply\(\)/g) ?? []).length === 1,
1313 true, 1631 true,
1314 ); 1632 );
1315 check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false); 1633 check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false);