hue TUI — Feature Requirements (full-screen terminal viewer)
Note: requirements shared with the GPU window moved to viewer.md and are listed under Retired IDs; what remains here is terminal substrate — escape-sequence decoding, the alt-screen, capability probing and OSC 52.
Status: shipped (T1–T4); T5 outstanding · Date: 2026-07-29 · Scope: the interactive terminal mode — a full-screen TUI with scrolling, a scrollbar, mouse support, selection, wrapping, line numbers, and the markdown preview. It is the terminal port of the GUI viewer.
NOTE
The full-screen viewer is shipped (tui.d, on sparkles:tui) and is the interactive tty mode on POSIX. The older minimal theme-selection previewer (previewer.d, PRV1–PRV8) survives only as the non-POSIX fallback and is scheduled for removal, with theme selection folding into this viewer as a widget overlay — see ui-architecture.md UIA4. Status legend and ID conventions: see the overview.
Design & scope
The full TUI is the same interactive viewer as the GUI, painted into terminal cells instead of GPU quads. It consumes hue's identical (source, events, theme) triple and — crucially — reuses the GUI's raylib-free views unchanged: the shared widget views (viewMarkdown / viewCodeDocument / viewTwoslashDocument) and gui_text.d (pure metrics/search) are already terminal-independent and unit-tested. The TUI is therefore a second canvas over the same widget trees, not a parallel layouter — the GUI's RND2 ("one wrapped visual-line list, painted by a single painter") generalizes across backends.
Three things the GUI gets from raylib that the TUI must supply itself: input (SGR mouse + an expanded key vocabulary), clipboard (OSC 52, there being no windowing clipboard API), and the surface (the alt-screen cell grid, which the shipped previewer already manages). Two things get simpler in a terminal: the grid is natively monospace (RND6 is free), box-drawing glyphs render without gaps (the BOX GPU workaround is unnecessary), and ```ansi fences can be passed straight to the real terminal instead of decoded through an off-screen VT (MDP12 / sparkles:ghostty).
GUI → TUI parity map
How each GUI requirement area applies to the TUI. full = ports directly · best-effort = ports with a terminal caveat · n/a = not a terminal concern · future = backend-agnostic, deferred with the GUI's. All GUI areas are in gui.md; IDs below are bare references into it.
| GUI area | Applies | Terminal note |
|---|---|---|
RND render model | full | the same widget trees, painted into cells (paintGrid); the terminal is natively monospace (RND6 free) |
VIW views & toggle | full | raw + markdown preview, Tab toggles |
WRP wrapping | full | same soft/hard wrap; reflow on SIGWINCH (TSF2) |
NUM line numbers | full | file gutter + per-code-block gutter, in cells |
NAV navigation & scroll | full | wheel (SGR mouse), j/k, PageUp/Down, Home/End, goto-line |
SCB scrollbar | best-effort → TSB | cell-column bar, block-glyph thumb, mouse drag / track-click; no smooth width easing |
THG live theme cycling | full (partial now) | <leader>ut cycles (←/→ before KBD1) — the shipped previewer already does this (PRV2) |
FND search & goto | full, minus :N | incremental search, n/N jumps and match tinting, all on the shared matcher and the shared input bar. A cell carries no alpha, so the window's translucent gold/orange are pre-mixed against the page background at the same 70/255 and 130/255. :N goto-line is the one gap (D8) |
MDP markdown constructs | best-effort → MDP-T (here) | all decorations; Nerd-glyph dependence like FNT8; box-drawing is native; ```ansi fences pass through the real terminal |
COD code blocks | full (best-effort) | code gutter + highlighted body + border via native box glyphs; copy region + OSC 52 (TCL) |
SEL selection & clipboard | best-effort → TSL | app-level drag-select → source offsets; clipboard via OSC 52; suppresses the terminal's native selection |
FNT font | n/a | the terminal owns the font/cell; bold/italic/underline → SGR attributes (the FNT5 analog) |
WIN window & lifecycle | n/a | no window; the alt-screen is the surface; resize arrives as SIGWINCH |
FSC fullscreen | n/a | the terminal emulator's concern, not hue's |
BOX procedural box-drawing | n/a (solved) | box glyphs render natively without gaps — the GPU arms-to-edges workaround isn't needed |
DBG debug/CI hooks | best-effort | a headless frame-dump analog for golden capture (the previewer already assembles a frame buffer) |
SEM semantic refinement | future | backend-agnostic, deferred with the GUI's SEM1 |
Terminal input (TIN)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TIN1 | The input layer must decode an expanded key vocabulary — arrows, PageUp/Down, Home/End, Tab, Enter, Ctrl-C, and printable characters (for search / goto) — beyond the shipped minimal Key{up,down,enter,cancel,other}. | full (dd70e1b4) | core-cli key_input.d (must grow / be superseded) |
| TIN2 | SGR mouse tracking (mode 1006 + 1000/1002) must be enabled on entry and disabled on exit; press / release / drag / wheel events with button + modifiers must decode to (row, col) cell coordinates. | full (b8809549) | proposed mouse decoder (core-cli) |
| TIN3 | Wheel events must scroll (NAV); left press/drag/release must drive selection (TSL); clicks must hit-test the scrollbar (TSB), the code-block copy region (COD), and notifier popup items (NTF6). | partial (b8809549) | handleWheel/handlePointer (tui.d); the notifier items wait on NTF6 |
| TIN4 | Mouse tracking must be restored (disabled) on exit, signal, and crash, so the terminal is never left in mouse mode. | full (b8809549) | scope(exit) / signal handler (proposed) |
Keyboard policy (TKB)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
TKB1 | The terminal backends must resolve keys through the one binding table (lantern KEY1), not a switch of their own. Both the viewer and the explorer carried one, and both had drifted from the window's. | full (e71a9417) | tui.handleKey; explorer.handleCommand |
TKB2 | The divergences that routing exposed are resolved vim-correct, not by picking a side: gg top, Shift-G bottom, gl go-to-line (both previous meanings survive because vim has both); y copies the selection everywhere and the ANSI-copy mode moves to <leader>uy; q quits in both backends. | full (8e4b0250) | lantern LMP3–LMP5 |
TKB4 | The key guide must paint into the cell grid from the same widget tree the window paints (lantern LTN5), and the event loop must take the guide's remaining delay as a second deadline — a terminal has no frames, so a panel that waits for the next keystroke is a panel nobody sees. | full (8ec10bee) | tui.paintLantern; workspace.d loop |
Terminal clipboard (TCL)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TCL1 | Copy must write to the system clipboard via OSC 52 (base64) — the only portable in-band terminal clipboard; no read is needed. | full (8b1d2aac) | proposed OSC 52 writer |
| TCL2 | A terminal without OSC 52 support must degrade — show a status message instead; copy must never block or corrupt the screen. | not started | TCP1 capability probe |
Surface & frame (TSF)
Extends the shipped previewer's frame discipline.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TSF1 | The TUI must render into the alt-screen (hide cursor), one synchronized-output frame per repaint, assembled into a single buffer and flushed with one write. | partial (844680a3/0657c94a) | PRV7/PRV8; NFR2 (previewer already does this) |
| TSF4 | The per-frame paint core should stay @nogc nothrow (NFR1); load-time layout may allocate, as the GUI's does. | not started | none: the @nogc core was the retired previewer (NFR1); the TUI paints through GridCanvas into Screen, and nothing checks that path's attributes |
NOTE
TSF3 pulls gui_preview.d (and, for ```ansi fences, potentially gui_ansi.d/sparkles:ghostty) into the default terminal build. Reconcile with NFR3 / the no-gui build (BLD2) — the preview layout is raylib-free, but the off-screen VT is not needed in a real terminal (fences pass through), so the TUI can take the layout without the VT dependency.
Selection & clipboard (TSL, best-effort → gui.md SEL)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TSL3 | App-level mouse selection requires SGR mouse tracking, which suppresses the terminal emulator's native selection — hue must provide its own. | full (8b1d2aac) | TIN2 |
Markdown preview in the terminal (MDP-T, best-effort → gui.md MDP)
The TUI is a second canvas over the shared widget views (TSF3), so the GUI's markdown constructs reach the terminal by construction — MDP-T1 binds each to its gui.md source, and MDP-T4 records the gaps that inherit unchanged. Three deltas are terminal-specific (MDP-T2/MDP-T3):
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| MDP-T2 | Box-drawing (tables, code-block borders, quote rules) must use the native terminal glyphs — no procedural drawBox; a real terminal renders ─│┼╭╮╰╯ without gaps. | full (dd70e1b4) | native SGR box glyphs |
| MDP26 | In the terminal, link text must be wrapped in OSC 8 so the terminal itself makes it clickable. A hyperlink is opened and closed inside each redrawn run, never across the diff's cursor moves. The GUI ignores the channel — there the hand cursor is the affordance. | full | TextSpan.linkId → Visual → CellT → Screen.render; MdLinkTable |
| MDP-T3 | ```ansi fences render through the shared fence renderer: in the default (ghostty) build they are decoded by the off-screen VT and re-emitted as resolved-color spans (full fidelity, the code-panel box preserved); in the raylib-/ghostty-free no-gui build the SGR is stripped to plain text (no VT). Raw byte pass-through into the bordered panel was rejected — it would corrupt the box. | partial (241e8052) | hueFenceRenderer; ViewerModel's decode hook; the strip fallback |
Diff view in the terminal (DIF-T, best-effort → diff-view.md)
The diff/PR/review feature reaches the terminal by construction (the shared widget views, TSF3); these rows bind the terminal-specific deltas.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| DIF-T1 | Both diff layouts render in cells from the shared widget view — dual gutters, filler rows, and hunk separators via native terminal glyphs (the MDP-T2 doctrine), diff row backgrounds as SGR over the syntax colors (DVL1/DVL2). | not started | paintGrid over the diff widget view |
| DIF-T4 | The write surface reaches the terminal: inline editing (DST5) and the comment composer (DCM3) through the UIA9 editor component's terminal input; degraded terminals fall back per TCP1 (no mouse → keyboard-only staging; no OSC 52 → copy disabled). | not started | UIA9; TCP1 |
Capabilities & degradation (TCP)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TCP1 | On entry the TUI must probe tty status, color depth (CLR1), mouse, and OSC 52 support, and adapt (no mouse → keyboard-only; no OSC 52 → copy disabled). | not started | core-cli term_caps.d |
| TCP2 | A non-tty invocation must fall through to the non-interactive ANSI whole-file emit (MOD3/MOD5) — the full TUI is only for interactive ttys. | full (74d8f6a3) | interactive gate (general spec) |
Retired IDs → viewer.md
Seventeen T* rows specified capabilities that gui.md already specified, which is how the two backends were able to drift apart. They are absorbed into the tier-scoped rows in viewer.md, which carry the old ids in their Supersedes column. This table exists so a citation of a retired id still lands somewhere useful.
| Retired | Absorbed into | Note |
|---|---|---|
TSF2 | NAV5, NAV6, WRP4 | Only "SIGWINCH is the resize source" was terminal-specific; that clause stays in TSF1's section |
TSF3 | RND2 | tui.md's own prose already said RND2 "generalizes across backends" |
TSB1 | SCB1, SCB3 | |
TSB2 | SCB4 | Divergence D3: said click-to-page; SCB4 says centre. Centre wins |
TSB3 | SCB2 | The cell degradation is now SCB2's precise → interactive tier |
TSL1 | SEL1, SEL2, SEL3 | Cited them directly |
TSL2 | SEL4 | Divergence D4: y copies everywhere per TKB2; SEL4 had never been updated |
TSL4 | SEL6, SEL7, TBL1–TBL8 | A deferral note, not a requirement |
TSL5 | COD3, TBL6 | Divergence D1: anchored the fence button to the header band; the border cutout wins |
TSL6 | SEL10 | The "request ticks only while capture is live" clause is terminal substrate and stays |
TSL7 | TBL7, TBL8 | Both already said "in both hosts" |
TSL8 | SEL4 | Divergence D2: holdMs: 1600 versus COD3's ~1.2 s. 1.2 s wins |
TCP3 | FNT8 | Same doctrine, two statuses — FNT8 partial, TCP3 not started |
TKB3 | THG1 | Divergence D5: silently narrowed THG1 to pane scope. THG1 now says so |
MDP-T1 | MDP1–MDP11 | A citation list |
MDP-T4 | MDP13–MDP18 | A citation list |
DIF-T3 | SEL9 |
Milestones
| Milestone | Scope | Status | Requirements |
|---|---|---|---|
| T0 | Minimal theme-selection previewer (viewport slice + ↑/↓ theme cycle) | full (74d8f6a3) | PRV* (baseline) |
| T1 | Viewport scrolling over the reused GUI wrapped-line layout | full (dd70e1b4) | TSF3, RND/VIW/WRP/NUM |
| T2 | SGR mouse + wheel + the cell scrollbar | full (b8809549) | TIN, TSB, NAV |
| T3 | Selection → source offsets + OSC 52 copy | full (8b1d2aac) | TSL, TCL, SEL parity |
| T4 | Markdown-preview parity + code blocks + incremental search | partial (30e8e133) | MDP-T*, COD, FND |
| T5 | Notifier popups in the terminal | not started | notifier.md NTF6 |
Module coverage (TUI)
| Source | Requirements |
|---|---|
apps/hue/src/previewer.d (extended to a full viewer) | TSF*, TSB*, TSL*, MDP-T*, TCP*, PRV* |
libs/doc-view/src/sparkles/doc_view/preview_model.d / gui_text.d (reused as-is) | TSF3 (shared layout); RND/WRP/NUM/SEL model |
sparkles:core-cli key_input.d (expanded), term_caps.d | TIN*, TCP1 |
| OSC 52 writer (proposed) | TCL* |
→ GUI requirements · General requirements · Notifier · Overview