sparkles:terminal-view — the terminal core as an embeddable component
Status: shipped (TVW1–TVW8) · Date: 2026-08-07 · Scope: the planned libs/terminal-view package — apps/terminal's core (libghostty screen, per-cell renderer, input encoding, pty lifecycle) as a runApp component any application can embed — and the one host extension it needs (the post-layout paint hook). Executes PLAN phase 2, P2.A.
Why a library
"The core of apps/terminal should be a widget itself, embeddable in other apps." A sub-package under apps/ cannot be depended on, so embeddability forces the split: the core moves to libs/terminal-view as sparkles:terminal-view, and apps/terminal shrinks to a shell that parses the CLI and calls runApp — the same extraction shape that produced sparkles:ui-tui/sparkles:ui-raylib out of hue, and sparkles:raylib-text out of this very app.
Two hard gates carry over from the plan, unchanged:
- identical behavior — every byte to the pty, every escape decoded, every overlay drawn as before;
- no measured performance regression —
apps/terminal-benchmark'sidle/render/churnscenarios against a baseline captured frommainbefore the migration branch exists.
Requirements (TVW)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TVW1 | The core is a sibling library, libs/terminal-view, depending on sparkles:ghostty, sparkles:raylib-text, sparkles:ui, sparkles:input and sparkles:base — never on an apps/ tree. apps/terminal becomes a thin shell (CLI parse → runApp), and its excluded-surface number falls to the phase-2 target. | full — sparkles:terminal-view (sourceLibrary, per the ImportC pkg-config rule); apps/terminal is a 114-line shell with the tested cli.d defaults guard | libs/terminal-view/dub.sdl |
| TVW2 | The core is a component (HST10): view emits a keyed pane widget so layout sizes and positions it like any other node, and handle consumes sparkles:input events. The per-cell renderer is untouched — it paints into the laid-out rect through the host's canvas (HST3), never through the display list (UIAPP-O5). | full — TerminalView (component.d): lazy open against the session fonts, frame/view, oracle-driven handle, paint/paintPane (mouse still polled by design) | terminal_view.d |
| TVW3 | The host grows a post-layout paint phase: an optional hook that runs inside the arm's frame bracket (after the display list paints, before the frame ends), receiving the canvas and the laid-out keyed rects. Detected by introspection on the component (a paint member), wired as an alias with a no-op default — a component without one costs nothing. This is the only host change the extraction needs; the widget vocabulary stays closed (PRN12). | full — HST13: paint(ref host, in WidgetTree, in Frame[]); both live hosts expose canvas | run_app.d; gui_loop.d |
| TVW4 | Byte parity is the oracle, not a review claim. The KeyStroke seam and its fixture tests pin what each keystroke writes to the pty; the migration's sparkles:input → KeyStroke mapping (KeyEvent.action/unshifted/text/mods exist for exactly this) is tested against the same fixtures, so the swap of input source cannot silently change an encoding. Mouse encoding gets the same seam treatment when its source swaps. | full — event_map.d: encodeKeyEvent through the same encodeKeyStroke path, fixtures re-drive the oracle bytes as events (incl. Ctrl+letter, releases, kitty CSI-u); mouse waits for its conversion | input.d encodeKeyStroke; event_map.d |
| TVW5 | Every behavior of today's loop survives, expressed through the host contract: dirty-frame skipping becomes skipFrame (HST6 was specified from this behavior), font-resize hotkeys re-request cell metrics, focus reporting (DECSET 1004), exit behavior, selection/hover/scrollbar overlays, kitty images, and the OSC color-query replies all remain in the component. | full — dirty-skip as skipFrame, drain-before-encode via the lazy per-frame drain, exit policies, DECSET 1004, kitty, OSC replies, bench + screenshot hooks all in the component | terminal_view.d |
| TVW6 | The pty stays a per-frame non-blocking drain in the first migration: read-until-EAGAIN before input handling, exactly today's ordering, so the perf gate measures the extraction alone. Moving pty reads onto event-horizon fibers is a separate, separately-measured step afterwards — never folded into the migration commit. | full — per-frame read-until-EAGAIN preserved; gate run 2026-08-08: idle 99.7→0.3%, render 109.5→32.2%, churn 99.9→98.0% (no regression; ring parking replaces busy-wait pacing) | terminal_view.d |
| TVW7 | Embedding is proven by a second consumer: a demo (or hue pane) that lays the terminal component out inside its own widget tree — sized by layout, painted through the hook, receiving events routed by the embedding app. Until that exists, "embeddable" is a claim, not a property. | full — apps/ui-gallery's Terminal page: VSCode-style tabs over heap-pinned instances, keyed pane sized by layout, painted in the draw phase (paintPane on the GPU arm; cell_paint.d through isCanvas on the terminal arm — terminal-in-terminal), keys routed by a capture mode whose only reserved binding is the release chord. The host-free surface it drove out: pump/decideRedraw/sendKey/notifyFocus/resize/openCore + title capture. Embedded mouse routing waits on the mouse-event conversion | apps/ui-gallery/src/pages/terminal_page.d |
| TVW8 | The pty and the reap move onto the ring — the step TVW6 reserved. Where the host offers HST15, open spawns one daemon per instance that parks a read on the pty master, feeds feedPtyChunk, marks content dirty and calls wake(); the child is reaped by an in-ring waitid (gated canSubmitOp!(DefaultBackend, OpWaitid)), retiring the per-frame WNOHANG poll and close()'s blocking waitpid on that arm. The synchronous pump() path remains — it is the fallback for hosts without the errand (blocking arms, the recorder, foreign embeddings) and the drain-before-encode ordering guarantee stays either way (a key drains before it encodes). The spawn itself stays forkpty: in-ring reaping needs only the pid, and swapping the spawn to spawnPty/ProcessConfig (env sanitization, pixel size via follow-up TIOCSWINSZ) is a separately-measured follow-up, not part of this step. Gate: the render-CPU benchmark against the TVW6 denominators (idle 0.3 / render 32.2 / churn 98.0) — idle must not move, and the gallery's per-frame pump() calls for background tabs disappear on ring arms. | shipped for the host-run terminal (the gallery's tabs still open directly — their hookup is the public startRingPump). Gate re-run 2026-08-09: idle 0.3→0.3 (holds), render 28.8→29.6, churn 98.2→99.6 — the churn +1.4 pp is the recorded finding: one ring round-trip per drain cycle under saturation, after the burst-drain fix (the naive one-park-per-chunk shape measured 109.5). Steady-state idle makes zero pty syscalls; the remaining 60 Hz is raylib's polled input (X11 + inotify), the documented ceiling | component.d pump; HST15; pty-drain.d |
| TVW9 | The standalone terminal's internal overlay scrollbar must use the toolkit's ScrollView machine and SCV7 geometry, measured against the laid-out pane, not GetScreenHeight(). Thumb presses remain grab-relative, track presses use STM9's jump rule, and embedding cannot change the reachable scroll range. | full — OverlayScrollbar wraps ScrollView; handle_mouse routes through scrollLayout/stepV using pane pixel extents, and the renderer reads the same machine/thumb formula | input.d OverlayScrollbar; core.d internal scrollbar paint |
IMPORTANT
TVW3 exists because of the frame bracket: on the GPU arm, canvas draw calls are only valid between beginFrame/endFrame, which the loop owns — a component cannot paint cells from view (too early) and has nowhere later. The hook is the one honest place. Its exact signature (how the laid-out rects and the tree reach it, and their lifetimes) is decided in the implementing PR against the real consumer — the pieces exist (Widget.key, keyedRects(tree, frames)), the composition is the open part.
Order of work
The— done (behavior-preserving extraction inKeyStrokebyte oracleapps/terminal/src/input.d, four fixture suites).- The benchmark baseline from
main—dub run :terminal-benchmarkon a quiet machine, all scenarios, results recorded in the PR (TVW6's comparison denominator). Interactive: the harness opens real GL windows. - The paint hook (
TVW3) insparkles:ui-app, with a recording-target test (the recorder's canvas captures what the hook painted). - The extraction (
TVW1/TVW2/TVW5/TVW6): move the core, map the input events, keep the renderer byte-identical;apps/terminalbecomes the shell. The benchmark comparison + the embedding proof (— done (the gate numbers inTVW7)TVW6's row; the proof is the gallery's Terminal page, on both arms).- The ring step (
TVW8), in its own PR, in this order: theHST15errands insparkles:ui-app(recorder first, then the two live async arms — the GPU arm needs a scope around its ticker loop before it can host daemons); an in-ring reap over a raw pid insparkles:event-horizon(waitid(P_PID)over an existing pid —waittakes aChildProcesstoday); the component's pump/reap daemons behind the capability probe, syncpump()untouched; the gallery stops per-frame-pumping background tabs on ring arms; the gate re-run.
Non-goals
| Not this package's job | Where it belongs |
|---|---|
| A whole-surface TUI terminal application | still nowhere — but the embedded cell path exists: cell_paint.d renders the VT screen through any isCanvas target (no fonts, no kitty, base codepoint per cell), which is how the gallery shows a terminal inside a terminal |
| Event-loop restructuring (pty on fibers) | now specced as TVW8 above, measured on its own |
| Backend choice, window/font CLI, the frame loop | sparkles:ui-app |
| VT interpretation, escape encoding | sparkles:ghostty (libghostty-vt) |