TUI architecture

How phi's interactive UI is structured — package layout, aggregation rules, and interaction flows.

phi’s interactive UI follows a panda-style split: a thin Editor root widget, domain handlers that own their state, and dumb widgets under internal/components. Agent lifecycle lives in internal/tui/controller; session→widget projection lives in internal/tui/transcript.

Object aggregation

cmd/main.go
  └─ editor.NewEditor(app, bus, ctrl, …)
       ├─ TranscriptPane   snap, list, mapper, subagents, welcome, text selection
       ├─ ComposerPane     chat, @/slash pickers, palette (input only)
       ├─ FooterChrome     activity, spinner, tokens, update hint, extension status
       ├─ Overlays         permission ask, continue ask
       └─ Submitter        submit / cancel / slash / bash → Controller

Aggregation rules

Owner Composes (lifecycle) Aggregates (injected)
Editor all panes, Submitter, toast, CommandRegistry Bus, App, vx
TranscriptPane MessageList, Mapper, SubagentStore, welcome, textSel theme, spinner ref from footer
ComposerPane ChatInput, pickers, palette callbacks onSubmit, onCancel, onRedraw
FooterChrome ActivityHandler, Spinner labelContext(), liveJobs() closures
Overlays permAskState, continueAskState activity ref, reply callbacks
Submitter BashRunner Controller, Bus, CommandRegistry, pane refs

Hard rule: no *Editor back-pointers on handlers. Cross-domain work uses injected refs, callbacks, or Bus.Publish. Toast feedback uses ToastMsg (Editor owns the overlay); do not inject toast callbacks.

Package layout

internal/tui/
├── editor/                 # Editor root: layout, dispatch, branch watch, command bridge
├── controller/             # Engine lifecycle, Bus/Msg, activity, permission replies
├── transcript/             # Mapper, SubagentStore, TranscriptPane
├── composer/               # ComposerPane, Wire(), Input iface
├── footer/                 # FooterChrome, token label helpers
├── overlays/               # permission + continue ask
├── submit/                 # Submitter, BashRunner
├── commands/               # registry, builtins, SessionCommands, ExtCommands
└── pathutil/               # short path + git branch labels
Package Role
editor TUI root components.Widget; wires panes; Draw drains the bus
controller Controller runs agent.Engine; publishes Msg to the bus only
transcript Projects session.Event → message list; sub-agent rows; copy selection
composer Keyboard routing for chat, / slash, @ mention, Ctrl+K palette
footer Spinner, activity line, token/context labels, update hint, extension status
overlays Modal permission / continue-ask panels; replaces composer when active
submit User submit path: agent prompt, slash commands, !bash, cancel
commands Slash/palette registry; session load/clear; extension command bridge
pathutil Cwd shortening and git branch labels for composer chrome

Dumb rendering widgets stay in internal/components/ (chat, input, palette, mention, transcript blocks, …).

Assembly (cmd/main.go)

cmd owns project/config loading and constructs collaborators before the TUI root:

proj.LoadConfig()
vx, theme, cwd
redraw := controller.NewRedrawRelay()
bus    := controller.NewBus(redraw.Fire)
ctrl   := controller.NewController(bus, proj, cwd)
ui     := editor.NewEditor(app, bus, ctrl, vx, theme, cwd, model, skillPath, contextWindow, modelNames)
redraw.Bind(ui.RequestRedraw)
ui.StartUpdateCheck(...)
ui.StartBranchWatch()
app.Run(ui)

Inside NewEditor, the CommandRegistry (builtins) is built first, then panes in dependency order:

  1. FooterChrome — spinner + activity (needs contextWindow)
  2. TranscriptPane — shares footer spinner; usage callback → footer tokens
  3. ComposerPane — chat chrome; footer binds composer for labels
  4. Overlays — permission/continue UI; uses footer activity + composer focus
  5. SessionCommands, ExtCommands, Submitter (owns BashRunner) — explicit deps, no *Editor fields
  6. ComposerPane.Wire(...) — connects composer keyboard path to submitter, overlays, bus

Editor does not call project.GetDefaultProject or construct Controller.

UI goroutine loop

xui event
  └─ Editor.Handle → ComposerPane.Handle (keys, paste, focus)
       ├─ overlay keys → Overlays (when active)
       ├─ copy keys    → TranscriptPane
       └─ submit       → bus.Publish(SubmitMsg)

app frame
  └─ Editor.Draw
       ├─ drainBus()          # apply pending Msg batch on UI thread
       ├─ layout: list | chat/overlay | footer
       └─ toast overlay (if visible)

RequestRedrawvx.QueueRefresh(). The bus coalesces high-frequency stream events; one armed wake can cover many publishes until the next Drain.

Bus: publish and drain

Publish (any goroutine): widgets, Controller, background tasks (StartBranchWatch, StartUpdateCheck), extension commands.

Drain (UI goroutine only, at start of Draw):

Phase Messages Handler
Batch pass SessionEventMsg, JobProgressMsg TranscriptPane → optional Sync + footer token refresh
Per-msg everything else Editor.Update → domain handler

Message routing

controller.Msg Handler
SessionEventMsg, JobProgressMsg TranscriptPane (in drainBus)
SubmitMsg, CancelStreamMsg Submitter
PermissionAskMsg, PermissionDismissMsg, ContinueAskMsg, ContinueDismissMsg Overlays
SetActivityMsg, ClearIfActivityMsg, UpdateAvailableMsg, ExtSessionEffectsMsg FooterChrome
MentionResultsMsg, BranchLabelMsg ComposerPane
ToastMsg Editor toast overlay
ExtCommandResultMsg ExtCommands
RedrawMsg no-op (redraw already scheduled)

Interaction flows

1. Agent submit

User Enter in composer
  → ComposerPane publishes SubmitMsg{text}
  → drainBus → Submitter.Submit
       ├─ "!cmd" prefix  → BashRunner (local shell, SessionEventMsg for output)
       ├─ "/slash"       → CommandRegistry / SessionCommands / ExtCommands
       └─ plain text     → Controller.Submit → agent.Engine.Loop (background)
                              └─ SessionEventMsg, SetActivityMsg, PermissionAskMsg, …

Submitter clears composer input after slash/bash; agent submit passes pending skills from composer.

2. Stream and transcript

Controller.runLoop
  → engine.Loop events
  → bus.Publish(SessionEventMsg{Event})
  → drainBus: TranscriptPane.ApplySession
  → TranscriptPane.Sync (mapper + subagent store)
  → FooterChrome.SyncFromSnap (tokens / context window)
  → stick-to-bottom if user was pinned

JobProgressMsg updates nested sub-agent tool rows without full thread resync when the tree is unchanged.

3. Cancel

Esc / composer cancel
  → CancelStreamMsg
  → Submitter.Cancel → Controller cancels stream context
  → ClearIfActivityMsg when activity was cancelled

4. Permission / continue ask

Engine needs approval
  → Controller publishes PermissionAskMsg (or ContinueAskMsg)
  → Overlays.Apply → replaces composer bottom panel
  → user keys → Overlays → Controller reply channel
  → PermissionDismissMsg / ContinueDismissMsg

Composer input is blocked while an overlay is active (OverlayBlocksComposer).

5. Slash / palette / extensions

/something or Ctrl+K
  → ComposerPane local UI OR SubmitMsg with slash text
  → Submitter.dispatchSlash → CommandRegistry
  → SessionCommands (/clear, /resume, …) or builtins
  → ExtCommands (async) → ExtCommandResultMsg → palette push / toast

commandBridge in editor builds commands.CommandContext for builtins (model switch, theme, permissions, copy last message, …).

6. Background chrome

Source Msg Target
StartBranchWatch BranchLabelMsg composer bottom-right label
StartUpdateCheck UpdateAvailableMsg footer update hint
Extension session lifecycle ExtSessionEffectsMsg footer status + toast

Layering vs internal/components

Layer Responsibility
internal/components/* Draw/handle only; no bus, no engine
internal/tui/* State, routing, session projection, submit
internal/tui/controller Agent engine, jobs, permission gate, extensions/MCP
cmd Config, xui, bus/controller construction, NewEditor

Reference implementation patterns: panda interactive.go (assembly), message.go (transcript), submit.go (submit/cancel/bash).