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 viaKeeper.terminate/2. This is correct and load-bearing — don't regress it. - TEA loop with
init/1,update/2,view/1, optionalsubs/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.Testbackend selectable viabackend: :testfor deterministic tests without a TTY. - Examples:
counter,sysmon. Smoke tests driven byscript(1)(handles BSD vs util-linux flag differences).
What's stubbed / missing — the honest list:
Cmdexecutor: runtime ignores the{model, cmd}return tuple entirely. No async work, no IO, no HTTP. The single biggest hole.Sub: only:intervalexists.- Layout:
:minand:maxconstraints 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. Stylenot consistently cascaded (table headers hard-codebold, focused box hard-codesreverse).- No
row.exhelper (onlycolumn.ex). mix.exshas no package metadata. README is themix newdefault.- No CI, no Dialyzer, no Credo wired in.
Guiding principles
- OTP-first. The supervision tree is the architecture. New features that need processes get their own child, supervised correctly.
- No NIFs in the core. ANSI in, ANSI out. NIFs only if a NIF-optional path measurably ships (e.g. termios via port).
- Phoenix devs feel at home.
update/view/subsmirrors LiveView's mental model on purpose.Sub.pubsubshould be a first-class citizen. - Headless-testable. Every new widget gets
IO.Test-driven coverage. No "works on my terminal" features. - Terminal restoration is sacred. Any new IO path must survive crashes without leaving the terminal in raw mode / alt-screen. Test it.
- 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 falseand 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. Wiremix docs. .github/workflows/ci.yml:mix format --check-formatted,mix test,mix dialyzer,mix credo --stricton push + PR..credo.exstuned to a green baseline on v0.1 code.- Dialyzer PLT cached in CI; baseline clean on v0.1 before opening v0.2 PRs.
CHANGELOG.mdseeded 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_continuedispatches 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:temporaryon individual tasks. - Implement
Cmd.from/1: runs the 0-arity fn underTask.Supervisor, sends result back as{:harlock_event, result}. - Implement
Cmd.batch/1: spawns each child cmd concurrently, no ordering guarantees. - Add
Cmd.map/2for 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/2returning{model, cmd}, dispatch cmd, update model, render. On:quitwith 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+) inKeeper(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 consideredtput cols/tput linesto avoid native code, but:os.cmd-based shell-outs lose the controlling tty (every shell spawned by ERTS issetsid()-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, _, _}: updatestate.rows/state.cols, discardprev_frame(full redraw — diff against differently-sized buffer is meaningless), mark dirty, render. - Initial size at Runtime startup also comes from
Keeper.size/1(synchronousGenServer.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 viamix harlock.gen_width. - Replace
String.lengthwithHarlock.Width.string_width/1in:clip/2,align_text/3,draw_title/6, table column rendering, and the newtext_inputcursor math. - Zero-width joiner / variation selectors: keep with the preceding grapheme, don't advance the cursor.
Cellstays one codepoint per cell; wide graphemes occupycell + cell'wherecell'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.Themestruct 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 asFocus).- App passes
theme: %Theme{...}toHarlock.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: truefocused row),:254(focus default),:214, 217(bg: :cyanselection) 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} | niltoFrame.Writeremits cursor-show / cursor-position after diff, or cursor-hide if nil.text_inputelement 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_changewith 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 fromupdate/2. Keeps the element dumb, model honest. - Keys: printable chars insert,
Backspacedeletes-back,Deletedeletes-forward,Left/Rightmove,Home/Endjump,Entersubmits.
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
Frameof{requested_w, large_h}, then blits the visible slice into the real region. - Emits scroll messages on
PgUp/PgDn/Up/Downwhen 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,docsconfig (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
sysmonembedded in README.
v0.3 — "shows well in demos" (~3 weeks after v0.2)
Layout: real :min and :max
Two-pass solver:
- Sum all
:minand:length. If > total, behave as today (truncate from tail, warn). - Distribute remainder across
:fillconstraints proportional to weight. - Apply
:maxcaps: any fill exceeding its:maxgets 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)— usesSub.intervalinternally. 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 withvboxand{: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_downand button is:left | :middle | :rightfor 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>forCtrl/Alt/Shift + arrows/home/end.- Detect kitty support via
\e[?uquery 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/1already exists from v0.2.
Style cascade
boxpropagates:border_styleto title.tableaccepts:header_style,:row_style,:alt_row_style,:selected_style,:focus_style(all default to theme).Style.merge/2with 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 onRight/LeftorEnter.menu(:items, :on_select)— vertical list, arrow navigation,Enterselects.select(:items, :value, :on_change)— dropdown (usesoverlayfor 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 viaPhoenix.PubSub. Killer integration for Phoenix-based ops dashboards.Sub.file(path, opts)— watch via:fsif 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
CapsAPI as public. - Public API freeze candidate: walk every
@moduledoc false, decide stable-public vs internal-forever. Move stable parts to@moduledocproper.
v1.0 — stable API, Hex release
- Public API frozen per the
@moduledocdecisions 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/1after 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.putsin there, you're doing it wrong. - Always test the crash path.
smoke_crash/0is the template — kill a linked process mid-render, assert the terminal is restored. New IO paths get the same treatment. - Read the
App.Supervisorcomment block before changing supervisor config. Therest_for_one+:temporaryruntime + Keeper-first ordering is the entire correctness argument for clean teardown.