0002 — TUI skeleton: process shape, event flow, and terminal lifecycle

Context

Phase 1 builds the ex_ratatui application shell: status bar, scrollable log, multi-line input — no LLM, streaming, or tool execution. Before designing, the ex_ratatui 0.17.1 surface was read from source and probed empirically (a throwaway script under mix run, not committed). Facts that shaped this ADR:

Decision

Process shape

DashFloat.Application (Supervisor, one_for_one)
├── DashFloat.StreamSupervisor   (Task.Supervisor — demo producer now, stream handlers in Phase 2)
└── DashFloat.TUI                (use ExRatatui.App → ExRatatui.Server, the UI state process)

Event flow and module decomposition

keyboard ──poll──▶ handle_event/2 ─┐
                                  ├─▶ DashFloat.TUI.State  (pure transitions)
messages ──send──▶ handle_info/2 ─┘         │
                                            ▼
                              DashFloat.TUI.View.render/2 (pure)
                                            │
                                            ▼
                             [{widget, rect}] → Server draws (NIF)

Layout — three regions

Vertical split of the full frame: status {:length, 1} (top), log {:min, 0} (middle), input {:length, input_height} (bottom), where input_height = 2 + textarea_line_count clamped to [3, max(3, height ÷ 3)]. The input box grows as the draft gains lines and never exceeds a third of the frame. The same pure split feeds both View.render/2 and State's page-scroll arithmetic — one source of truth.

Scrollback model

Input mechanics and Enter vs Ctrl+Enter

Stream message contract (the Phase 2 seam)

Everything that isn't keyboard input reaches the UI as plain messages into handle_info/2:

Message Effect
{:token, text} append to the in-flight streaming entry (opened lazily)
{:phase, :streaming} set the phase only — re-announcing streaming never finalizes; a producer resuming after a tool interlude must not fragment its reply
{:phase, :executing_tool | :idle} set the status phase and finalize the in-flight entry into the log
{:entry, :tool | :system, text} append a complete entry

Phase 2's stream handler replaces the demo producer but sends these same messages — that is the entire integration surface between the streaming engine and the UI.

The producer runs as a monitored, restart: :temporary task under DashFloat.StreamSupervisor. A dead producer must never brick the UI (the ADR 0001 rule: failures degrade to chat messages): its {:DOWN, …} monitor message finalizes any partial reply, appends a :system entry explaining the failure, and returns the phase to :idle so input keeps working. Abnormal exits are never restarted — the handler owns retry semantics, and an auto-restart would duplicate the reply. One race is accepted by policy: a producer that exits between start_child/2 returning and the monitor attaching yields :noproc, which is indistinguishable from success after the fact — it is treated as a normal exit (the fast demo cadence hits this window routinely). Note the flip side: a producer that crashes that early is swallowed the same way — no error entry, just a missing reply. Phase 2 producers must therefore always send their own terminal {:phase, _} message before exiting; the monitor is a safety net, not the reporter of record.

Synthetic-update demo path

Superseded by ADR 0005: the demo module is deleted and the real SSE producer takes its place. The description below is kept as the record of what Phase 1 shipped.

DashFloat.SyntheticStream.emit(ui_pid, prompt, opts) runs under DashFloat.StreamSupervisor as a Task (started by the TUI on submit) and drives the full contract on a canned reply: ~60 word-tokens at ~25 ms intervals, an executing_tool interlude with a simulated list_directory entry, then :idle. It performs no I/O beyond send/2 and :timer.sleep, proving token-rate messages never block input handling — the UI stays typeable while tokens land (verified by the responsiveness test, not by assertion).

Teardown and crash paths

The terminal must never be left broken:

Non-blocking rule

handle_event/2, handle_info/2, and render/2 perform zero network, filesystem, shell, or timer work. The only side effect they may trigger is Task.Supervisor.start_child/2 on submit (cheap, non-blocking). Anything slow lives in a Task under DashFloat.StreamSupervisor and reports back via messages. Render errors are already contained by the runtime (frame dropped, logged, loop continues).

Alternatives considered

Consequences