Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/src/app/terminal/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <terminal> 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,
}
}
Expand All @@ -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 `<media-surface surface>` and `<image image>`. A `<terminal>` without it is refused at validation the way a `<media-surface>` 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
Expand Down
7 changes: 7 additions & 0 deletions src/primitives/canvas/terminal_grid.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<terminal pty={key}>`, 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 `<terminal>` 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
Expand Down
48 changes: 47 additions & 1 deletion src/primitives/canvas/ui_markup_reflect.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/primitives/canvas/ui_markup_view_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/terminal_session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
38 changes: 38 additions & 0 deletions src/runtime/terminal_session_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down