# Harlock Roadmap
A pure-Elixir TUI framework for Unix terminals. TEA-style model/update/view
loop on top of OTP, with a thin termios NIF for direct /dev/tty control.
This roadmap is the working plan from v0.1 (current) to v1.0 (Hex-publishable,
stable API). It's a living document — revise as we learn.
## Status snapshot (v0.1, current)
What works:
- OTP supervision tree (`Keeper → Writer → Reader → Runtime`, `rest_for_one`,
`max_restarts: 0`). Terminal is restored on any crash via
`Keeper.terminate/2`. This is correct and load-bearing — don't regress it.
- TEA loop with `init/1`, `update/2`, `view/1`, optional `subs/1`. Dirty-flag
rendering, no periodic polling.
- Focus traversal (`Tab` / `Shift-Tab`), focus traps for modals with
automatic stash/restore on open/close.
- Constraint layout solver: `:length`, `:percentage`, `:fill`. Deterministic
round-off absorption; graceful truncation on over-constraint (logs warning,
never crashes).
- Cell-grid renderer with frame diffing. ANSI output via `Writer`.
- Primitives: `text`, `vbox`, `hbox`, `spacer`, `box` (4 border styles +
title + padding), `overlay` (5 anchors + focus trap), `table` / `list`
(row-id identity, single/multi selection, header).
- Input parser handles CSI/SS3, bracketed paste, XTerm focus reporting.
- Headless `IO.Test` backend selectable via `backend: :test` for
deterministic tests without a TTY.
- Examples: `counter`, `sysmon`. Smoke tests driven by `script(1)` (handles
BSD vs util-linux flag differences).
What's stubbed / missing — the honest list:
- `Cmd` executor: runtime ignores the `{model, cmd}` return tuple entirely.
No async work, no IO, no HTTP. **The single biggest hole.**
- `Sub`: only `:interval` exists.
- Layout: `:min` and `:max` constraints behave as `:length` (documented).
- No SIGWINCH handling — terminal resize doesn't reflow.
- No `text_input`, `viewport`, `progress`, `spinner`, `tabs`, `tree`,
`menu`/`select`, `keybar`/`statusbar`.
- No mouse, no kitty keyboard protocol, no modified arrows (`CSI 1;5A`).
- No wide-grapheme width (CJK, emoji). `String.length` ≠ visual columns.
- `Style` not consistently cascaded (table headers hard-code `bold`,
focused box hard-codes `reverse`).
- No `row.ex` helper (only `column.ex`).
- `mix.exs` has no package metadata. README is the `mix new` default.
- No CI, no Dialyzer, no Credo wired in.
## Guiding principles
1. **OTP-first.** The supervision tree is the architecture. New features
that need processes get their own child, supervised correctly.
2. **No NIFs in the rendering path.** ANSI in, ANSI out. The termios NIF
shipped in v0.2 is the one allowed exception, strictly scoped to
/dev/tty control; new NIFs require the same level of justification.
3. **Phoenix devs feel at home.** `update/view/subs` mirrors LiveView's
mental model on purpose. `Sub.pubsub` should be a first-class citizen.
4. **Headless-testable.** Every new widget gets `IO.Test`-driven coverage.
No "works on my terminal" features.
5. **Terminal restoration is sacred.** Any new IO path must survive crashes
without leaving the terminal in raw mode / alt-screen. Test it.
6. **Honest stubs over fake completeness.** Stub modules clearly say so in
their moduledoc. No silent half-features.
## Versioning
- **0.x** — API may break. We document breaking changes in CHANGELOG.
- **1.0** — locked public API for `Harlock`, `Harlock.App`, `Harlock.Elements`,
`Harlock.Cmd`, `Harlock.Sub`, `Harlock.Render.Style`, `Harlock.Layout`.
Internal modules — Harlock.App.Runtime, the rest of Harlock.Terminal.\*,
Harlock.Element.Renderer — stay `@moduledoc false` and remain free
to change without notice.
---
## v0.2-prep — tooling first (do before any v0.2 implementation)
CI gates the v0.2 implementation work; it does not land alongside it.
Otherwise the Cmd executor, SIGWINCH path, and text_input land without
type-checking or lint to catch regressions, and the first green build is
also the first build that has to satisfy every new check at once.
- Add dev deps: `:ex_doc`, `:dialyxir`, `:credo`. Wire `mix docs`.
- `.github/workflows/ci.yml`: `mix format --check-formatted`, `mix test`,
`mix dialyzer`, `mix credo --strict` on push + PR.
- `.credo.exs` tuned to a green baseline on v0.1 code.
- Dialyzer PLT cached in CI; baseline clean on v0.1 before opening v0.2 PRs.
- `CHANGELOG.md` seeded from v0.1.
---
## v0.2 — "actually usable" (~2 weeks)
The minimum set that lets someone build a real internal tool. Ship together.
### Cmd executor (the unblocker)
The runtime currently destructures `{model, _cmd}` and throws the cmd away.
Wire a real executor.
- Add a Task.Supervisor child to the app supervisor, positioned
**between IO and Runtime** so it's already up when Runtime's
`handle_continue` dispatches the init-time cmd. (Earlier plan said
"after Runtime"; that lost the init-cmd race against the supervisor's
child-start loop.) Strategy: `:one_for_one`, restart `:temporary` on
individual tasks.
- Implement `Cmd.from/1`: runs the 0-arity fn under `Task.Supervisor`,
sends result back as `{:harlock_event, result}`.
- Implement `Cmd.batch/1`: spawns each child cmd concurrently, no
ordering guarantees.
- Add `Cmd.map/2` for tagging results: `Cmd.map(cmd, fn r -> {:tag, r} end)`.
- Tasks linked to runtime; if runtime exits, tasks die. If a task crashes,
log + emit `{:harlock_event, {:cmd_error, reason}}` (don't kill runtime).
- Runtime: on `update/2` returning `{model, cmd}`, dispatch cmd, update
model, render. On `:quit` with cmd, dispatch then quit.
Tests: cmd that sleeps then returns; cmd that crashes; batch of three;
quit-with-cmd ordering.
### SIGWINCH + resize event
Terminal resize must reflow. Without this we can't ship.
- Use `:os.set_signal(:sigwinch, :handle)` (OTP 22+) in `Keeper` (the
TTY-owning process). Signal arrives as `{:signal, :sigwinch}` in
Keeper's mailbox.
- Keeper queries new size via `ioctl(TIOCGWINSZ)` through the termios
NIF (`Harlock.Terminal.Termios.winsize/1`). The original plan
considered `tput cols`/`tput lines` to avoid native code, but
`:os.cmd`-based shell-outs lose the controlling tty (every shell
spawned by ERTS is `setsid()`-detached), so a NIF was needed for
termios anyway — TIOCGWINSZ goes through the same one.
- Keeper sends `{:harlock_resize, rows, cols}` to Runtime.
- Runtime handles `{:harlock_resize, _, _}`: update `state.rows` /
`state.cols`, discard `prev_frame` (full redraw — diff against
differently-sized buffer is meaningless), mark dirty, render.
- Initial size at Runtime startup also comes from `Keeper.size/1`
(synchronous `GenServer.call`, no race because Keeper starts first
in the supervision tree). Test backend supplies explicit rows/cols
via opts.
Tests: simulate resize event into `IO.Test` runtime, assert reflow
(`test/harlock/resize_test.exs`).
### Wide-grapheme width (prerequisite for text_input)
`String.length` ≠ display columns. Pulled into v0.2 from v0.3 because
text_input cursor math depends on it — shipping text_input first would
either land with broken CJK/emoji handling or force a width retrofit later.
Verified usage at `renderer.ex:188, 194, 200, 308, 315–325` and called out
in code at `renderer.ex:323-324` and `cell.ex:7-8`.
- New module `Harlock.Width`: `width(grapheme)` returns 0/1/2 based on
Unicode East Asian Width + emoji presentation. Static table — pull from
Unicode 15.1 EastAsianWidth.txt at build time via `mix harlock.gen_width`.
- Replace `String.length` with `Harlock.Width.string_width/1` in:
`clip/2`, `align_text/3`, `draw_title/6`, table column rendering,
and the new `text_input` cursor math.
- Zero-width joiner / variation selectors: keep with the preceding
grapheme, don't advance the cursor.
- `Cell` stays one codepoint per cell; wide graphemes occupy `cell + cell'`
where `cell'` is a sentinel "continuation" cell. Frame diffing and clip
math must skip continuations.
Tests: "héllo" (combining), "東京" (wide), "🇮🇹" (regional indicator pair),
"👨👩👧" (ZWJ sequence).
### Minimal theme tokens (prerequisite for v0.3 widgets)
Replace the hard-coded styles in the renderer with theme lookups now, so
v0.3 widgets (`progress`, `tabs`, `statusbar`, `keybar`) aren't built
against hard-coded values that need a sweep in v0.4. Full theming
ergonomics still land in v0.4 — this is the minimum surface to avoid
rework.
- `Harlock.Theme` struct with the four tokens the current renderer needs:
`header`, `focus`, `selection`, `border`. Full token set (`primary`,
`accent`, `muted`, `error`) deferred to v0.4.
- `Harlock.Theme.get(token)` available in callbacks via process dict
(same pattern as `Focus`).
- App passes `theme: %Theme{...}` to `Harlock.run/3`; omitted = built-in
default that matches today's hard-coded values exactly (no v0.1 → v0.2
visual diff for existing apps).
- Replace hard-coded styles at `renderer.ex:165` (`%Style{bold: true}`
header), `:211-212` (`%Style{reverse: true}` / `bold: true` focused row),
`:254` (focus default), `:214, 217` (`bg: :cyan` selection) with theme
lookups.
Tests: app with custom theme overrides each token; app with no theme
renders byte-identical to v0.1 baseline (golden frame).
### text_input element
Single-line first. The cursor is a new concept — `Frame` needs to track it.
- Add `cursor: {row, col} | nil` to `Frame`. `Writer` emits cursor-show /
cursor-position after diff, or cursor-hide if nil.
- `text_input` element opts: `:value` (caller-owned, model holds state),
`:placeholder`, `:focusable` (required), `:on_change` (msg to send),
`:max_length`, `:style`, `:placeholder_style`, `:password` (mask with `•`).
- Runtime injects key events to the focused `text_input`'s `:on_change`
with shape `{msg, {:input_event, kind, payload}}` where kind is
`:insert | :delete | :move | :submit`. App owns the buffer.
- Helper module `Harlock.TextBuffer` (pure functions: `insert/3`,
`delete_backward/2`, `move_cursor/2`, etc.). App calls it from `update/2`.
Keeps the element dumb, model honest.
- Keys: printable chars insert, `Backspace` deletes-back, `Delete`
deletes-forward, `Left`/`Right` move, `Home`/`End` jump, `Enter` submits.
Defer to v0.3: multi-line, IME, word movement (`Ctrl-Left`/`Ctrl-Right`),
selection.
### viewport element
Generic scrollable container.
- `viewport(child: el, height: n, offset: model.offset, on_scroll: msg)`.
- Renders child into a virtual `Frame` of `{requested_w, large_h}`, then
blits the visible slice into the real region.
- Emits scroll messages on `PgUp`/`PgDn`/`Up`/`Down` when focused.
- App owns the offset (same TEA discipline as text_input).
### Presentation track (parallel with v0.2 implementation)
Tooling itself ships in v0.2-prep (above). What's left here is the
external-facing surface that gates adoption:
- Fill `mix.exs`: `description`, `package` (licenses, links, maintainers),
`source_url`, `homepage_url`, `docs` config (main: "readme", extras).
- Replace `README.md`: 30-second pitch, screenshot/GIF, install snippet,
minimum counter example, status table linking to ROADMAP, "why not X"
(vs Owl, Ratatouille, ratatui-via-port).
- Asciinema cast of `sysmon` embedded in README.
---
## v0.3 — "shows well in demos" (~3 weeks after v0.2)
### Layout: real :min and :max ✓
Shipped. The solver:
1. Computes a lower bound per slot: `:length(n)→n`, `:percentage(p)→p%`,
`:min(n)→n`, `:fill(_)→0`, `:max(_)→0`.
2. If lower bounds exceed total → truncate from tail and warn (unchanged
over-constraint behavior).
3. Distributes the remainder across flexible slots. `:fill(weight)`
carries its weight; `:min` and `:max` each carry weight 1.
4. Clamps `:max` violators to their cap, returns excess to remaining,
iterates. Convergence is bounded by `length(constraints)` — each
pass either freezes ≥1 slot or terminates.
5. If `:max` caps leave space unallocated (`[max: 10, max: 10]` in a
30-cell region), the trailing region is simply not used — children
don't overflow caps to fill space.
### Standard widgets ✓
Shipped — all composable from existing primitives, dumb renderers with
app-owned state:
- `progress(:value, :max, :width, :style, :fill_style)` — single-line
bar with `█` for the filled portion.
- `spinner(:tick, :frames, :style)` — single cell. Caller increments
`:tick` from a `Sub.interval` subscription.
- `tabs(:items, :active, :focusable, :style, :active_style)` —
horizontal tab bar; the body for the active tab is rendered
separately by the app. Pair with `Harlock.Tabs.apply_key/3` in
`update/2` for Left/Right/Home/End navigation.
- `statusbar(:left, :right, :style)` — pinned-row helper, left/right
alignment with style-filled middle.
- `keybar(:bindings, :style, :separator, :right)` — formats
`[?q, "quit"]` → `[q] quit`, with arbitrary separators and an
optional right-aligned addendum.
### Viewport element ✓
Shipped. App-owned scroll state, render-then-clip pipeline:
- `viewport(child:, offset:, content_height:, scrollbar:)` — required
`:offset` and `:content_height` (app knows what it's scrolling).
- Renderer allocates a `width × content_height` temporary frame,
renders the child into it, blits the visible slice. Cost is
O(content × width) per frame — fine for hundreds of rows. Day-one
perf test (`viewport_perf_test.exs`) holds the budget.
- Scroll-into-view is a render-pipeline phase: focusable elements
record their bounds via `Frame.set_focus_rect/2`; the viewport
reads its child's `tall_frame.focus_rect` and snaps the effective
offset so the focused element stays visible. Model offset is
untouched — this is render-time-only adjustment.
- Cursor positions (set by `text_input`) are remapped from
tall-frame coords to dst coords when the cursor falls in the
visible window; hidden otherwise.
- Optional cosmetic scrollbar: single column on the right edge,
thumb proportional to `visible_h / content_h`.
- `Harlock.Viewport.apply_key/4` translates scroll keys
(`:up | :down | :page_up | :page_down | :home | :end`) into a
new clamped offset. Scroll keys are explicit — app calls the
helper from `update/2`, no runtime interception.
### Mouse events (parser) ✓
Parser landed. Emits
`{:mouse, action, button | nil, col, row, mods}` for SGR encoding
(`CSI < button;col;row M|m`). Actions: `:press | :release | :drag |
:move | :wheel_up | :wheel_down`. Buttons: `:left | :middle | :right |
:extra4 | :extra5`.
**Runtime enabling + hit-test routing — deferred.** The runtime does
not write `\e[?1006h` by default and does not route events to elements.
Apps that need mouse input can write the enable sequence themselves and
match on raw `(col, row)` in `update/2`. Hit-test infra (frames carry
element bounds, runtime resolves cursor → element) waits on a real use
case shaping the API.
### Modified arrows ✓
`CSI 1;<mod><letter>` for arrows + Home/End with shift/alt/ctrl/meta.
`CSI <n>;<mod>~` for modified PageUp/PageDown/Insert/Delete and F1-F12.
### Kitty keyboard protocol (parser) ✓
Parser landed:
- Detection response `CSI ? <flags> u` → `{:capability,
:kitty_keyboard, flags}`.
- Key events `CSI <code>[:<shifted>:<base>][;<mod>[:<type>]] u`.
Event-type 1=press → `{:key, ...}`, 2=repeat → `{:key_repeat, ...}`,
3=release → `{:key_release, ...}`. Kitty private-range codepoints
(57344-57375) map to functional-key atoms.
Enabling the protocol (writing `CSI > <flags> u` to push state) is a
runtime decision deferred until a real use case appears.
### Telemetry instrumentation ✓
Shipped. Events:
- `[:harlock, :frame, :render, :start | :stop | :exception]` — span
wrapping `view/1` + tree walk + diff emission. Stop measurements
include duration; metadata includes app, dirty, rows, cols.
- `[:harlock, :input, :dispatch, :start | :stop | :exception]` — span
wrapping the runtime mailbox → `update/2` return path. Metadata
includes app, event, focused.
- `[:harlock, :cmd, :dispatch]` — one-shot when a cmd is handed to the
task supervisor. Metadata: `%{kind: :fun | :batch | :map | :none}`.
- `[:harlock, :cmd, :complete]` — when a cmd task returns. Measurements:
`%{duration: native}`. Metadata: `%{status: :ok | :error}`.
- `[:harlock, :reader, :tty_lost]` — one-shot on EOF (ssh disconnect,
terminal close).
`Harlock.Telemetry` documents the full catalog. `:telemetry` is a
hard dep (tiny library, no transitive deps).
---
## v0.4 — "absorb the boilerplate" ✓ (shipped 2026-05-18)
The v0.4 plan was re-scoped against [`docs/feedback-v0.3.md`](https://github.com/thatsme/harlock/blob/main/docs/feedback-v0.3.md)
and [`docs/v0.4-plan.md`](https://github.com/thatsme/harlock/blob/main/docs/v0.4-plan.md): instead of growing the widget
roster, v0.4 made the widgets that already shipped cost less to wire.
The original "polish & adoption" sub-sections below are kept as-is for
provenance — entries marked ✓ landed, others moved to v0.5.
### Focus-aware widget key routing (R2) ✓
The headline. Focusable `viewport`, `tabs`, and `text_input` elements
get their navigation keys auto-routed by the runtime; the app's
`update/2` receives a synthesised `{:harlock_scroll | _select | _edit
| _submit, focus_id, payload}` instead of raw `{:key, …}`. Opt out
per-element with `handle_keys: false`. The four routed-message tuples
are public API and documented in `Harlock.App`'s moduledoc.
Applied to `examples/showcase.exs`: the per-field text-input dispatch
clause in `update/2` went 21→7 lines (-67%); the manual
`Viewport.apply_key/4` scroll dispatch went 7→3 lines. No widget calls
`apply_key/_` from `update/2` in showcase any more.
### End-to-end README example ✓
`examples/overview.exs` (also embedded inline in the README between
the Counter snippet and Installation) is a runnable ~70-line app
covering focus traversal, a selectable table, a focusable viewport
with R2 auto-routing, and a `Cmd` round-trip.
`test/examples/overview_test.exs` `Code.require_file`s it so the
README snippet can't rot silently.
### Theming (full token set) ✓
The four-token minimum (`header`, `focus`, `selection`, `border`) landed
in v0.2. v0.4 extends to the full palette and ships the ergonomics around
it:
```elixir
%Theme{
primary: :cyan,
accent: :magenta,
muted: %Style{fg: {:rgb, 128, 128, 128}},
error: :red,
border: %Style{fg: :white},
focus: %Style{reverse: true},
header: %Style{bold: true},
selection: %Style{bg: :cyan}
}
```
- Add the remaining tokens (`primary`, `accent`, `muted`, `error`) and
any per-widget tokens surfaced during v0.3 widget work.
- Built-in themes: `:default`, `:dark`, `:high_contrast`.
- Auto-downgrade truecolor → 256 → 16 based on `Caps`.
- App still configures via `Harlock.run(MyApp, init_arg, theme: theme)`;
`Harlock.Theme.get/1` already exists from v0.2.
### Style cascade ✓
- `box` propagates `:border_style` to title. ✓ (was already true in
v0.3 — `draw_title` shares the border style parameter; verified
during Phase 3.)
- `table` accepts `:header_style`, `:row_style`, `:alt_row_style`,
`:selected_style`, `:focus_style` (all default to theme). ✓
`:alt_row_style` is the only behavioural addition; the rest are
pure overrides that preserve v0.3 output when unset.
- `Style.merge/2` with proper inheritance (child overrides parent;
unspecified attrs inherit). ✓ (already had these semantics in v0.2;
verified during Phase 3.)
### `:default` byte-identical to v0.3 ✓
Pinned in `test/harlock/golden_frame_test.exs`. Hash captured by
running the same canonical app under a git worktree at tag `v0.3.0`,
not by observing the v0.4 implementation — so the test proves parity,
not self-locking.
---
## v0.5 — widgets on the new contract (next)
The work originally listed under v0.4 that was deferred so it could
ship as the **first consumer of the R2 routing contract** instead of
being built against the v0.3 manual-dispatch idiom. Building them
inside v0.4 would have meant writing their key handling against the
old API and rewriting it the moment R2 landed; v0.5 lets them
arrive as native R2 widgets from day one.
### tree / menu / select widgets
- `tree(:nodes, :expanded, :focused, :on_toggle, :on_select)` —
recursive node display with expand/collapse on `Right`/`Left` or
`Enter`. Auto-routed via `{:harlock_select, focus_id, node_id}` and
a new `{:harlock_toggle, focus_id, node_id}`.
- `menu(:items, :on_select)` — vertical list, arrow navigation,
`Enter` selects. Auto-routed via `{:harlock_select, focus_id, id}`.
- `select(:items, :value, :on_change)` — dropdown (uses `overlay` for
the open state). Same routed-select shape.
Each gets its own `apply_key/n` pure helper plus a per-type clause in
`Harlock.App.Runtime.route_to_widget/4`; no new mechanism, just three
new consumers.
### Multi-line text_area
Builds on `text_input` + `viewport`. Word wrap, soft/hard line breaks,
proper cursor across wraps. Word movement (`Ctrl-Left`/`Ctrl-Right`),
line movement (`Up`/`Down` preserving visual column). Routed-edit
message stays `{:harlock_edit, focus_id, {value, cursor}}` — the
cursor type widens slightly to accommodate `{line, col}`.
### Richer Sub kinds
- `Sub.pubsub(pubsub_mod, topic, transform_fn)` — subscribes via
`Phoenix.PubSub`. The killer integration for Phoenix-based ops
dashboards. Deferred from v0.4 because R2 took the cycle.
- `Sub.file(path, opts)` — watch via `:fs` if available, polling
fallback.
- `Sub.signal(:sigusr1, msg)` — wraps `:os.set_signal/2`.
- `Sub.port(cmd, args)` — long-running external process, stdout lines
as events.
### `box(focus_proxy: :child_id)`
Polish for the R2 visual story. With R2 default-on, `:focusable`
lives on the interactive widget (the viewport, the tabs, the text
input) — but the wrapping `box` is what users *see*. The
`focus_proxy:` opt lets a box mirror a named child's focus state for
visual highlighting only (border style, title style) without itself
participating in focus traversal. Until this lands, `examples/overview.exs`
styles its boxes' borders off `Focus.current()` by hand as a workaround
(see the `border_style/1` helper there).
---
## v0.6 — pre-1.0 hardening (~3 weeks)
- **Dialyzer clean** at `:underspecs` + `:overspecs`. Strict specs on all
public functions.
- **Property-based tests** for layout solver (StreamData): for any list of
constraints summing to ≥ 0, output sizes are non-negative and sum to
total. For any frame diff: replay produces equivalent frame.
- **Benchmarks**: `Harlock.Bench` — render a 200×80 frame with N elements,
measure µs per frame. Establish baseline, prevent regressions.
- **Documentation**: every public function has `@doc` + example. Module
guides (`guides/getting_started.md`, `guides/widgets.md`,
`guides/testing.md`, `guides/embedding.md`).
- **Examples expansion**: filemgr (two-pane), todo (text_input + list),
log_viewer (viewport + filter), git_branch_picker (tree).
- **Caps refinement**: detect terminfo entries properly; fallback table for
common terminals. Document the `Caps` API as public.
- **Public API freeze candidate**: walk every `@moduledoc false`, decide
stable-public vs internal-forever. Move stable parts to `@moduledoc`
proper.
## v1.0 — stable API, Hex release
- Public API frozen per the `@moduledoc` decisions above.
- All v0.6 hardening complete.
- Hex publish, hexdocs.pm live.
- Announcement post + Reddit/Elixir Forum thread.
- Minimum supported: Elixir 1.17+, OTP 26+ (decide closer to date).
---
## Out of scope (probably forever)
- Windows native console. The TTY layer is POSIX-only and that's fine.
WSL works.
- Rendering anything other than monospace cells. No Sixel, no Kitty
graphics protocol, no images. Different project.
- Web export. Different project.
## Stretch / "would be cool"
- **`Harlock.LiveView`** — a Phoenix LiveView hook that renders the same
element tree to HTML for remote viewing. Same model, two backends.
Probably v1.x.
- **Hot reload** of the app module during dev. Possible because TEA is
pure — re-invoke `view/1` after code change. Needs careful supervisor
handling.
- **Recording / replay** — log every event + final frame, replay
deterministically for bug reports. Useful for headless testing too.
## Notes for whoever picks this up
- The runtime is the spine. Don't add state to it casually — every field
is load-bearing for focus/render/sub lifecycle. New features that need
process state usually want their own GenServer, not a runtime field.
- The renderer is pure. Keep it that way. If you find yourself wanting
`IO.puts` in there, you're doing it wrong.
- Always test the crash path. `smoke_crash/0` is the template — kill a
linked process mid-render, assert the terminal is restored. New IO
paths get the same treatment.
- Read the `App.Supervisor` comment block before changing supervisor
config. The `rest_for_one` + `:temporary` runtime + Keeper-first
ordering is the entire correctness argument for clean teardown.