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

Two-pass solver:

  1. Sum all :min and :length. If > total, behave as today (truncate from tail, warn).
  2. Distribute remainder across :fill constraints proportional to weight.
  3. Apply :max caps: any fill exceeding its :max gets clamped, the excess redistributed to non-capped fills. Iterate to fixpoint (max 3 passes, bail with warning otherwise).

Tests: percentage + min + fill + max combinations; over-constraint; unsatisfiable max.

Standard widgets

All composable from existing primitives; ship as named modules for ergonomics and consistency.

  • progress(:value, :max, :width, :style, :fill_style) — single-line bar.
  • spinner(:frames, :interval, :style) — uses Sub.interval internally. Caller passes a model field for the tick counter (TEA discipline).
  • tabs(:items, :active, :on_select, :focusable) — horizontal bar + region underneath, focus integrates with traversal.
  • statusbar(:left, :right, :style) — pinned-bottom helper, often paired with vbox and {:length, 1}.
  • keybar(:bindings, :style)[{?q, "quit"}, {?n, "new"}] → rendered [q] quit [n] new.

Mouse events

SGR mouse mode \e[?1006h on init, \e[?1006l on teardown (via Caps + Writer).

  • Parser emits {:mouse, action, {row, col}, mods} where action is :press | :release | :drag | :wheel_up | :wheel_down and button is :left | :middle | :right for press/release/drag.
  • Runtime does a hit-test against the last Frame (frames carry element-id metadata for hit-testable cells — new infra) and routes to the right element.
  • Defer global mouse-down dragging across regions to v0.4.

Modified arrows + kitty keyboard protocol

  • CSI 1;<mod><letter> for Ctrl/Alt/Shift + arrows/home/end.
  • Detect kitty support via \e[?u query at startup, set protocol level if supported. Falls back to legacy parsing otherwise.
  • Emit unified {:key, key, mods} events regardless of source protocol.

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.