a73x

cdedcd36

feat: expose client semantics through wasm

a73x   2026-08-18 12:27

Commit message
feat: expose client semantics through wasm

src/wasm_core.zig
Old New
@@ -22,6 +22,7 @@ const Engine = @import("engine").Engine;
22 const Replica = @import("replica").Replica; 22 const Replica = @import("replica").Replica;
23 const keymap = @import("keymap"); 23 const keymap = @import("keymap");
24 const proto = @import("protocol"); 24 const proto = @import("protocol");
25 const client_core = @import("client_core");
25 26
26 /// std.heap.wasm_allocator grows linear memory with @wasmMemoryGrow and 27 /// std.heap.wasm_allocator grows linear memory with @wasmMemoryGrow and
27 /// needs no libc, no syscalls, no host imports. 28 /// needs no libc, no syscalls, no host imports.
@@ -53,6 +54,10 @@ pub const std_options: std.Options = .{
53 const Core = struct { 54 const Core = struct {
54 eng: *Engine, 55 eng: *Engine,
55 rep: Replica, 56 rep: Replica,
57 client: client_core.ClientCore = .{},
58 /// Borrows from input_buf. It is valid only until the host next stages
59 /// a frame, exactly like ClientCore's payload-borrowing result.
60 clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} },
56 /// Grid the readout buffers are sized for; follows rep.grid. 61 /// Grid the readout buffers are sized for; follows rep.grid.
57 cols: u16, 62 cols: u16,
58 rows: u16, 63 rows: u16,
@@ -85,6 +90,13 @@ var input_buf: [256 * 1024]u8 = undefined;
85 var output_buf: [64 * 1024]u8 = undefined; 90 var output_buf: [64 * 1024]u8 = undefined;
86 var output_len: u32 = 0; 91 var output_len: u32 = 0;
87 92
93 const ClientAction = enum(i32) {
94 ignored = 0,
95 terminal_modes = 1,
96 clipboard = 2,
97 bell = 3,
98 };
99
88 // --------------------------------------------------------------------- 100 // ---------------------------------------------------------------------
89 // Lifecycle 101 // Lifecycle
90 // --------------------------------------------------------------------- 102 // ---------------------------------------------------------------------
@@ -237,6 +249,54 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
237 return 0; 249 return 0;
238 } 250 }
239 251
252 /// Decode one staged daemon frame through the same semantic core used by
253 /// the native client. Clipboard bytes remain borrowed from input_buf until
254 /// the host stages the next frame; getters never copy or allocate them.
255 export fn mux_client_frame(msg_type: u32, len: u32) i32 {
256 const c = core orelse return -1;
257 if (len > input_buf.len) return -2;
258 if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored);
259
260 // MsgType is deliberately non-exhaustive, so every u8 is a valid enum
261 // value. Unknown wire bytes reach ClientCore and are ignored safely.
262 const t: proto.MsgType = @enumFromInt(@as(u8, @intCast(msg_type)));
263 c.clipboard = .{ .target = 0, .base64 = &.{} };
264 return switch (c.client.receive(t, input_buf[0..len])) {
265 .ignored => @intFromEnum(ClientAction.ignored),
266 .state => |state| switch (state) {
267 .terminal_modes => @intFromEnum(ClientAction.terminal_modes),
268 },
269 .effect => |effect| switch (effect) {
270 .clipboard_set => |clipboard| blk: {
271 c.clipboard = clipboard;
272 break :blk @intFromEnum(ClientAction.clipboard);
273 },
274 .bell => @intFromEnum(ClientAction.bell),
275 },
276 };
277 }
278
279 export fn mux_bracketed_paste() u32 {
280 const c = core orelse return 0;
281 return @intFromBool(c.client.terminal_modes.bracketed_paste);
282 }
283
284 export fn mux_clipboard_target() u32 {
285 const c = core orelse return 0;
286 return c.clipboard.target;
287 }
288
289 export fn mux_clipboard_ptr() [*]const u8 {
290 const c = core orelse return &input_buf;
291 if (c.clipboard.base64.len == 0) return &input_buf;
292 return c.clipboard.base64.ptr;
293 }
294
295 export fn mux_clipboard_len() u32 {
296 const c = core orelse return 0;
297 return @intCast(c.clipboard.base64.len);
298 }
299
240 /// Force a full repaint on the next mux_read_viewport (scroll-mode exit, 300 /// Force a full repaint on the next mux_read_viewport (scroll-mode exit,
241 /// a canvas the host lost, first paint after tab restore). 301 /// a canvas the host lost, first paint after tab restore).
242 export fn mux_mark_all_dirty() void { 302 export fn mux_mark_all_dirty() void {
@@ -364,18 +424,29 @@ export fn mux_text_encode(len: u32) i32 {
364 /// acts on the marker acts on it right there: vim leaves paste mode 424 /// acts on the marker acts on it right there: vim leaves paste mode
365 /// 32 KiB in and re-indents the rest. 425 /// 32 KiB in and re-indents the rest.
366 /// 426 ///
367 /// UNCONDITIONAL, a known v1 limitation: the daemon's pty_mode frame
368 /// carries only icanon/echo, so the hub cannot know whether the
369 /// application asked for mode 2004. An application that did not ask sees
370 /// the literal markers. Teaching pty_mode to carry the bit is a daemon
371 /// protocol change and is deferred.
372 export fn mux_paste_begin() i32 { 427 export fn mux_paste_begin() i32 {
428 const c = core orelse {
429 output_len = 0;
430 return 0;
431 };
432 if (!c.client.terminal_modes.bracketed_paste) {
433 output_len = 0;
434 return 0;
435 }
373 @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin); 436 @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin);
374 output_len = @intCast(keymap.paste_begin.len); 437 output_len = @intCast(keymap.paste_begin.len);
375 return @intCast(keymap.paste_begin.len); 438 return @intCast(keymap.paste_begin.len);
376 } 439 }
377 440
378 export fn mux_paste_end() i32 { 441 export fn mux_paste_end() i32 {
442 const c = core orelse {
443 output_len = 0;
444 return 0;
445 };
446 if (!c.client.terminal_modes.bracketed_paste) {
447 output_len = 0;
448 return 0;
449 }
379 @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end); 450 @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end);
380 output_len = @intCast(keymap.paste_end.len); 451 output_len = @intCast(keymap.paste_end.len);
381 return @intCast(keymap.paste_end.len); 452 return @intCast(keymap.paste_end.len);
web/verify.js
Old New
@@ -18,6 +18,13 @@ const wasmPath = process.argv[2] ||
18 path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); 18 path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm');
19 19
20 let passed = 0, failed = 0; 20 let passed = 0, failed = 0;
21 const clientAction = Object.freeze({
22 ignored: 0,
23 terminalModes: 1,
24 clipboard: 2,
25 bell: 3,
26 });
27
21 function check(name, got, want) { 28 function check(name, got, want) {
22 const ok = Object.is(got, want); // SameValue compares BigInts by value 29 const ok = Object.is(got, want); // SameValue compares BigInts by value
23 if (ok) { passed++; } 30 if (ok) { passed++; }
@@ -95,6 +102,38 @@ async function main() {
95 check('cols', e.mux_cols(), 80); 102 check('cols', e.mux_cols(), 80);
96 check('rows', e.mux_rows(), 24); 103 check('rows', e.mux_rows(), 24);
97 104
105 // --- shared client semantics ABI ---
106 // Pasting is raw until the daemon explicitly samples mode 2004 as on.
107 check('paste begin disabled', e.mux_paste_begin(), 0);
108 check('paste begin disabled clears output', e.mux_output_len(), 0);
109 check('paste end disabled', e.mux_paste_end(), 0);
110 check('paste end disabled clears output', e.mux_output_len(), 0);
111
112 const modesOn = Buffer.from([1, 0, 0, 0]);
113 const modesOff = Buffer.from([0, 0, 0, 0]);
114 check('client modes on action', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes);
115 check('bracketed paste on', e.mux_bracketed_paste(), 1);
116 check('client repeated modes action', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes);
117 check('client modes off action', e.mux_client_frame(0x8d, stage(modesOff)), clientAction.terminalModes);
118 check('bracketed paste off', e.mux_bracketed_paste(), 0);
119 check('client malformed modes ignored', e.mux_client_frame(0x8d, stage(Buffer.from([1, 0]))), clientAction.ignored);
120 check('malformed modes preserve state', e.mux_bracketed_paste(), 0);
121
122 const clipboard = Buffer.from([0, 'c'.charCodeAt(0), ...Buffer.from('aGk=', 'ascii')]);
123 check('client clipboard action', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
124 check('clipboard target', e.mux_clipboard_target(), 'c'.charCodeAt(0));
125 check('clipboard len', e.mux_clipboard_len(), 4);
126 check(
127 'clipboard borrowed bytes',
128 Buffer.from(mem().subarray(e.mux_clipboard_ptr(), e.mux_clipboard_ptr() + e.mux_clipboard_len())).toString('ascii'),
129 'aGk=',
130 );
131 check('client bell action', e.mux_client_frame(0x8f, stage(Buffer.from([1]))), clientAction.bell);
132 check('bell clears clipboard getter', e.mux_clipboard_len(), 0);
133 check('client unsafe clipboard ignored', e.mux_client_frame(0x8f, stage(Buffer.from([0, 'X'.charCodeAt(0), 'A'.charCodeAt(0)]))), clientAction.ignored);
134 check('ignored event clears clipboard getter', e.mux_clipboard_len(), 0);
135 check('client unknown u8 type ignored', e.mux_client_frame(0x40, 0), clientAction.ignored);
136
98 // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- 137 // --- attach payload before any state: quotes (0,0); wall spelling 1x1 ---
99 check('attach len', e.mux_attach_payload(1, 1, 0), 20); 138 check('attach len', e.mux_attach_payload(1, 1, 0), 20);
100 let att = outBytes(); 139 let att = outBytes();
@@ -196,6 +235,9 @@ async function main() {
196 check('text len', e.mux_text_encode(9), 9); 235 check('text len', e.mux_text_encode(9), 9);
197 check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines'); 236 check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines');
198 237
238 // Fresh sample: this assertion must not inherit mode state from the
239 // client-ABI checks above.
240 check('paste section modes on', e.mux_client_frame(0x8d, stage(modesOn)), clientAction.terminalModes);
199 check('paste begin len', e.mux_paste_begin(), 6); 241 check('paste begin len', e.mux_paste_begin(), 6);
200 const pasteWire = [outBytes().toString('latin1')]; 242 const pasteWire = [outBytes().toString('latin1')];
201 for (const chunk of ['two\n', 'lines']) { 243 for (const chunk of ['two\n', 'lines']) {
@@ -264,6 +306,7 @@ async function main() {
264 // --- deinit / re-init --- 306 // --- deinit / re-init ---
265 e.mux_deinit(); 307 e.mux_deinit();
266 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); 308 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1);
309 check('client frame after deinit', e.mux_client_frame(0x8d, 0), -1);
267 check('re-init', e.mux_init(80, 24), 0); 310 check('re-init', e.mux_init(80, 24), 0);
268 e.mux_deinit(); 311 e.mux_deinit();
269 312