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) | component.d (TerminalView) |
| 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 | component.d (TerminalView) |
| 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) | component.d (TerminalView) |
| 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.
Renderer fidelity and resource lifetime
The GPU terminal renderer supplements libghostty-vt's state with these presentation contracts:
cell_graphics.ddraws box, block, braille, sextant, octant, and legacy mosaic geometry on integer cell boundaries, independently of font coverage.FontSet.drawClustershapes complete grapheme clusters with HarfBuzz and rasterizes fallback and color glyphs with FreeType into padded RGBA atlas pages. Terminal cell width, rather than the sum of constituent advances, determines their span. Preferred and emoji faces precede scalar-coverage fallbacks; a second shaping pass retains synthesized complex-script glyphs.kitty_images.dretains owned image pixels and GPU textures, compares changed images once per mutation epoch, and updates dirty rows. Placements preserve native dimensions, crop/offset geometry, and the below-background, below-text, and above-text layers. Image-only commands request a repaint.synchronized_output.dcaptures the completed prefix at a mode-2026 begin boundary, including image placements. Further input and query replies continue without exposing partial frames. End, reset, resize, EOF, or the one-second deadline releases the hold.osc_query.danswers OSC 4 queries from the current palette, preserving set/query ordering and multiple pairs within one control string.
Components may implement shutdown(ref host). The host invokes this optional hook after its daemons drain and before destroying the graphics context. TerminalView.shutdown releases terminal-owned textures and closes the terminal; embedding components forward the hook to their terminal children. This ordering is required even on exceptional exit: deleting textures after the GL context has gone away is not valid cleanup.
Behavioral coverage lives in the terminal-view feature modules, raylib-text's shaping tests, and ui_app.run_app.shutdownReleasesResourcesOnQuitAndException. Actual GPU verification additionally requires the terminal application: headless unit tests cannot prove glyph rasterization or texture layering.
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) |