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-SAFEChannelHandlewhosepost(bytes)stages into a per-channel non-lossy FIFO, wakes the host, and delivers one.dataevent Msg per accepted post on the next drain (bytes in drain scratch, bounded atmax_effect_channel_bytes);fx.closeChannel(key)flushes the staged backlog and delivers exactly one.closedterminal 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.rejectedevent. - Back-pressure is part of the contract, and the post's answer names it:
postreturns aChannelHandle.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 intodropped_pending/dropped_totalon the NEXT delivered event — never silent drops, and never a blocked posting thread given a conforming host wake: the platform'swake_fnis contractually a bounded, non-blocking, enqueue-only nudge (documented atPlatformServices.wake_fn; every first-party host conforms — macOSdispatch_async, GTKg_idle_add, Win32PostMessageW), 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.closedinstead of touching freed memory. - The journal fingerprint moves, a conscious break: the
.channeleffect-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, theexamples/channel-monitorpattern. 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-memberChannelStateunion 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 throughEffects.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 — nofx.startTimer, no polling, and Stop winds the detached worker down through the handle's own.closedanswer, while a transient.dropped_fullonly skips a sample — the drop counters reach the status line.- Horizontal and two-axis canvas scrolling: scroll views declare
axis="vertical|horizontal|both"(builderaxis:), horizontal offsets ridevalue-xwith the same source-wins reconcile asvalue, 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_yscrolls the list,delta_xreaches the timeline. - BREAKING —
ScrollStateis two-axis now: the one-axis{offset, velocity, viewport_extent, content_extent}record (TS:offset/velocity/viewportExtent/contentExtent) was replaced by per-axis fieldsoffset_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_yfields where it read the old ones — anon-scrollarm 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'sonMouseEnter/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, andf1–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 journaledcontext_menu_actionevent macOS already emits — one authored menu, one replayable outcome, three desktop platforms. - The
.context_menusplatform 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_actiontokens 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.ptySpawnand the whole pty family now run on Windows throughCreatePseudoConsoleover an overlapped pipe pair, honoring the exact vocabulary contract the macOS/Linux backends implement — same spawn admission and environment policy (the bound host environment plusTERM; env names match case-insensitively, the Windows rule), same all-or-nothingptyWrite,ptyResizeviaResizePseudoConsole,ptyKillviaTerminateProcessplus 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
signalednever occurs there — a crash surfaces asexitedwith the NTSTATUS bit-cast toi32— 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), andCmd.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), wherekeyis 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 idfx.ptySpawnnamed, the media-surfacesurfacebinding 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.terminalwidget 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-terminaldelivers acanvas.TerminalState(scrollback,history,cols,rows) after every runtime-applied view-state change, andscrollbackechoes it back under the scrollvaluesource-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>withoutpty={binding}is refused as dead markup (the media-surface-without-surface policy); a literal pty key,pty/scrollback/on-terminalon 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 throughptyWrite, answers device queries, scrolls history on the wheel, and drivesptyResizefrom the element's laid-out extent through the shared cell-metrics seam. An app's terminal isfx.ptySpawnplus 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 lazyghosttypin in the app's ownbuild.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_enabledreports 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 coalescedon_eventMsgs;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), andfx.ptyKill(key)terminates the job. A pty is a spawn with a different transport — it rides the samecommandpermission, 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 largefilejournals per-frame batches instead of a record perread(). 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 reportsdropped_writesfor anyptyWriterefused over the session's life. - One exit per spawn, honest classes: exactly one
.exitevent ends every accepted (and every refused) spawn —exitedwith the child's code,signaledwith the signal,cancelledafterptyKill,rejectedfor requests refused before a child existed (bad argv, zero grid, duplicate key, table full, unsupported platform),spawn_failedwhen 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: theptySpawnparks 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 theghostty-vtZig 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 unclaimedtext_inputafter the same widget-precedence routingon_keyyields to, carrying the committed UTF-8 (IME results included) so consumers stay layout- and input-method-correct. Chrome may also declare avariable_prefixprefix 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, attributescontrols/autoplay/loop/mutedat codes 82-85 withsrcriding 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_playbackreports false and the load verbs answer with a teaching plus one explicit failed event until their decoders land. - The video command/event vocabulary:
fx.loadVideomirrors 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 (loadedwith stream dimensions and duration, position ticks with the honestbufferingflag, onecompletedat a non-looping natural end, explicitfailed/rejected), and replace semantics that release the surface claim; TypeScript cores getCmd.videoLoad/videoCtlat 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
.videoeffect-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_versionand the CLI/appprotocolversion 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_epochremains for the rare meaning-only change with identical bytes (the context-menu token generations were one); layout changes need no action. zig build print-pinsandnative versionprint 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.