You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
New Features
The external-source channel — fx.openChannel, TEA subscriptions done our way: apps with long-lived external sources (sockets, file watchers, app-managed worker threads) get a first-class, journaled way to wake the UI loop and produce a Msg — no more timer-polling a shared queue. fx.openChannel(.{ .key, .on_event, .max_pending? }) returns a THREAD-SAFE ChannelHandle whose post(bytes) stages into a per-channel non-lossy FIFO, wakes the host, and delivers one .data event Msg per accepted post on the next drain (bytes in drain scratch, bounded at max_effect_channel_bytes); fx.closeChannel(key) flushes the staged backlog and delivers exactly one .closed terminal with final drop totals. Channels share the keyed families' one key space — occupied from open until close delivers — and never fail from the caller's view: a duplicate occupied key or a full table answers with one .rejected event.
Back-pressure is part of the contract, and the post's answer names it: post returns a ChannelHandle.PostResult — .accepted, .dropped_full (staging FIFO full: transient, skip and keep producing), .dropped_oversized (bytes over the post bound: a programming error no retry fixes), or .closed (the occupancy is over: exit the loop) — so a producer never has to guess "retry later" from "stop forever". Both drop answers count into dropped_pending/dropped_total on the NEXT delivered event — never silent drops, and never a blocked posting thread given a conforming host wake: the platform's wake_fn is contractually a bounded, non-blocking, enqueue-only nudge (documented at PlatformServices.wake_fn; every first-party host conforms — macOS dispatch_async, GTK g_idle_add, Win32 PostMessageW), and the runtime holds no channel lock across the call, so even a violating embedder wake hangs only its own posting thread, never a drain, close, or teardown. A violator still inside the hook at teardown is abandoned after a bounded wait, and the platform is then deliberately kept alive — destruction skipped, leaked process-lived, with one loud log — so the stale call can never execute into freed host state. Wakes are exactly as many as the loop needs: a refused post never wakes the host (a wake is issued only when a post makes new work drainable), and accepted posts COALESCE behind one latched wake per drain (a burst costs the host queue one entry, cleared at the drain boundary before it snapshots — so a post racing the drain always lands a fresh wake), meaning neither a refusal storm nor a fast producer can grow the loop's queue. Handle lifetime is safe by construction: the handle resolves through a generation-stamped process-lifetime header, so posts after close, after slot reuse, or after runtime teardown answer .closed instead of touching freed memory.
The journal fingerprint moves, a conscious break: the .channel effect-record kind journals every delivered event as executor truth at the drain boundary, post bytes INLINE (channel posts are small-message-shaped — no blob store detour). Replay feeds the recorded events verbatim and never NEEDS the source — the channel open is an ordinary replayed dispatch that PARKS the occupancy (the key registers as live, duplicate opens reject symmetrically, admission rejections regenerate) and returns an inert handle whose every post answers .closed. Honesty about what re-runs: the opening update re-executes under replay, so a producer launched unconditionally really starts — socket connects and blocking setup before its first post included — and is stopped only AT that first post; ChannelHandle.live() is the producer-launch check (false for parked replay handles, refused opens, and closed occupancies — advisory, the post's own answer stays authoritative), so producers that consult it before launching keep replay fully offline, the examples/channel-monitor pattern. Impossible records (bytes over the post bound, byte-carrying terminals) refuse replay as damage. Journals from earlier builds are refused at the preamble with the standard re-record teaching.
Bridge refusal timing, a conscious break: TS-tier refusals produced by the bridge itself — duplicate-spawn keys, image validation, channel admission — used to deliver their rejection Msg at the command cycle's own boundary, before anything else could run. They now stage into the engine's seq-stamped pending stream and deliver at the next host drain, so every rejection — engine-refused or bridge-refused — arrives in ONE seq-ordered stream in command order, which is what Cmd.batch's performed-in-order contract requires across layers (a batch mixing the two authorities used to deliver its rejections out of order). The observable difference: a frame may render between the command cycle and the rejection Msg, so an app or test that asserted the rejection landed inside the same cycle now sees the intermediate model rendered once and the rejection one drain later.
TS tier first-class: Cmd.channelOpen(key, { event }) / Cmd.channelClose(key) (wire opcodes 0x15/0x16, additive within cmd_format_version 3) with a five-field event arm matched by name (key/state/bytes/droppedPending/droppedTotal; the three-member ChannelState union checked at build time). Posting is deliberately not a TS verb — transpiled cores are single-threaded: the TS tier opens, closes, and receives, and the native side feeds through Effects.channelHandle(key).
examples/channel-monitor: an app-owned worker thread samples its own process and posts each reading; the UI updates only when events arrive — no fx.startTimer, no polling, and Stop winds the detached worker down through the handle's own .closed answer, while a transient .dropped_full only skips a sample — the drop counters reach the status line.
Horizontal and two-axis canvas scrolling: scroll views declare axis="vertical|horizontal|both" (builder axis:), horizontal offsets ride value-x with the same source-wins reconcile as value, the engine draws a bottom-edge scrollbar, keyboard scrolling gains Left/Right/Home/End on horizontal-capable regions, and macOS native scroll drivers carry both axes with OS momentum and rubber-band.
Independent per-axis wheel routing: each axis of a wheel/trackpad gesture travels to the nearest ancestor scrollable on that axis, so a horizontal timeline holding a vertical list splits a diagonal gesture — delta_y scrolls the list, delta_x reaches the timeline.
BREAKING — ScrollState is two-axis now: the one-axis {offset, velocity, viewport_extent, content_extent} record (TS: offset/velocity/viewportExtent/contentExtent) was replaced by per-axis fields offset_x/offset_y, velocity_x/velocity_y, viewport_extent_x/viewport_extent_y, content_extent_x/content_extent_y (TS: offsetX…contentExtentY); migrate a vertical region by reading the _y fields where it read the old ones — an on-scroll arm still declaring the old shape fails the build with a teaching that names the new fields.
Hover-driven Msgs — on-hover-enter / on-hover-leave: widgets can now bind pointer hover as first-class TEA vocabulary (Elm's onMouseEnter/onMouseLeave): enter dispatches once when the pointer enters a bound element's hit region, leave once when it exits — discrete containment edges, never per-move — so hover previews, prefetch, and hover cards are ordinary Msgs. Legal on any element in markup and in Zig views (ElementOptions.on_hover_enter / on_hover_leave), and the TS tier gets the pair for free (payloadless events need no SDK types).
Binding hover makes the element hover-hittable the way a bound press makes it pressable — but never pressable: clicks keep falling through, no accessibility action is announced, and no hover wash appears (a quiet content tile that binds hover stays visually quiet). Nested bound elements track containment independently; enters fire outermost-first, leaves innermost-first.
Every enter is answered by exactly one eventual leave: the leave Msg is captured when the enter dispatches, so it still arrives when the exit is the element unmounting. Exits resolve exactly like the hover wash already does — moving off, the pointer leaving the window, dismissals, and content scrolling or reflowing out from under a stationary pointer all re-hit-test the last pointer position — and overlays occlude hover the way they occlude clicks.
Opt-in and free when unbound: apps that bind no hover handlers keep an empty containment chain, no extra rebuilds, and no journal traffic. Where bound, hover Msgs derive deterministically from already-journaled pointer input, so recorded sessions replay them byte-identically with no journal format change.
Touch honesty: hover comes from mouse and trackpad pointers only — touch input never synthesizes it, so anything reachable only by hover must stay reachable another way. Deliberate break: reserving pointer-id bit 63 as the touch-source stamp changes the meaning of a journaled field, so the session journal's semantic epoch moves and recordings from earlier builds refuse with the standard re-record teaching.
examples/notes: hovering a note row now previews its title, age, and word count in the status bar (the browser status-line convention) without committing the selection.
Named keys grow delete, home, end, pageup, pagedown, insert, and f1–f12: every desktop platform now reports them on GPU-surface key events (they previously surfaced on some platforms as private-use strings or not at all), and shortcuts and menu accelerators can bind them. Terminal-style consumers can encode the full navigation and function-key set; none of these require a modifier, matching platform convention (F5 alone is a valid accelerator).
Native context menus on Windows and Linux: a right-click on a widget with a declared menu (or the zero-code editable-text and selected-text defaults) now presents the OS menu at the pointer on Windows (TrackPopupMenu) and Linux (GtkPopoverMenu), with the selection or dismissal riding the same journaled context_menu_action event macOS already emits — one authored menu, one replayable outcome, three desktop platforms.
The .context_menus platform capability now reports true on both system-engine hosts, so feature-gated code takes the native path everywhere the system web engine runs.
The engine fallback surface (hosts with no native presenter) now anchors the menu at the click point instead of the target widget's edge, matching where the pointer actually is on wide targets.
Selections now resolve from a present-time snapshot of the shown items, so a menu left open across a rebuild (a timer reordering conditional items) dispatches the item the user saw, never the rebuilt tree's occupant of that slot.
Deliberate automation-protocol break: recorded context_menu_action tokens are per-request generations instead of widget ids, so the protocol semantic epoch moves. Recordings from earlier builds are refused loudly at the preamble (their context-menu selections would otherwise be silently swallowed by the token gate); re-record with this build.
Windows pty transport — ConPTY, first-class: fx.ptySpawn and the whole pty family now run on Windows through CreatePseudoConsole over an overlapped pipe pair, honoring the exact vocabulary contract the macOS/Linux backends implement — same spawn admission and environment policy (the bound host environment plus TERM; env names match case-insensitively, the Windows rule), same all-or-nothing ptyWrite, ptyResize via ResizePseudoConsole, ptyKill via TerminateProcess plus pseudoconsole teardown (which reaches every descendant still attached to the console), same coalesced output batches and lossless back-pressure, and the same exactly-one exit. The terminal example runs unchanged (its deterministic shell pick adds cmd.exe), and recorded sessions replay offline exactly as on POSIX.
Encoding honesty: the pseudoconsole's pipe contract is UTF-8 with VT sequences in both directions, and the backend creates it with flags 0 — no PSEUDOCONSOLE_INHERIT_CURSOR, so conhost never opens with a cursor-position handshake the app would have to answer. There are no console-mode calls to make host-side: the VT modes live inside the pseudoconsole's conhost.
Differences stated plainly (docs' platform matrix moved from "staged" to supported): Windows has exit codes only, so signaled never occurs there — a crash surfaces as exited with the NTSTATUS bit-cast to i32 — and ConPTY output is conhost's VT rendering of the child's screen, not the child's raw byte stream.
TS tier: the pty command family: Cmd.ptySpawn(argv, { cols?, rows?, term?, event }), Cmd.ptyWrite(key, bytes), Cmd.ptyResize(key, cols, rows), and Cmd.ptyKill(key) (wire opcodes 0x19-0x1C) expose the pty vocabulary to transpiled cores, with an event arm matched by field name (key/state/bytes/code/reason/signal/droppedWrites), where key is the app's own session key so two sessions routing one arm stay distinguishable. The native side owns the transport; the TS tier spawns, writes, resizes, kills, and receives.
<terminal> — the terminal as a markup built-in: ui.terminal(.{ .pty = key, .scrollback, .on_terminal }) (markup <terminal pty={key} scrollback={offset} on-terminal="...">) promotes the terminal from the example tier to a first-class element. It binds a model-owned pty effect key — the same id fx.ptySpawn named, the media-surface surface binding shape — and renders the framework-owned emulator session behind it: the grid painted as real text with geometric box drawing, a theme-derived ANSI palette, selection, cursor, and scrollback, all moved into the canvas (canvas.TerminalGrid, the .terminal widget kind) from the example. Focused, it routes keys, IME text, and wheel scrollback to the session; the live viewport text rides the widget's accessibility label so screen readers read the real screen and session fingerprints cover cell state.
The terminal state contract: on-terminal delivers a canvas.TerminalState (scrollback, history, cols, rows) after every runtime-applied view-state change, and scrollback echoes it back under the scroll value source-wins reconcile rule. Only app-visible view state crosses the boundary — the emulator's cells, modes, and selection pins stay framework-owned and are never model state. Expressible in both authoring tiers, matched structurally for transpiled cores.
Teachings: a <terminal> without pty={binding} is refused as dead markup (the media-surface-without-surface policy); a literal pty key, pty/scrollback/on-terminal on any other element, and children all teach exactly where they belong, in the validator and both markup engines alike.
Live <terminal> sessions, runtime-owned: binding a pty key with <terminal pty={key}> now renders a REAL session — the runtime feeds the key's journaled pty output into a framework-owned emulator, routes the focused element's keys and IME text back out through ptyWrite, answers device queries, scrolls history on the wheel, and drives ptyResize from the element's laid-out extent through the shared cell-metrics seam. An app's terminal is fx.ptySpawn plus the element: no emulator wiring, no key encoding, no grid plumbing. Because the emulator is fed from the journaled byte stream and every outbound byte crosses the journaled write path, a recorded session replays to the same screen with no shell present.
Opt-in emulator, consumer-safe: AppOptions.terminal_sessions = true (with a lazy ghostty pin in the app's own build.zig.zon) wires libghostty-vt behind the element; every other build — scaffolded apps, the docs preview, transpiled cores — gets a stub that renders the empty terminal surface and never traverses that dependency graph. native_sdk.runtime.terminal_sessions_enabled reports which half a build carries.
examples/workbench: a live terminal beside a browser in one resizable split — the terminal is the element (no emulator code in the app), the browser is a webview pane snapped to a markup anchor with app-owned navigation history behind back/forward, reload, and an address bar.
Terminal — the pty effect vocabulary and a recordable terminal embed: fx.ptySpawn(.{ .key, .argv, .cols, .rows, .term?, .on_event }) opens a pseudo-terminal, forks the command onto it as its controlling terminal, and streams output back as coalesced on_event Msgs; fx.ptyWrite(key, bytes) sends stdin all-or-nothing and returns whether the payload was accepted (a caller that must not lose bytes retains a refusal and retries; verdicts are journaled so replay takes the identical path), fx.ptyResize(key, cols, rows) pushes a new grid (SIGWINCH), and fx.ptyKill(key) terminates the job. A pty is a spawn with a different transport — it rides the same command permission, the same environment policy, the same argv budgets, and the same one key space as spawns, fetches, and channels. macOS and Linux ship the real transport (openpty + a controlling terminal); Windows ships ConPTY (its own fragment); the null platform gets a scriptable fake pty so the whole vocabulary tests headless.
Output is coalesced per frame, never per read, and back-pressure is lossless: bytes arriving between drains deliver as one batch bounded at 64 KiB, so cat largefile journals per-frame batches instead of a record per read(). The transport's staging ring never drops a byte — a full ring parks the reader and the kernel slows the child, a terminal's native flow control — and the exit event reports dropped_writes for any ptyWrite refused over the session's life.
One exit per spawn, honest classes: exactly one .exit event ends every accepted (and every refused) spawn — exited with the child's code, signaled with the signal, cancelled after ptyKill, rejected for requests refused before a child existed (bad argv, zero grid, duplicate key, table full, unsupported platform), spawn_failed when the pty or exec could not start.
Recorded sessions replay byte-identical, offline — no shell present: output bytes are the effect result, written at effect-result time into the content-addressed blob store beside the journal (blobs/<sha256[..16]>, identical batches deduplicated), with the journal record carrying the hash and length. Replay never spawns a process: the ptySpawn parks the pty (writes/resizes/kills go inert), the journaled batches and exit feed verbatim from the blob store, and the fingerprint checkpoints verify the replayed emulator grid frame by frame. Adding the pty record kind moved the journal format fingerprint — older recordings refuse at the preamble with the standard re-record teaching.
examples/terminal: a keyboard-first terminal at the showcase bar — libghostty-vt (Ghostty's extracted VT core, pinned as the ghostty-vt Zig module) owns cell state, damage, scrollback, wrapping, reflow, and selection; the canvas paints the viewport as real text with theme-mapped ANSI-16, exact 256-color and truecolor, and wide CJK cells. Typing rides the IME-correct committed-text channel and the emulator's key encoder; cmd/ctrl+shift+space arms line/block cell selection, cmd/ctrl+arrows page the scrollback, and cmd/ctrl+C copies.
UiApp.Options.on_text: the target-less committed-text seam — on_key's typing twin — for apps that consume text without a focused text-entry widget (a terminal grid). Delivered for unclaimed text_input after the same widget-precedence routing on_key yields to, carrying the committed UTF-8 (IME results included) so consumers stay layout- and input-method-correct. Chrome may also declare a variable_prefix prefix whose command count is model-derived, for chrome whose shape changes per frame (a terminal grid, a data plot).
Video playback: a new <video> element (registry code 68, attributes controls/autoplay/loop/muted at codes 82-85 with src riding the existing attribute) plays platform-decoded video through the media-surface texture channel — AVFoundation on macOS decodes straight into the compositor while the app core sees only commands and journaled events; Windows (Media Foundation) and Linux (GStreamer) stage the capability honestly: video_playback reports false and the load verbs answer with a teaching plus one explicit failed event until their decoders land.
The video command/event vocabulary: fx.loadVideo mirrors the audio channel end to end — local-then-URL source cascade with the http(s) scheme check, transport verbs (playVideo/pauseVideo/stopVideo/seekVideo/setVideoVolume/setVideoMuted/setVideoLoop), key-stamped events (loaded with stream dimensions and duration, position ticks with the honest buffering flag, one completed at a non-looping natural end, explicit failed/rejected), and replace semantics that release the surface claim; TypeScript cores get Cmd.videoLoad/videoCtl at wire opcodes 0x17/0x18 with the by-name seven-field event-arm convention.
The session journal fingerprint moves (a deliberate break — recordings from earlier builds are refused at the preamble; re-record with this build): the new .video effect-result kind (code 13) and platform-event tag journal every delivered event verbatim, so a recorded playback replays byte-identical on a host with no decoder and no texture producer attached, and texture contents stay out of session fingerprints exactly like every media-surface texture.
Improvements
Build fingerprints replace version counters for the session journal and automation protocol: the journal's format_version and the CLI/app protocol version are gone in favor of comptime layout fingerprints — a Wyhash over a canonical description reflected from the actual record, event, and command types — so any layout change moves the identity automatically, with no counter to remember to bump and no next integer for parallel branches to contend over. Since no journal or dropbox skew is ever migrated, identity beats ordering: "same or different" was the entire question the integers answered.
Deliberate break: journals and automation sessions recorded by any earlier build are refused with the re-record teaching (the journal preamble now carries the u64 format fingerprint; the snapshot header stamps protocol=0x...), and skew refusals name fingerprints instead of version numbers.
A small semantic_epoch remains for the rare meaning-only change with identical bytes (the context-menu token generations were one); layout changes need no action.
zig build print-pins and native version print the fingerprints, so a build's wire identities can be quoted exactly.
Bug Fixes
No more stale fringes when content reflows: incremental canvas damage now covers the anti-aliasing bleed — the up-to-one-device-pixel ring rasterizers ink past a command's bounds — so a list-detail selection change that reflows conditional content (badge pills removed, shrunk, moved, or replaced under new keys) no longer leaves leftover edge pixels where the old content extended beyond the new. Every finalized incremental dirty rect (the refined union, each refined cluster on the retained-patch wire, and the summary fallback) inflates by one device pixel before surface clipping; full repaints are unchanged.
Terminal context menu: right-clicking a <terminal> now presents the standard Copy and Paste actions, copying the emulator selection and sending pasted clipboard text to the bound PTY.
Natural terminal editing on macOS: focused terminals now translate Option+Left/Right to word movement, Command+Left/Right to line boundaries, and Command+Delete to clearing back to the line start, instead of leaking unsupported modifier sequences into the shell prompt; Command+V now sends clipboard text through the terminal's bracketed-paste-aware input path.
Selectable terminal text: <terminal> now supports pointer-drag cell selection, double-click word selection, triple-click line selection, and Cmd/Ctrl+C clipboard copy without forwarding the copy chord to the child.
Terminal Tab input: focused live <terminal> components now send Tab and Shift+Tab to the PTY for completion, indentation, and TUI navigation, while focus-entry gestures and ended or unbound terminals retain ordinary traversal.
Video letterboxes instead of stretching: the video surface now aspect-fits (contain) the decoded frame — centered at the stream's reported proportions, letterboxed or pillarboxed on black, never distorted. Contain is the video surface's one fit mode, stamped on the <video> element and on any app-claimed surface while its playback is live; unknown dimensions before the LOADED report keep the full-frame placeholder, and a source replacement re-fits from the new report. Camera and app-producer media surfaces are untouched.
Clear terminal focus: terminal cursors now fill while their live session owns keyboard focus and switch to a hollow outline when focus leaves or the session ends.
Clean workbench terminal chrome: the full-bleed terminal pane keeps keyboard focus without showing its clipped outer focus ring as a stray horizontal rule beneath the titlebar.
Workbench pane focus stays truthful: clicking the embedded page now blurs the address bar and hollows the terminal caret; clicking either canvas pane restores its expected keyboard focus.