0006 — UI truth & rendering: markdown log, live status, activity line, input hardening

Context

After Phase 2 the TUI works but lies: placeholder status fields, raw markdown, no activity feedback, no paste hardening. The visual north star is Claude Code's console — minimal top bar, warm terracotta accent, dim secondary text, rounded bordered input with a > glyph, animated activity line while working — implemented fresh with our own branding (no Anthropic wordmarks, no verbatim UI strings). Out of scope (binding, from the issue): syntax highlighting inside code blocks, resizable panes, mouse support, modal/confirmation UI.

Dependency facts verified from source before deciding (ex_ratatui 0.17.1, earmark_parser 1.4.46):

Decision

Visual language

Four regions; the activity row exists only while work is in flight:

┌ status ─ 1 row ──────────── ❯ ~/FluxVale/dashfloat · model · 4.1k tok · 30 tok/s ● Streaming
│ log     :min 0             (windowed; markdown-rendered assistant rows)
├ activity 1 row (0 idle) ── ⠋ Streaming…  esc interrupt
└ input   2 + draft lines ── rounded border, ❯ title, placeholder, soft wrap

Theme as data

DashFloat.TUI.Theme is a map of role → %Style{} (Theme.style/1), so Phase 3 extends the vocabulary by adding roles, not code:

Role Style
:accent {:rgb, 217, 119, 87} — terracotta (#d97757)
:dim :dark_gray
:dim_italic :dark_gray + italic (system rows)
:user :dark_gray
:assistant default foreground
:tool :dark_gray (accent prefix glyph is Phase 3's to add)
:heading1 accent + bold
:heading2 bold
:heading3 bold + dim (h4–h6 map here)
:emphasis_bold / :emphasis_italic / :strikethrough modifiers
:inline_code {:indexed, 215} (warm)
:code fg {:indexed, 252}, bg {:indexed, 234} (fenced regions)
:code_lang dim (fenced-block language tag)
:link underlined
:quote dim
:rule dim
:hint dim (activity-line interrupt hint, separators)
:status_busy / :status_tool / :status_idle accent / :light_yellow / dim

The palette is warm-dim by intent: one accent, grays, two warm support colors — no rainbow.

Markdown pipeline

Entries carry rendered lines

Entries become maps %{kind: :user | :assistant | :tool | :system, text: String.t(), lines: [Line.t()]} (was {kind, text}). text is kept verbatim for persistence (Phase 4); lines is the render truth built once at append by DashFloat.TUI.Render.entry_lines/2. The raw text never re-renders. (Amended: entries also carry logical — the width-free render truth — and lines is its wrap at the current width, re-wrapped on resize; see the wrap amendment.)

Status-bar truth

Clock discipline

State.stream_message/3 and State.tick/2 take now (monotonic milliseconds) with a System.monotonic_time/1 default — pure functions with an injectable clock. render/2 never reads the clock: the displayed rate lives in state, updated by tick/finalize.

Animation model — active-only tick

A :dashfloat_tick message every 125 ms via Process.send_after/3, scheduled iff phase != :idle and no tick is pending (a tick_ref in state), checked at a single choke point after every handle_event/handle_info callback. The tick handler steps the spinner, re-renders dirty in-flight markdown (bounded as above), recomputes the rate, and reschedules only if still active; any transition to :idle cancels the pending timer. A tick message that arrives stale (its timer was cancelled after the message queued) — or that halts — returns render-suppressed, so it can never draw a frame. Idle ⇒ no timers ⇒ no renders. This is the second sanctioned exception to ADR 0002's non-blocking rule (the first is submit-time start_child): send_after schedules a message and blocks nothing.

The activity animation exists only while a stream/tool is live — this is both the Claude-Code feel and the fleet's parked-fan insurance: an idle DashFloat renders nothing on a timer, ever.

Message contract extension (ADR 0002 table)

Message Effect
{:usage, %{prompt_tokens: n, completion_tokens: n, total_tokens: n}} reconcile token counts, hold the frame's total as the turn's banked total (last frame wins; non-negative integers only — anything else is ignored by the producer); no phase change

Sent before the terminal {:phase, :idle} when the provider includes usage (the stream's final chunk). Engines stay TUI-agnostic — it is data.

Paste, wrap, cursor

Render-rate counter

Decision: no new render-loop code. The metric of record is ExRatatui.Runtime.snapshot/1 (render_count, last_rendered_at), already maintained by the server. Tests and dev probes measure rates by sampling snapshots — which doubles as the animation-discipline proof: renders increase while a stream is live (ticks) and stay flat over an idle window with no input. Keeping render/2 free of counters preserves its purity.

Interface freeze

The exact seams implementers build against:

Alternatives considered

Amendment: log word-wrap (issue #20)

Live testing (#20) showed the shipped contract truncating long log lines at the pane edge — cut off, not soft-wrapped — exactly as ADR 0002 had decided and this ADR had retained. The product answer changed: the log wraps. What was decided, amending this ADR (and ADR 0002's truncation clause):

ADR 0002's scroll-math rationale ("wrapped log lines" rejected; truncation keeps the math exact) is reversed by this amendment: wrapping keeps the math exact by storing the wrapped lines as the counted truth, and the truncation it defended was the bug.

Amendment (2026-10-25, issue #28): margin, exit guard, mouse selection

Status: Accepted. Extends this ADR's layout, input-contract, and render-rate sections; supersedes nothing in ADR 0002 beyond what the mouse amendment below already reversed. Live smoke testing (Oct 25) drove all three.

Margin geometry — the padded width is the only width

One cell of padding on every side of the chrome: View.regions/3 splits an inner area inset by one from the frame, so no text hugs a screen edge.

Double Ctrl+C exit guard — and the sibling-key survey

Raw mode makes Ctrl+C a key event, and one accidental press must not kill a live session.

Mouse selection and OSC 52 copy

Mouse capture (#21) took the terminal's native selection away; the app now owns selection over the log.

Consequences


Amendment (2026-10-24, issue #21): mouse wheel routing

Status: Accepted. Supersedes exactly one Phase-1 stance — the "mouse capture for scrolling — rejected" alternative in ADR 0002. Everything else in ADR 0002's input and scroll contract stands.

Root cause (the record)

DashFloat always runs in the alternate screen. With mouse tracking off — the Phase-1 default — xterm-class terminals (xterm alternateScroll, VTE, Windows Terminal, iTerm2, Alacritty, WezTerm, foot) translate each wheel notch into Up/Down arrow-key escape sequences, byte-identical to real arrow presses. They decoded as ordinary key events and landed in State.handle_key/3 for "up"/"down" → input-history navigation. The live bug: the wheel walked the input draft instead of scrolling the log. Interception at the event boundary is therefore impossible — emulated and genuine arrows are the same bytes at every layer the app can observe.

Decision

Costs and compatibility