A pure-Elixir TUI framework for Unix terminals. TEA-style model/update/view loop on top of OTP, no NIFs, no ports for the core rendering path.

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 core. ANSI in, ANSI out. NIFs only if a NIF-optional path measurably ships (e.g. termios via port).
  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


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 — "polish & adoption" (~4 weeks)

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:

%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.
  • table accepts :header_style, :row_style, :alt_row_style, :selected_style, :focus_style (all default to theme).
  • Style.merge/2 with proper inheritance (child overrides parent; unspecified attrs inherit).

tree / menu / select widgets

  • tree(:nodes, :expanded, :focused, :on_toggle, :on_select) — recursive node display with expand/collapse on Right/Left or Enter.
  • menu(:items, :on_select) — vertical list, arrow navigation, Enter selects.
  • select(:items, :value, :on_change) — dropdown (uses overlay for the open state).

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).

Richer Sub kinds

  • Sub.pubsub(pubsub_mod, topic, transform_fn) — subscribes via Phoenix.PubSub. Killer integration for Phoenix-based ops dashboards.
  • 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.

v0.5 — 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.5 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.