This guide is the detailed contributor reference for the current PilotSwarm terminal UI.
Use it when you are:
- changing layout, rendering, or key handling
- adding a new pane, modal, or command
- debugging session refresh, scroll, or activity behavior
- deciding which layer should own a TUI behavior
This guide covers the current implementation built on Ink in packages/app/tui/, but "Ink" is now an implementation detail of the one shipped terminal UI.
The TUI has three layers with a strict ownership split:
terminal input / process lifecycle / clipboard / OS services
│
▼
packages/app/tui (host)
│
▼
packages/app/ui/react (shared tree)
│
▼
packages/app/ui/core (behavior)
│
▼
PilotSwarm transport + management/runtime APIs
The most important rule is:
ui-coredecides what the UI meansui-reactdecides how the shared app is composedpackages/app/tuidecides how that shared app is rendered and controlled in a terminal
If a fix can live below packages/app/tui, it usually should.
packages/app/tui/bin/tui.js— CLI entrypointpackages/app/tui/src/bootstrap-env.js— env, plugin, branding, mode resolutionpackages/app/tui/src/index.js— host bootstrap, render lifecycle, shutdownpackages/app/tui/src/app.js— terminal input wiring, keybindings, modal dispatch, quit flowpackages/app/tui/src/platform.js— terminal rendering primitives, pane frames, scrollbars, prompt drawing, selectionpackages/app/tui/src/node-sdk-transport.js— SDK/management/log/artifact bridgepackages/app/tui/src/embedded-workers.js— local-mode worker bootstrap
packages/app/ui/core/src/state.js— initial state shapepackages/app/ui/core/src/reducer.js— pure state transitionspackages/app/ui/core/src/controller.js— async flows and command handlingpackages/app/ui/core/src/commands.js— command ids, tabs, focus regionspackages/app/ui/core/src/selectors.js— visible view models and pane linespackages/app/ui/core/src/history.js— CMS/live event normalization into chat/activitypackages/app/ui/core/src/formatting.js— markdown and terminal-markup parsingpackages/app/ui/core/src/layout.js— pane sizing, focus traversal, prompt row math
packages/app/ui/react/src/components.js— app shell, panes, modalspackages/app/ui/react/src/platform.js— host platform contextpackages/app/ui/react/src/use-controller-state.js— store subscription hook
run.sh / npm run tui / npx pilotswarm
│
▼
packages/app/tui/bin/tui.js
│
▼
bootstrap-env.js
│
├─ resolve env file
├─ resolve plugin dirs and worker module
├─ resolve local vs remote mode
└─ resolve branding/splash
│
▼
index.js
│
├─ setup host runtime logging
├─ create terminal platform
├─ create NodeSdkTransport
├─ create ui-core store + controller
└─ render SharedPilotSwarmApp through Ink
Important implication:
- startup logic belongs in
packages/app/tui/src/index.jsandbootstrap-env.js - shared app state should not be created in React components
The terminal host owns raw input.
keyboard / mouse
│
▼
packages/app/tui/src/app.js
│
├─ terminal-only shortcuts
├─ modal editing
├─ prompt editing
├─ focus/tab routing
└─ controller.handleCommand(...)
│
▼
packages/app/ui/core/src/controller.js
│
▼
reducer
│
▼
selectors
│
▼
rendered panes
- translating raw
inkkey events into commands - terminal-only affordances like mouse drag selection
- quit/ctrl-c handling
- prompt editing keystrokes
- modal text input wiring
- deciding how a session row should look
- constructing activity/chat lines
- session refresh semantics
- artifact/file list derivation
If a behavior is currently implemented in app.js and also matters for portal or tests, it probably wants to move downward.
The shared layers produce line models, not direct terminal widget mutations.
state
│
▼
selectors.js
│ returns:
│ - pane titles
│ - body lines
│ - sticky lines
│ - modal models
▼
components.js
│
▼
platform.Panel / platform.Lines / platform.TextInput
│
▼
packages/app/tui/src/platform.js
│
├─ wrap
├─ trim
├─ scroll
├─ selection highlighting
├─ scrollbars
└─ Ink <Text>/<Box> output
This split is why most visible regressions are easiest to debug by asking:
- Did
selectors.jsgenerate the wrong model? - Or did
platform.jsrender a correct model incorrectly?
The state tree is intentionally UI-oriented.
High-value areas:
state.connection— mode, status, connection errorsstate.sessions— catalog, active session, flat tree, collapse statestate.history— per-session normalized event/chat/activity datastate.ui— focus region, inspector tab, modal state, scroll offsets, prompt draftstate.files— artifacts, previews, browser scope, selection
Rules:
- Reducers should stay pure.
- Async calls live in the controller or transport.
- Selectors should consume normalized state, not reach into transports.
The command registry in packages/app/ui/core/src/commands.js is the shared vocabulary for the app.
When you add a new command:
- add or update the command id in
commands.js - handle it in
controller.js - wire the keybinding in
packages/app/tui/src/app.js - update visible help in
selectors.js - update Keybindings
- update .github/copilot-instructions.md and the TUI skill if the maintenance rule changed
The TUI treats focus as explicit state, not as terminal-widget magic. Focus movement should go through shared focus regions and layout helpers, not hard-coded panel assumptions.
history.js is where raw events become human-facing chat and activity.
That means:
- deduping live and persisted messages belongs there
- system-card extraction belongs there or in formatting/selectors
- activity summarization belongs there
- ephemeral streaming/noise filtering belongs there
Do not put event-shape interpretation in components.js.
A good test for ownership:
- if portal would want the same timeline semantics, put it in
history.js - if only the terminal cares about the visual treatment, put it in
platform.js
The artifact pipeline has three layers:
NodeSdkTransport
│ list/download/open/upload
▼
controller.js
│ refresh selection, preview load, modal flow
▼
selectors.js
│ file browser list + preview model
▼
components.js / platform.js
Rules:
- artifact I/O belongs in the transport
- selected-file and preview behavior belongs in the controller
- rendered file list/preview lines belong in selectors
- host-specific "open in OS default app" belongs in
packages/app/tui
Scroll offsets are shared state. Selection highlighting is host rendering.
That split matters:
- whether a pane should be bottom-anchored is a shared behavior question
- how a scrollbar is drawn is a host question
- mouse drag region extraction is a host question
- which pane a copy operation applies to is part host, part shared frame registration
If scroll behavior is wrong, inspect:
controller.jsfor offset updatesselectors.jsfor sticky/body line countsplatform.jsfor clipping/wrapping/scrollbar math
The TUI uses a shared modal model rather than host-owned ad hoc dialogs.
Pattern:
- controller opens modal by dispatching shared modal state
- selectors derive display text/options/footer help
components.jschooses the matching shared modal shellapp.jsroutes keystrokes while the modal is open
If you add a modal, keep all four pieces in sync.
The host supports two runtime shapes:
- local: embedded workers started by
embedded-workers.js - remote: management/client-only TUI with log streaming and no local workers
The view layer should not fork heavily for these modes. Prefer:
- transport capability checks
- state/selector differences
- generic status messages
Avoid host-only branches in many files unless the behavior is truly different.
Touch:
commands.jsstate.jscontroller.jsselectors.jscomponents.jsapp.jsif keybindings/help change
Usually touch:
selectors.js- maybe
context-usage.jsor another shared helper - docs if the badge is user-visible and meaningful
Usually touch:
app.jsfor keyscontroller.js/reducer.jsfor prompt state changeslayout.jsfor prompt row mathplatform.jsfor cursor and multi-line renderingselectors.jsfor hint text
Usually touch:
history.jsformatting.jsselectors.js- tests first, then platform only if rendering is the actual problem
- Do not rebuild shared view state inside React components.
- Do not add terminal escape parsing to selectors.
- Do not let the transport mutate view state directly.
- Do not hard-code keybinding copy in multiple places without updating all help surfaces.
- Do not treat the host as the source of truth for session semantics.
When a TUI bug shows up, narrow it quickly:
- inspect
history.js - inspect
selectors.js - inspect controller state snapshots
- inspect
packages/app/tui/src/platform.js
- inspect
packages/app/tui/src/app.js - inspect command handling in
controller.js
- inspect
state.js,controller.js,selectors.js, andcomponents.jstogether
- inspect
bootstrap-env.js - inspect
index.js - inspect
node-sdk-transport.js
For most TUI changes, do at least these:
node --input-type=module -e "await import('./packages/app/tui/src/index.js'); await import('./packages/app/tui/src/platform.js'); await import('./packages/app/ui/react/src/components.js'); await import('./packages/app/ui/core/src/selectors.js')"
node packages/app/tui/bin/tui.js --helpAnd then at least one of:
- targeted selector/controller/history Vitest coverage
./run.sh local --dbfor real interaction changes./run.sh remotefor remote/log/management changes