From 76f20a811989fe2685a51cdafbe46d92c13a7d27 Mon Sep 17 00:00:00 2001 From: sonhyrd Date: Mon, 27 Jul 2026 17:31:13 +0700 Subject: [PATCH] feat(terminal): carry the bound pty key on canvas.TerminalState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With more than one `` mounted at once, an `on-terminal` dispatch could not say which one it was about. The payload is `canvas.TerminalState` — scrollback, history, cols, rows — and none of those is an identity; two panes with the same geometry report byte-identical states. There was no way for the app to add one, either: `on-terminal` takes a bare Msg tag and an authored payload is refused (`on_terminal_payload_message`, with a test pinning it), so `on-terminal="term_state:{pty_key}"` — the shape every other handler accepts — is exactly what the schema rejects. The key was already in hand at the dispatch sites. `WidgetLayoutNode` carries `terminal.pty`, and the wheel path reads it three times immediately before calling `msgForTerminal`. This stamps it onto the state at the store's public seam instead — `EnabledStore.reconcile` and `currentState` both take the key as a parameter, and every path that reaches an app comes through one of them, so a `Session` (found BY key, carrying none) needs no new field. Cost is 8 bytes on a struct the app copies once per event. `terminalMsg` needs no change: the tag's payload just grows a field. For transpiled cores, `pty` joins the declared-record vocabulary as an OPTIONAL field — a core written before this still matches on the four, one that wants the key declares five. The enabled-path test skips in this repo's own suite, where `terminal_vt` is always the stub; it runs in an app that pins ghostty. --- docs/src/app/terminal/page.mdx | 12 +++-- src/primitives/canvas/terminal_grid.zig | 7 +++ src/primitives/canvas/ui_markup_reflect.zig | 48 ++++++++++++++++++- .../canvas/ui_markup_view_tests.zig | 5 +- src/runtime/terminal_session.zig | 10 +++- src/runtime/terminal_session_tests.zig | 38 +++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/docs/src/app/terminal/page.mdx b/docs/src/app/terminal/page.mdx index 04ef65e6e..cc3608e53 100644 --- a/docs/src/app/terminal/page.mdx +++ b/docs/src/app/terminal/page.mdx @@ -47,10 +47,12 @@ pub fn view(ui: *Ui, model: *const Model) Ui.Node { pub fn update(model: *Model, msg: Msg) void { switch (msg) { - // canvas.TerminalState: scrollback, history, cols, rows — the - // app-visible view state. Echo scrollback back under the same - // source-wins rule scroll offsets follow; the emulator's cell - // state stays framework-owned. + // canvas.TerminalState: pty, scrollback, history, cols, rows — + // the app-visible view state. `pty` is the bound key, so an app + // with several elements mounted knows WHICH one + // reported. Echo scrollback back under the same source-wins + // rule scroll offsets follow; the emulator's cell state stays + // framework-owned. .term_state => |state| model.scrollback = state.scrollback, } } @@ -60,6 +62,8 @@ pub fn update(model: *Model, msg: Msg) void { `pty` is the model-owned pty key — one `{binding}`, never a markup literal, exactly like `` and ``. A `` without it is refused at validation the way a `` without `surface` is: dead markup can never attach a session. The grid derives its `cols`/`rows` from the frame the layout gives it, so size the element like any leaf (`grow`, or a definite `width`/`height`); the runtime pushes the derived size to the pty with `ptyResize`. +`on-terminal` takes a **bare Msg tag** — an authored payload is refused — so the reported `canvas.TerminalState` is the whole message. That is why it names its own `pty`: with N terminals mounted, every one of them dispatches the same tag with the same payload type, and the key is what distinguishes them. A transpiled core's declared mirror may add `pty` to `{scrollback, history, cols, rows}` to receive it, or leave it out and keep the four. + Behind the binding, the runtime owns the session: it feeds the key's output batches into the emulator as they are journaled, encodes the focused element's keys and committed IME text back through `ptyWrite`, answers the device queries a program sends (cursor position, device attributes), scrolls history on the wheel, and reports the applied view state through `on-terminal`. Pointer selection follows terminal conventions: drag selects cells, double-click selects a word, triple-click selects a line, and Cmd/Ctrl+C copies the emulator's selected text instead of forwarding the chord to the child. Because every byte in both directions crosses the journaled effect boundary, a recorded session replays to the same screen with no shell present — the same guarantee the raw vocabulary gives, through the element. ### Enabling live sessions diff --git a/src/primitives/canvas/terminal_grid.zig b/src/primitives/canvas/terminal_grid.zig index f36fa7d8c..e1b20b220 100644 --- a/src/primitives/canvas/terminal_grid.zig +++ b/src/primitives/canvas/terminal_grid.zig @@ -170,6 +170,13 @@ pub const TerminalGrid = struct { /// layout-derived grid it may display; emulator internals (cell state, /// modes, selection pins) stay framework-owned and never surface here. pub const TerminalState = struct { + /// WHICH terminal this state belongs to: the model-owned pty key + /// bound by ``, the same u64 the app's + /// `ptySpawn` named. 0 on an unbound terminal. `on-terminal` takes + /// a bare Msg tag — an authored payload is refused — so with more + /// than one `` mounted this is the ONLY thing that tells + /// the app which pane just reported. + pty: u64 = 0, /// Rows the viewport sits above the live screen; 0 is pinned to the /// bottom (the live view). Echo it into `scrollback` and the /// runtime-owned position survives rebuilds; move it model-side to diff --git a/src/primitives/canvas/ui_markup_reflect.zig b/src/primitives/canvas/ui_markup_reflect.zig index 4bb532cfb..e8e73ea11 100644 --- a/src/primitives/canvas/ui_markup_reflect.zig +++ b/src/primitives/canvas/ui_markup_reflect.zig @@ -193,6 +193,14 @@ pub const terminal_state_field_names = [_][]const u8{ "scrollback", "history", "cols", "rows", }; +/// Terminal-state fields a declared record MAY carry but need not: a +/// transpiled core written before `pty` existed still matches, and one +/// that wants to know which pane reported declares it. Required fields +/// stay required — a record missing `cols` is not a terminal state. +pub const terminal_state_optional_field_names = [_][]const u8{ + "pty", +}; + /// The RETIRED one-axis scroll-state vocabulary, kept only to recognize /// a pre-two-axis mirror and teach the migration by name (see /// `declaredLegacyScrollStateRecord`). Nothing dispatches through these @@ -206,6 +214,40 @@ pub const legacy_scroll_state_field_names_ts = [_][]const u8{ "offset", "velocity", "viewportExtent", "contentExtent", }; +/// The same structural match, with a set of fields the record MAY also +/// carry: every required name must be present, every declared field must +/// be required-or-optional, and all of them numeric. One spelling only +/// (the words are single), so there is no TS variant to match against. +fn declaredRecordMatchesVocabularyWithOptional( + comptime T: type, + comptime required_names: []const []const u8, + comptime optional_names: []const []const u8, +) bool { + const info = switch (@typeInfo(T)) { + .@"struct" => |s| s, + else => return false, + }; + if (info.fields.len < required_names.len) return false; + if (info.fields.len > required_names.len + optional_names.len) return false; + inline for (required_names) |name| { + if (!@hasField(T, name)) return false; + } + inline for (info.fields) |field| { + if (!isNumeric(field.type)) return false; + const known = comptime blk: { + for (required_names) |name| { + if (std.mem.eql(u8, field.name, name)) break :blk true; + } + for (optional_names) |name| { + if (std.mem.eql(u8, field.name, name)) break :blk true; + } + break :blk false; + }; + if (!known) return false; + } + return true; +} + fn declaredRecordMatchesVocabulary(comptime T: type, comptime canvas_names: []const []const u8, comptime ts_names: []const []const u8) bool { const info = switch (@typeInfo(T)) { .@"struct" => |s| s, @@ -253,7 +295,11 @@ pub fn declaredScrollStateRecord(comptime T: type) bool { /// of exactly the four field names (scrollback/history/cols/rows — one /// spelling, the words are single), each numeric. pub fn declaredTerminalStateRecord(comptime T: type) bool { - return declaredRecordMatchesVocabulary(T, &terminal_state_field_names, &terminal_state_field_names); + return declaredRecordMatchesVocabularyWithOptional( + T, + &terminal_state_field_names, + &terminal_state_optional_field_names, + ); } /// A mirror of the RETIRED one-axis scroll state — `{offset, velocity, diff --git a/src/primitives/canvas/ui_markup_view_tests.zig b/src/primitives/canvas/ui_markup_view_tests.zig index ec2ad655a..e66597b6f 100644 --- a/src/primitives/canvas/ui_markup_view_tests.zig +++ b/src/primitives/canvas/ui_markup_view_tests.zig @@ -3010,7 +3010,10 @@ test "the terminal element binds its pty key, scrollback echo, and view-state ha // The view-state handler resolves through the tree and carries the // applied state. - const msg = tree.msgForTerminal(widget.id, .{ .scrollback = 12, .history = 40, .cols = 80, .rows = 24 }) orelse return error.TestExpectedHandler; + const msg = tree.msgForTerminal(widget.id, .{ .pty = 7, .scrollback = 12, .history = 40, .cols = 80, .rows = 24 }) orelse return error.TestExpectedHandler; + // The bound key rides the payload: `on-terminal` takes a bare tag, + // so this is how the app learns WHICH terminal reported. + try testing.expectEqual(@as(u64, 7), msg.term_state.pty); try testing.expectEqual(@as(u32, 12), msg.term_state.scrollback); try testing.expectEqual(@as(u32, 40), msg.term_state.history); try testing.expectEqual(@as(u16, 80), msg.term_state.cols); diff --git a/src/runtime/terminal_session.zig b/src/runtime/terminal_session.zig index 808265e42..071cabf9a 100644 --- a/src/runtime/terminal_session.zig +++ b/src/runtime/terminal_session.zig @@ -428,7 +428,12 @@ const EnabledStore = struct { // caller re-emits the display list. session.rebuildSnapshot(self.tokens) catch {}; } - const state = session.currentState(); + // The key is stamped HERE, at the store's public seam, because + // this is where it is known: a `Session` is found BY key and + // does not carry one, and every path that reaches an app — + // reconcile and the wheel — comes through these two functions. + var state = session.currentState(); + state.pty = pty; if (session.last_reported == null or !stateEql(session.last_reported.?, state)) { session.last_reported = state; return state; @@ -438,7 +443,8 @@ const EnabledStore = struct { pub fn currentState(self: *EnabledStore, pty: u64) ?canvas.TerminalState { const session = self.find(pty) orelse return null; - const state = session.currentState(); + var state = session.currentState(); + state.pty = pty; session.last_reported = state; return state; } diff --git a/src/runtime/terminal_session_tests.zig b/src/runtime/terminal_session_tests.zig index 951dbaaf4..c217c8194 100644 --- a/src/runtime/terminal_session_tests.zig +++ b/src/runtime/terminal_session_tests.zig @@ -334,6 +334,44 @@ test "reconcile resizes the emulator, pushes ptyResize, and reports the state on try testing.expectEqual(@as(usize, 30), grid.rows.len); } +test "every reported state names its pty, so N mounted terminals stay distinguishable" { + if (comptime !terminal_session.enabled) return error.SkipZigTest; + var store = TerminalSessions.init(testing.allocator); + defer store.deinit(); + var gw = TestGateway{ .gpa = testing.allocator }; + defer gw.deinit(); + store.setGateway(gw.gateway()); + store.beginBuild(.{}); + + // Two panes with the SAME geometry: without the key their states + // are byte-identical, which is exactly the case an app cannot + // attribute. `on-terminal` takes a bare Msg tag — an authored + // payload is refused — so the payload is the app's only channel. + const first = store.reconcile(11, 0, 80, 24) orelse return error.TestExpectedState; + const second = store.reconcile(12, 0, 80, 24) orelse return error.TestExpectedState; + try testing.expectEqual(@as(u64, 11), first.pty); + try testing.expectEqual(@as(u64, 12), second.pty); + try testing.expectEqual(first.scrollback, second.scrollback); + try testing.expectEqual(first.cols, second.cols); + + // The wheel path reports through `currentState`, and carries the + // key too — the pane the pointer was over, not "some pane". + var line: [16]u8 = undefined; + for (0..30) |index| { + feedOutput(&store, 12, std.fmt.bufPrint(&line, "line {d}\r\n", .{index}) catch unreachable); + } + try testing.expect(store.wheel(12, 18 * 3)); + const scrolled = store.currentState(12) orelse return error.TestExpectedState; + try testing.expectEqual(@as(u64, 12), scrolled.pty); + try testing.expectEqual(@as(u32, 3), scrolled.scrollback); + + // The untouched pane still reports its own key and its own + // (unmoved) position. + const untouched = store.currentState(11) orelse return error.TestExpectedState; + try testing.expectEqual(@as(u64, 11), untouched.pty); + try testing.expectEqual(@as(u32, 0), untouched.scrollback); +} + test "wheel scrollback windows the viewport and the declared echo follows source-wins" { if (comptime !terminal_session.enabled) return error.SkipZigTest; var store = TerminalSessions.init(testing.allocator);