Skip to content

Latest commit

 

History

History
697 lines (592 loc) · 35.6 KB

File metadata and controls

697 lines (592 loc) · 35.6 KB

Conversation rendering — agentty as controller, maya as view

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.


0. The screen, annotated

┌────────────────────────── 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.

1. The architectural rule

agentty constructs no Elements. Every Element{...}, every dsl::v(...), every dsl::h(...), every dsl::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.


2. Widget hierarchy

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.


3. Data flow — view(m) to terminal cells

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();
}

4. Inside maya::AppLayout::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.


5. Inside maya::Thread::build()

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.


6. Inside maya::Turn::build() — the body-slot dispatch

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:

  1. Renders the header row (<glyph> <label> ___ <meta>).
  2. Walks each body slot, dispatches via std::visit to the right widget invocation, and inserts a blank line between consecutive non-empty slots — callers don't push spacers.
  3. If error is non-empty, appends a ⚠ <message> row.
  4. Wraps everything in the bold left-only border (the speaker rail) in rail_color.
  5. If checkpoint_above, prepends a CheckpointDivider outside 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.


7. Inside maya::AgentTimeline::build()

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:

  1. Picks the tree glyph (── / ╭─ / ├─ / ╰─ based on position).
  2. Picks the status icon (10-frame braille spinner for active states; ✓ ✗ ⊘ for terminal).
  3. Renders name + detail + (optional duration).
  4. Builds the body via ToolBodyPreview{ev.body}.build() and stripes each line with the connector, in event_connector_color(status).
  5. 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.


8. Inside maya::ToolBodyPreview::build()

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[].


9. The other top-level widgets

maya::WelcomeScreen — empty-thread splash

                    ┌─┐┌─┐┌─┐┌┐ ┌┬┐┌┬┐┬ ┬
                    ├─┤│ ┐├─ │└┐ │  │ └┬┘
                    ┴ ┴└─┘└─┘┘ ┘ ┴  ┴  ┴ 

                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.

maya::ChangesStrip — pending edits banner

╭─────────────────────────────────────────────────────╮
│ 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.

maya::Composer — bordered input box

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.

maya::StatusBar — bottom panel

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:

  1. PhaseAccent (top) — ▔▔▔▔ in phase color, dim
  2. activity row — TitleChip · PhaseChip · TokenStreamSparkline · ModelBadge · ContextGauge
  3. StatusBanner — toast banner or blank (always 1 row tall to prevent jitter)
  4. ShortcutRow — width-adaptive key/label list
  5. PhaseAccent (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 on StreamStarted.
  • StatusBanner — empty text renders 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 (so S-Tab profile and ^/ models lose 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.

maya::Overlay — modal layer

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.


10. agentty's adapter side — one widget, one adapter file

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.


11. Caching and persistent state

One thread-local cache + one persistent ring buffer carry across frames.

Markdown cache — cache.hpp / cache.cpp

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_texttext, the next render takes the finalized branch: builds maya::markdown(text) once, caches the resulting Element, returns the same pointer every subsequent frame.

Streaming-rate ring buffer — StreamState::rate_history

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.


12. Data flow for one assistant turn with tools

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.


13. Files

maya widgets — flat directory, hierarchy in headers

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

agentty adapters — directory tree mirrors the widget hierarchy

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.