Skip to content

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 regressionapps/terminal-benchmark's idle/render/churn scenarios against a baseline captured from mainbefore the migration branch exists.

Requirements (TVW)

IDRequirementStatusTraces to
TVW1The 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 guardlibs/terminal-view/dub.sdl
TVW2The 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
TVW3The 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 canvasrun_app.d; gui_loop.d
TVW4Byte 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:inputKeyStroke 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 conversioninput.d encodeKeyStroke; event_map.d
TVW5Every 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 componentterminal_view.d
TVW6The 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
TVW7Embedding 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 conversionapps/ui-gallery/src/pages/terminal_page.d
TVW8The 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 ceilingcomponent.d pump; HST15; pty-drain.d
TVW9The 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 formulainput.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

  1. The KeyStroke byte oracledone (behavior-preserving extraction in apps/terminal/src/input.d, four fixture suites).
  2. The benchmark baseline from maindub run :terminal-benchmark on a quiet machine, all scenarios, results recorded in the PR (TVW6's comparison denominator). Interactive: the harness opens real GL windows.
  3. The paint hook (TVW3) in sparkles:ui-app, with a recording-target test (the recorder's canvas captures what the hook painted).
  4. The extraction (TVW1/TVW2/TVW5/TVW6): move the core, map the input events, keep the renderer byte-identical; apps/terminal becomes the shell.
  5. The benchmark comparison + the embedding proof (TVW7)done (the gate numbers in TVW6's row; the proof is the gallery's Terminal page, on both arms).
  6. The ring step (TVW8), in its own PR, in this order: the HST15 errands in sparkles: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 in sparkles:event-horizon (waitid(P_PID) over an existing pid — wait takes a ChildProcess today); the component's pump/reap daemons behind the capability probe, sync pump() untouched; the gallery stops per-frame-pumping background tabs on ring arms; the gate re-run.

Non-goals

Not this package's jobWhere it belongs
A whole-surface TUI terminal applicationstill 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 loopsparkles:ui-app
VT interpretation, escape encodingsparkles:ghostty (libghostty-vt)