How a Model becomes terminal cells. agentty is a pure data adapter: it
extracts state from the runtime model and emits widget Configs.
maya owns every Element, every chrome glyph, every layout decision,
every breathing animation. The host app constructs no Elements.
Read this alongside UI.md, which catalogs the maya DSL
primitives that the widgets are built on top of.
┌────────────────────────── terminal viewport ─────────────────────────────┐
│ │ ─┐
│ ─── [↺ Restore checkpoint] ───────────────────────────────── ┐ │ │
│ ┃ ❯ You 12:34 · turn 1│ │ │
│ ┃ │ │ │
│ ┃ refactor the login flow to use the new auth provider │ │ │
│ ───────────────────────────────────────────────────────────── ┘ │ │
│ │ │
│ ┃ ✦ Opus 4.7 12:34 · 4.2s · turn 1│ │ │ Thread
│ ┃ │ │ │ (welcome
│ ┃ I'll start by exploring the current auth structure. │ │ │ if empty,
│ ┃ │ │ │ conversation
│ ┃ ╭─ ACTIONS · 3/3 · 1.8s ─────────────────────────────╮ │ │ │ otherwise)
│ ┃ │ I N S P E C T 2 · M U T A T E 1 │ │ │ │
│ ┃ │ ╭─ ✓ Read src/auth/login.ts 42ms │ │ │ │
│ ┃ │ │ │ import { Session } from './session'; │ │ │ │
│ ┃ │ │ │ ··· 80 hidden ··· │ │ │ │
│ ┃ │ │ │ export default login; │ │ │ │
│ ┃ │ ├─ ✓ Grep provider in src/auth 190ms │ │ │ │
│ ┃ │ │ │ src/auth/login.ts:14: const provider = … │ │ │ │
│ ┃ │ ╰─ ✓ Edit src/auth/login.ts (+5 -2) 1.6s │ │ │ │
│ ┃ │ │ - const provider = legacyAuth(); │ │ │ │
│ ┃ │ │ + const provider = await NewAuth.create({ │ │ │ │
│ ┃ │ ✓ DONE 3 actions 1.8s │ │ │ │
│ ┃ ╰────────────────────────────────────────────────────╯ │ │ ─┘
│ │
│ ╭─────────────────────────────────────────────────────────────────╮ │ ─┐
│ │ Changes (2 files) Ctrl+R review A accept X reject │ │ │ ChangesStrip
│ │ 2 files changed +12 -3 │ │ │ (only when
│ │ ~ src/auth/login.ts +5 -2 │ │ │ pending)
│ │ + src/auth/types.ts +7 -1 │ │ │
│ ╰─────────────────────────────────────────────────────────────────╯ │ ─┘
│ │
│ ╭─ ⠋ — type to queue… ──────────────────────────────────╮ │ ─┐
│ │ ❯ ▎ │ │ │ Composer
│ │ │ │ │
│ │ ↵ send · ⇧↵/⌥↵ newline · ^E expand ▎ Write │ │ ─┘
│ ╰────────────────────────────────────────────────────────╯ │
│ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ │ ─┐
│ ▎ Title · ▌ ⠋ Streaming 4.2s ⚡ 23.4 t/s ▁▂▃▅▇ ●Opus CTX 18% │ │
│ │ │ StatusBar
│ ^K palette · ^J threads · ^T todo · ^N new · ^C quit │ │
│ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ ─┘
└──────────────────────────────────────────────────────────────────────────┘
When a modal/picker is open, an Overlay floats above the base, centered
horizontally, pinned to the bottom edge, with an opaque background.
agentty constructs no Elements. Every
Element{...}, everydsl::v(...), everydsl::h(...), everydsl::text(...)lives in a maya widget. agentty extracts state into widget Configs and lets maya render.
Concrete: every .cpp under src/runtime/view/ (except the legacy
overlay files in §13) contains zero Element construction. Each
file is a function Model → SomeWidget::Config, one widget = one
adapter file, and the directory layout mirrors the widget tree
(see §13).
The single exception is cached_markdown_for (private to
thread/turn/turn.cpp): it returns an Element because
maya::StreamingMarkdown is stateful and its block-cache must
persist across frames. agentty caches the widget instance, calls
set_content() on it, and slots the resulting Element into a Turn
body slot via the typed Element variant. No Element{...} literals
— only widget.build() calls.
maya::AppLayout top-level chat-app frame
├── maya::Thread conversation viewport
│ │
│ ├── maya::WelcomeScreen (when messages.empty())
│ │ wordmark + tagline + chips +
│ │ starters card + hint row
│ │
│ └── maya::Conversation (when !messages.empty())
│ │ list of turns + dim dividers + optional in-flight
│ │
│ ├── maya::Turn[*] one speaker turn (rail + header + body)
│ │ │
│ │ ├── maya::CheckpointDivider (above turn, outside rail)
│ │ │ "─── [↺ Restore checkpoint] ───"
│ │ │
│ │ └── body slots (typed variant — Turn auto-spaces between):
│ │ ├── PlainText user message text
│ │ ├── MarkdownText maya::markdown(content)
│ │ ├── maya::AgentTimeline Actions panel (one per assistant turn)
│ │ │ │
│ │ │ ├── stats row "INSPECT 2 · MUTATE 1"
│ │ │ ├── per-event header tree glyph + status icon + name + detail + duration
│ │ │ ├── maya::ToolBodyPreview body content under │ stripe
│ │ │ │ │ discriminated by Kind:
│ │ │ │ ├── CodeBlock head+tail preview, dimmed
│ │ │ │ ├── EditDiff multi-hunk per-side diff
│ │ │ │ ├── GitDiff per-line +/-/@@ coloring
│ │ │ │ ├── TodoList ✓ ◍ ○ checkbox list
│ │ │ │ └── Failure red preview block
│ │ │ └── footer "✓ DONE 3 actions 1.4s"
│ │ │
│ │ ├── maya::Permission inline permission card
│ │ │
│ │ └── Element escape hatch (cached StreamingMarkdown)
│ │
│ └── maya::ActivityIndicator (optional, bottom of thread)
│ "▎ ⠋ streaming…" — only when active and no Timeline visible
│
├── maya::ChangesStrip pending-edits banner
│ │
│ ├── header row "Changes (2 files) Ctrl+R review A accept X reject"
│ └── maya::FileChanges file list with +/− line counts
│
├── maya::Composer bordered input box
│ │
│ ├── prompt + body rows state-driven color (idle/streaming/awaiting)
│ └── hint row (width-adaptive)
│ │
│ ├── shortcuts (left) ↵ send · ⇧↵ newline · ^E expand
│ └── ambient (right) queue · words · tokens · profile chip
│
├── maya::StatusBar bottom panel (5 rows tall — fixed-height)
│ │
│ ├── maya::PhaseAccent (top) ▔▔▔▔▔▔ in phase color, dim
│ │
│ ├── activity row (width-adaptive)
│ │ ├── maya::TitleChip ▎ thread title (truncated)
│ │ ├── maya::PhaseChip colored glyph + verb + elapsed (breathing)
│ │ ├── maya::TokenStreamSparkline ⚡ rate · ▁▂▃▄ · total
│ │ ├── maya::ModelBadge ● Opus / Sonnet / Haiku
│ │ └── maya::ContextGauge CTX usage with green/amber/red zones
│ │
│ ├── maya::StatusBanner error/toast banner (always 1 row → no jitter)
│ │
│ ├── maya::ShortcutRow ^K palette · ^J threads · …
│ │
│ └── maya::PhaseAccent (bottom) ▁▁▁▁▁▁ in phase color, dim
│
└── maya::Overlay (when a modal is open)
│
├── base everything above (z-stacked underneath)
└── overlay centered horizontally, pinned bottom,
opaque background to mask the base
Every name above is a real widget at maya/include/maya/widget/<name>.hpp.
agentty::ui::view(m) [view.cpp]
↓
builds maya::AppLayout::Config { … }
.thread = thread_config(m) [thread/thread.cpp]
.changes_strip = changes_strip_config(m) [changes_strip.cpp]
.composer = composer_config(m) [composer.cpp]
.status_bar = status_bar_config(m) [status_bar/status_bar.cpp]
.overlay = pick_overlay(m) [view.cpp]
↓
AppLayout{cfg}.build()
↓
v(
Thread{cfg.thread}.build() | grow(1.0f),
ChangesStrip{cfg.changes_strip}.build(),
Composer{cfg.composer}.build(),
StatusBar{cfg.status_bar}.build()
) | pad<1> | grow(1.0f)
↓
Overlay{base, cfg.overlay}.build()
↓
Element (one tree of BoxElement / TextElement / ComponentElement)
↓
maya layout engine → Canvas → terminal cells
Each *_config adapter is its own file matching the widget name; the
directory tree mirrors the widget hierarchy (§13).
view() is one declarative struct expression. No imperative chaining,
no if branches around zstack / vstack, no element construction —
just a Config populated from Model data.
// src/runtime/view/view.cpp — the entire body of view():
maya::Element view(const Model& m) {
return maya::AppLayout{{
.thread = thread_config(m),
.changes_strip = changes_strip_config(m),
.composer = composer_config(m),
.status_bar = status_bar_config(m),
.overlay = pick_overlay(m),
}}.build();
}auto base = (v(
v(Thread{cfg_.thread}.build()) | grow(1.0f),
ChangesStrip{cfg_.changes_strip}.build(),
Composer{cfg_.composer}.build(),
StatusBar{cfg_.status_bar}.build()
) | pad<1> | grow(1.0f)).build();
Overlay::Config oc;
oc.base = std::move(base);
if (cfg_.overlay) { oc.overlay = *cfg_.overlay; oc.present = true; }
return Overlay{std::move(oc)}.build();That's the whole top-level layout. Four sections in a vstack with the
Thread growing to fill, all wrapped in pad<1>. If overlay is
present, Overlay z-stacks it on top with center-bottom alignment.
if (cfg_.is_empty)
return WelcomeScreen{cfg_.welcome}.build();
return Conversation{cfg_.conversation}.build();Thread::Config nests WelcomeScreen::Config and
Conversation::Config; the widget just picks the branch. Each
sub-widget gets its own agentty adapter (thread/welcome_screen.cpp,
thread/conversation.cpp).
Conversation::Config itself nests typed sub-configs:
struct Conversation::Config {
std::vector<Turn::Config> turns;
std::optional<ActivityIndicator::Config> in_flight;
};The widget builds each Turn from its config internally — the host
never assembles a vector<Element> of pre-built turns.
The most interesting widget. Turn::Config::body is a typed variant:
using BodySlot = std::variant<
PlainText, // user/plain text path
MarkdownText, // string → maya::markdown()
AgentTimeline::Config, // tool-calls Actions panel
Permission::Config, // inline permission card
Element // escape hatch (cached StreamingMarkdown)
>;
std::vector<BodySlot> body;Turn:
- Renders the header row (
<glyph> <label> ___ <meta>). - Walks each body slot, dispatches via
std::visitto the right widget invocation, and inserts a blank line between consecutive non-empty slots — callers don't push spacers. - If
erroris non-empty, appends a⚠ <message>row. - Wraps everything in the bold left-only border (the speaker rail) in
rail_color. - If
checkpoint_above, prepends aCheckpointDivideroutside the rail (between-turns concern, not inside the rail).
Per-slot widget invocation:
| BodySlot variant | Renderer inside Turn |
|---|---|
PlainText |
text(content, fg) |
MarkdownText |
maya::markdown(content) |
AgentTimeline::Config |
AgentTimeline{cfg}.build() |
Permission::Config |
Permission{cfg}.build() |
Element |
the Element itself (escape hatch) |
The escape-hatch Element slot exists for one reason: cross-frame
caching. maya::StreamingMarkdown keeps a per-block parse cache that
must survive between renders, so agentty holds the widget instance in
its MessageMdCache and feeds the resulting Element back through
the slot list. That's the only Element-producing call agentty makes.
The Actions panel. Composition:
╭─ ACTIONS · 3/5 · Bash ─────────────────────────────╮
│ I N S P E C T 2 · M U T A T E 1 │ ← stats row (only when events > 1)
│ │
│ ╭─ ⠋ Bash npm test 1.2s │ ← per-event header
│ │ │ PASS test/foo.test.ts │ ← ToolBodyPreview rows under │ stripe
│ │ │ ✓ all 5 tests passed │
│ │ │ ← inter-event connector (next status color)
│ ├─ ✓ Read src/foo.ts 38ms │
│ │ │ import { bar } from './bar'; │
│ ╰─ ✓ Edit src/foo.ts 210ms │
│ │ edit 1/2 · −1 / +3 │
│ │ - const provider = … │
│ │ + const provider = await … │
│ │
│ ✓ DONE 3 actions 1.4s │ ← footer (only when all settled)
╰─────────────────────────────────────────────────────╯
Each AgentTimelineEvent carries:
struct AgentTimelineEvent {
std::string name; // "Bash", "Read", …
std::string detail; // "npm test · exit 0"
float elapsed_seconds;
Color category_color; // inspect/mutate/execute/plan/vcs
AgentEventStatus status; // Pending/Running/Done/Failed/Rejected
ToolBodyPreview::Config body; // typed body — no Elements
};For each event the widget:
- Picks the tree glyph (
──/╭─/├─/╰─based on position). - Picks the status icon (10-frame braille spinner for active states;
✓ ✗ ⊘for terminal). - Renders
name + detail + (optional duration). - Builds the body via
ToolBodyPreview{ev.body}.build()and stripes each line with the│connector, inevent_connector_color(status). - Inserts a short inter-event connector colored by the next event's status (so the lane visually flows into the upcoming event).
Footer is rendered only when every event is terminal: ✓ DONE /
✗ N FAILED / ⊘ N REJECTED + count + total elapsed.
A discriminated body widget. Config::kind picks the renderer:
| Kind | Inputs | Rendering |
|---|---|---|
None |
— | empty Element (skipped) |
CodeBlock |
text, text_color |
head+tail preview (4+3 lines) with ··· N hidden ··· mark |
Failure |
text |
same as CodeBlock but in Color::red() |
EditDiff |
hunks[] |
per-hunk header edit i/N · −k / +m, head+tail per side |
GitDiff |
text |
per-line styling (+green / -red / @@dim / context plain) |
TodoList |
todos[] |
✓ completed (dim), ◍ in-progress, ○ pending |
All elision math (split lines → keep first head + last tail →
insert dim middle marker) lives inside the widget. agentty just provides
the raw text / hunks[] / todos[].
┌─┐┌─┐┌─┐┌┐ ┌┬┐┌┬┐┬ ┬
├─┤│ ┐├─ │└┐ │ │ └┬┘
┴ ┴└─┘└─┘┘ ┘ ┴ ┴ ┴
a calm middleware between you and the model
● Opus 4.7 ▌ WRITE ▐ ← chips row
╭─ T R Y ──────────────────────────────╮
│ │
│ • Implement a small feature │
│ • Refactor or clean up this file │
│ • Explain what this code does │
│ • Write tests for ... │
╰───────────────────────────────────────╯
type to begin · ^K palette · ^J threads · ^N new
agentty supplies brand content (wordmark glyphs, tagline, starter prompts, hint keys); the widget owns the layout, the wordmark gradient ("last row dim"), the small-caps title, the centering.
╭─────────────────────────────────────────────────────╮
│ Changes (2 files) Ctrl+R review A accept X reject│
│ 2 files changed +12 -3 │
│ ~ src/auth/login.ts +5 -2 │
│ + src/auth/types.ts +7 -1 │
╰─────────────────────────────────────────────────────╯
When cfg.changes is empty, the widget renders to an empty Element so
the slot collapses cleanly without an if in the host.
State-driven: border + prompt color reflect activity (idle / awaiting
permission / streaming / executing tool); placeholder text changes
("type a message…" / "running tool — type to queue…"); height pins to
min_rows during activity to prevent vertical jitter from layout
reflows above. Hint row is width-adaptive — drops expand then
newline keys as width shrinks; right side carries queue depth /
word-and-token counters / profile chip.
StatusBar::Config nests typed sub-widget Configs, so each
sub-widget gets its own agentty adapter (one widget = one adapter file):
struct StatusBar::Config {
Color phase_color; // top/bottom PhaseAccent + leading rail
TitleChip::Config breadcrumb; // empty title = hide
PhaseChip::Config phase;
TokenStreamSparkline::Config token_stream;
Element model_badge; // pre-built (ModelBadge has its own adapter)
ContextGauge::Config context; // max=0 = hide
StatusBanner::Config status_banner; // empty text = blank slot
ShortcutRow::Config shortcuts;
// … width thresholds …
};Five fixed rows in the layout:
PhaseAccent(top) — ▔▔▔▔ in phase color, dim- activity row —
TitleChip·PhaseChip·TokenStreamSparkline·ModelBadge·ContextGauge StatusBanner— toast banner or blank (always 1 row tall to prevent jitter)ShortcutRow— width-adaptive key/label listPhaseAccent(bottom) — ▁▁▁▁ in phase color, dim
Owned-by-widget behaviour:
PhaseChip— breathing animation cadence (32-frame cycle, bold half / dim half — perceptible motion below resting heart-rate).ContextGauge— green/amber/red zones (<60%safe,60–80%warn,>80%danger) plus a placeholder slot when no usage data has arrived yet (so the right-side chips don't shove leftward when the first usage event fires mid-stream).TokenStreamSparkline— fixed 37-cell ⚡ rate · ▁▂▃▄ · total. The ring buffer behind it persists across sub-turns and tool gaps so the bar shows a continuous trace of generation rate over the session; only the per-burst rate accumulator resets onStreamStarted.StatusBanner— emptytextrenders a 1-cell blank placeholder so the row count stays fixed regardless of toast presence.ShortcutRow— greedy width-fit: sheds labels in priority-ascending order first (soS-Tab profileand^/ modelslose their labels before the higher-priority bindings do), then sheds whole bindings in the same order. The last surviving binding is never dropped, so notifications surfaced through this row always stay visible.
The activity row's width-adaptive logic (drop breadcrumb < 130, drop
token stream < 110, drop ctx bar < 55) lives in StatusBar::build(),
which patches per-frame copies of the sub-configs based on terminal
width before invoking each sub-widget.
A thin coordinator: present=false collapses to just base. When
present, z-stacks overlay on top, centered horizontally, pinned to
the bottom edge, with an opaque background to mask the base.
Every adapter file under src/runtime/view/ has the same shape: one
function Model → SomeWidget::Config. Filenames mirror the widget
they adapt; the directory tree mirrors the widget hierarchy.
| Adapter file | Function | Returns |
|---|---|---|
view.cpp |
view(m) |
Element (the one .build()) |
view.cpp |
pick_overlay(m) |
optional<Element> |
thread/thread.cpp |
thread_config(m) |
Thread::Config |
thread/welcome_screen.cpp |
welcome_screen_config(m) |
WelcomeScreen::Config |
thread/conversation.cpp |
conversation_config(m) |
Conversation::Config |
thread/activity_indicator.cpp |
activity_indicator_config(m) |
optional<ActivityIndicator::Config> |
thread/turn/turn.cpp |
turn_config(msg, idx, n, m) |
Turn::Config |
thread/turn/permission.cpp |
inline_permission_config(pp,tc) |
Permission::Config |
thread/turn/agent_timeline/agent_timeline.cpp |
agent_timeline_config(msg, frame, c) |
AgentTimeline::Config |
thread/turn/agent_timeline/tool_body_preview.cpp |
tool_body_preview_config(tc) |
ToolBodyPreview::Config |
composer.cpp |
composer_config(m) |
Composer::Config |
changes_strip.cpp |
changes_strip_config(m) |
ChangesStrip::Config |
status_bar/status_bar.cpp |
status_bar_config(m) |
StatusBar::Config |
status_bar/title_chip.cpp |
title_chip_config(m) |
TitleChip::Config |
status_bar/phase_chip.cpp |
phase_chip_config(m) |
PhaseChip::Config |
status_bar/token_stream_sparkline.cpp |
token_stream_sparkline_config(m) |
TokenStreamSparkline::Config |
status_bar/context_gauge.cpp |
context_gauge_config(m) |
ContextGauge::Config |
status_bar/status_banner.cpp |
status_banner_config(m) |
StatusBanner::Config |
status_bar/shortcut_row.cpp |
shortcut_row_config(m) |
ShortcutRow::Config |
status_bar/model_badge.cpp |
model_badge_config(m) |
maya::ModelBadge |
Pure helpers (no maya types touched): under
thread/turn/agent_timeline/tool_helpers.cpp (display name, category
color/label, event status, timeline detail) and
thread/turn/agent_timeline/tool_args.cpp (arg parsers); shared
helpers (format_duration_compact, small_caps, phase_*) live in
helpers.cpp.
The single Element-returning function inside an adapter is
cached_markdown_for (private to thread/turn/turn.cpp) — required
because maya::StreamingMarkdown is stateful and its block-cache
must persist across frames.
One thread-local cache + one persistent ring buffer carry across frames.
| Cache | Key | Holds |
|---|---|---|
message_md_cache(tid, idx) |
(thread_id, msg_idx) |
shared_ptr<Element> (finalized) + shared_ptr<StreamingMarkdown> (live) |
StreamingMarkdown is the only widget held across frames — its
internal block-boundary cache makes each delta O(new_chars) rather
than re-parsing the full transcript. agentty keeps the instance alive,
calls set_content(streaming_text) each frame, slots
instance.build() into the Turn body via the Element variant.
Once finalize_turn moves streaming_text → text, the next render
takes the finalized branch: builds maya::markdown(text) once,
caches the resulting Element, returns the same pointer every
subsequent frame.
The TokenStreamSparkline reads its history from a 16-slot ring
buffer in StreamState. The ring buffer persists across sub-turns
and tool gaps — only the per-burst rate accumulator
(live_delta_bytes, first_delta_at, rate_last_sample_*) resets on
StreamStarted. So the rate number measures only the current burst
(not polluted by the previous turn's bytes), but the sparkline bars
trace generation rate continuously over the whole session.
End to end, what happens when an assistant turn with two tool calls needs rendering:
view(m) ┐
AppLayout::Config{ .thread = ..., …} │
AppLayout{cfg}.build() │ host
↓ ┘
Thread{thread_cfg}.build()
↓
for each Turn::Config in thread_cfg.turns:
Turn{tc}.build()
↓
header = h(glyph, label, meta) | grow(1.0f)
for each BodySlot:
visit(slot):
MarkdownText → maya::markdown(content)
(or cached StreamingMarkdown.build()
via Element variant)
AgentTimeline::Config →
AgentTimeline{cfg}.build()
↓
for each AgentTimelineEvent:
header row (tree glyph, status icon, name, detail, duration)
ToolBodyPreview{event.body}.build()
↓
switch(kind):
CodeBlock → head_tail(text, 4, 3) → vstack of lines
EditDiff → for each hunk: header + push_diff_side(old, '-', red) + push_diff_side(new, '+', green)
GitDiff → per-line pick_style (+/-/@@) + head+tail
TodoList → for each: glyph + content (status-styled)
Failure → CodeBlock in red
inter-event connector (colored by next status)
stats row (if events > 1)
footer (if all terminal)
| border<Round> | bcolor(rail_color) | btext("ACTIONS · 3/3 …")
Permission::Config → Permission{cfg}.build()
| rail (Bold left border in rail_color)
→ Element
collected as Conversation::Config.turns
Conversation{conv_cfg}.build()
→ list of turn Elements separated by dim ─── dividers
+ optional ActivityIndicator at bottom
→ Element
(slotted into AppLayout's vstack alongside changes_strip / composer / status_bar)
Every transition is widget.build() returning an Element. agentty
participates only at the entry: building the top-level Config tree.
maya/include/maya/widget/
├── app_layout.hpp top-level frame: Thread + ChangesStrip + Composer + StatusBar + Overlay
├── thread.hpp welcome | conversation branch
├── conversation.hpp list of typed Turn::Configs + optional in-flight
├── turn.hpp single turn: rail + header + typed body slot variant
├── checkpoint_divider.hpp "─── [↺ Restore checkpoint] ───"
├── activity_indicator.hpp "▎ ⠋ streaming…"
├── welcome_screen.hpp wordmark + chips + starters + hints
├── agent_timeline.hpp Actions panel for tool calls
├── tool_body_preview.hpp discriminated tool body (CodeBlock/EditDiff/GitDiff/TodoList/Failure)
├── permission.hpp inline permission card
├── changes_strip.hpp pending edits banner
├── composer.hpp bordered input box (state-driven color, hint row)
├── status_bar.hpp bottom panel — nests typed sub-widget Configs
├── phase_accent.hpp soft horizontal rule in phase color
├── phase_chip.hpp breathing colored glyph + verb + elapsed
├── title_chip.hpp ▎ + bold title with middle-truncation
├── token_stream_sparkline.hpp ⚡ rate · ▁▂▃▄ · total — fixed 37-cell slot
├── context_gauge.hpp CTX usage fuel-gauge with zones
├── status_banner.hpp toast/error row (always 1 row tall)
├── shortcut_row.hpp width-adaptive keyboard hints
├── model_badge.hpp ● colored model chip
└── overlay.hpp z-stack base + centered modal
src/runtime/view/
├── view.cpp # AppLayout
├── changes_strip.cpp # ChangesStrip
├── composer.cpp # Composer
├── cache.cpp · helpers.cpp # shared (not adapters)
├── login.cpp · pickers.cpp · diff_review.cpp # legacy modals (pending widgetization)
├── thread/
│ ├── thread.cpp # Thread
│ ├── welcome_screen.cpp # WelcomeScreen (empty branch)
│ ├── conversation.cpp # Conversation (non-empty branch)
│ ├── activity_indicator.cpp # ActivityIndicator (bottom of conversation)
│ └── turn/
│ ├── turn.cpp # Turn
│ ├── permission.cpp # Permission (body slot)
│ └── agent_timeline/
│ ├── agent_timeline.cpp # AgentTimeline (body slot)
│ ├── tool_body_preview.cpp # ToolBodyPreview (per-event body)
│ ├── tool_helpers.cpp # per-tool helpers
│ └── tool_args.cpp # arg parsers
└── status_bar/
├── status_bar.cpp # StatusBar
├── title_chip.cpp # TitleChip (activity row)
├── phase_chip.cpp # PhaseChip (activity row)
├── token_stream_sparkline.cpp # TokenStreamSparkline (activity row)
├── context_gauge.cpp # ContextGauge (activity row)
├── model_badge.cpp # ModelBadge (activity row)
├── status_banner.cpp # StatusBanner (status row)
└── shortcut_row.cpp # ShortcutRow (shortcut row)
Headers mirror the same layout under include/agentty/runtime/view/.
login.cpp, pickers.cpp, diff_review.cpp are modal overlays that
still construct elements directly — they predate the controller-only
refactor. Future widgetization: maya::LoginModal, maya::Picker
(or CommandPalette / ThreadList / ModelPicker / TodoModal),
maya::DiffReview.