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 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 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.
- 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 ✓
Shipped. The solver:
- Computes a lower bound per slot:
:length(n)→n,:percentage(p)→p%,:min(n)→n,:fill(_)→0,:max(_)→0. - If lower bounds exceed total → truncate from tail and warn (unchanged over-constraint behavior).
- Distributes the remainder across flexible slots.
:fill(weight)carries its weight;:minand:maxeach carry weight 1. - Clamps
:maxviolators to their cap, returns excess to remaining, iterates. Convergence is bounded bylength(constraints)— each pass either freezes ≥1 slot or terminates. - If
:maxcaps 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:tickfrom aSub.intervalsubscription.tabs(:items, :active, :focusable, :style, :active_style)— horizontal tab bar; the body for the active tab is rendered separately by the app. Pair withHarlock.Tabs.apply_key/3inupdate/2for 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:offsetand:content_height(app knows what it's scrolling).- Renderer allocates a
width × content_heighttemporary 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'stall_frame.focus_rectand 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/4translates scroll keys (:up | :down | :page_up | :page_down | :home | :end) into a new clamped offset. Scroll keys are explicit — app calls the helper fromupdate/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 wrappingview/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/2return 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
and 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_files 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:
%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. ✓ (was already true in v0.3 —draw_titleshares the border style parameter; verified during Phase 3.)tableaccepts:header_style,:row_style,:alt_row_style,:selected_style,:focus_style(all default to theme). ✓:alt_row_styleis the only behavioural addition; the rest are pure overrides that preserve v0.3 output when unset.Style.merge/2with 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 onRight/LeftorEnter. 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,Enterselects. Auto-routed via{:harlock_select, focus_id, id}.select(:items, :value, :on_change)— dropdown (usesoverlayfor 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 viaPhoenix.PubSub. The killer integration for Phoenix-based ops dashboards. Deferred from v0.4 because R2 took the cycle.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.
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
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.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/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.