zidane/ tui
The terminal UI for zidane, built on React and OpenTUI.
import { … } from 'zidane/tui'src/tui/index.tsx
Use runTui to start the full terminal app from your own launcher. It runs on Bun only. The package also exports the screens and components for a custom shell (Terminal app). RunTuiOptions takes the ChatOptions fields of resolveConfig (prefix, storageDir, providers, agents, store, cwd, extension and plugin scan paths). It adds extensions (inline extension definitions), remoteHost (run every session on one zidane-server) and onboarding (false never opens the setup wizard at launch).
import { runTui } from 'zidane/tui'
await runTui({ prefix: '.myapp' })Functions#
AgentPickerModalFunction#
Modal that lists the registered AgentProfiles and lets the user
pick one. Each row shows the label (✓ on the current agent) and the
profile's description, and, with cacheImpact, what switching to it
would re-read in a long session.
App does not mount this modal: it switches agents with the cycleAgent
binding (shift+tab by default). The modal is exported for hosts that want
to drive agent switching from their own composition.
AppFunction#
Top-level TUI component. Accepts a fully-resolved ResolvedConfig and wires
everything (settings, modal layer, screens, footer) underneath it.
Hosts can either drive this via runTui() for the standard bootstrap or
mount <App config={resolveConfig(...)} /> themselves inside a renderer
they already own.
AuthScreenFunction#
First-run screen that lists the providers and runs the setup wizard for one.
BadgeFunction#
A colored label, on its own line or inside a row. For badges inside a
<text>, use <span> with useTheme().colors.
buildMdStyleFunction#
Convert the renderer-agnostic Theme.syntax map (hex strings + plain
booleans) into an OpenTUI SyntaxStyle. Used both for the markdown
structural captures (markup.heading, markup.bold, …) and the
embedded Tree-sitter language tokens (keyword, string, function,
…) — OpenTUI's <markdown> re-uses the same SyntaxStyle for the
fenced-code renderable, so one table drives both surfaces.
overrides, when set, replaces individual entries by token name —
each override is merged INTO the resolved entry (shallow). Used by
the "on-selection" variant to swap markup.raw.bg for the row's
selection surface so inline code chips blend into the highlight
rather than reading as "punched out" rectangles. Top-level keys
absent from the base theme become brand-new entries.
ChatScreenFunction#
The chat view: transcript, prompt composer, approval and interaction blocks, and the footer.
CompletionPopupFunction#
Popover above the textarea showing the active provider's items. Provider-
agnostic — reads label + description off each CompletionItem. The
TUI hosts can pass any CompletionState<TItem>; the popup never needs
to know what TItem is.
Geometry: 1 row of chrome + min(N, visibleRows) item rows + 1 hint row.
flexShrink: 0 pins the height so a long transcript can't squeeze it.
The popup is invisible (null-rendered) when state.active is null or
state.items is empty — the prompt block keeps its layout calm.
Solid backgroundColor: SURFACE.modal is load-bearing: in PromptBlock
the popup floats over the transcript via position: absolute, and a
transparent fill would let the transcript text bleed through. Pairs
with the modal panel surface so floating UI shares one visual identity.
Title overlays are painted on the top border (same trick as
PromptHints): provider label on the left in the chip-id's accent
color, match count on the right in dim text. Both ride absolute
positions so they take no flow space; the popup's height comes
entirely from the bordered body. The accent reuses
resolveChipColor(...).bg (foreground only, no background pill) so
the picker title still reads as "this is the X provider" without
carrying the chip pill's heavy visual weight.
EffortPickerModalFunction#
Modal for choosing the thinking effort level. adaptive is listed only when the provider supports it.
EmptyStateFunction#
What a list or screen shows when it has nothing to show.
ExtensionRenderersProviderFunction#
The base of the renderer tables: empty, so every row falls back until a
layer above adds renderers (ExtensionRenderersBridge). Mounted once near the
app root.
ExtensionsSettingsModalFunction#
List + toggle modal for discovered extensions. Mirrors the Skills / MCP pickers in shape:
- Toggleable rows: every extension that loaded its module
successfully — both the ones currently running (
loaded) AND the ones the user already toggled off (inactivewithout an error). Without surfacing the latter the user can't flip them back on. - Preamble rows: extensions whose import or
setup()threw. Read-only diagnostic so the user can fix the bug orrmthe folder without leaving the TUI.
Enabled state lives in settings.enabledExtensions (mirrors
enabledSkills / enabledMcps). Toggling OFF filters the live
registry immediately; toggling ON an extension that boot skipped
hot-reloads the registry (see "Hot-load on enable" in
src/tui/extensions/use-extension-auto-reload.ts), so its setup()
runs and its settings appear without a restart.
Agent-side contributions (tools / hooks) apply on the next run.
filterListItemsFunction#
The rows of items matching query, best first. An empty query keeps
every row in order. Matches the label and the description.
FormFunction#
A form over a settings schema.
initialFormValuesFunction#
The values a form starts with: values, else the wire's, else each
item's default. Values that do not fit their item fall back to the default.
InteractionBlockFunction#
Prompt block for a pending interaction from the agent, such as a question or a plan review. For plan requests it shows the title and the decision picker only, see PlanReviewPanel.
KeyHintFunction#
A row of key hints, like the footer of every modal: ↵ open esc close.
Clicking a hint presses its key.
ListFunction#
A selectable, optionally searchable list.
MarkdownFunction#
Draws markdown.
McpsSettingsModalFunction#
MCP server picker. Hierarchical: each server is one row, and an expanded server reveals one row per advertised tool. The full surface:
▶ linear 5 of 7 Signed in stdio npx -y mcp-remote … ━━● ▼
create_issue Create a new Linear issue ━━●
list_issues Query issues by status / assignee ━━●
update_issue Update fields on an existing issue ●──
chrome-devtools stdio npx -y chrome-devtools-mcp@latest ━━● ◀
▶ marks the cursor row; the chevron after the switch says whether a
server is open (▼) or closed (◀).
Keybindings:
↑ / ↓ / k / j navigate (over servers AND visible tool rows)
→ / tab expand the focused server
← / shift+tab collapse — on a tool row, also snaps the cursor
back to the parent server
enter / space toggle — server toggles enabledMcps; tool toggles
disabledMcpTools[serverName]
l sign in (server rows only — needs-auth / error)
o sign out (server rows only — authed / error /
authorizing)
r refresh discovery
esc close — also cancels an in-flight authorizing
row
Status badge legend (server row only):
(the shared MCP_STATUS_LABEL words, same as the desktop app)
Signed in tokens stored + bootstrap connected
Needs sign-in OAuth wanted, or the server refused the connection
Signing in… interactive sign-in in flight
Sign-in failed the last sign-in attempt failed
Couldn't connect failed for another reason
Key refused refused the key in its headers
The N of M badge on a server header counts ENABLED / TOTAL advertised
tools, drawn only when the tool catalog has loaded. Without a catalog
(server never bootstrapped this profile, cache cold) the badge is
suppressed and the empty-state row under the expanded server explains
how to populate.
Errors (DiscoveryError[]) surface in a warn-colored preamble so a
broken mcps.json is loud rather than invisible.
MdStyleProviderFunction#
Gives the markdown views below their syntax styles, built from the current theme.
ModalFunction#
Responsive modal — picks a width (and optionally a height) based on the live terminal size.
- On a wide terminal, the modal grows to
maxWidthso descriptions sit on one line and don't wrap. - On a narrow terminal, the modal shrinks down to
minWidth, keeping a small horizontal margin from the screen edges. Text inside wraps naturally. - When
maxHeightis set, the same tier logic applies on the vertical axis — anything beyond is the consumer's job (typically ascrollboxchild for long content).
Uses useTerminalDimensions() so it reflows on SIGWINCH without remount.
ModalRootFunction#
Provides the modal layer: renders the active modal over its children, closes it on esc or a click outside the panel, and exposes ModalApi through useModal.
ModelOptionsPickerModalFunction#
Modal for the active model's reasoning effort and feature toggles.
ModelPickerModalFunction#
Modal for choosing a model across the authenticated providers, with search. With onApplyChain it becomes an ordered multi-select for the primary model and its fallbacks.
onInputSubmitFunction#
@opentui/react extends React.JSX.IntrinsicElements, so onSubmit on <input>
gets intersected with the DOM SubmitEvent shape and demands an unhelpful overload.
The OpenTUI input runtime fires (value: string) => void; this helper isolates
the cast so each call site stays readable.
PlanReviewPanelFunction#
The plan BODY half of plan review — pairs with InteractionBlock
(which renders only the title + decision picker for plan requests).
The chat screen hides the Transcript (same visible: false pattern as
the file-edit approval modal) and mounts this panel in its place, so
the plan body gets the full conversation area: always expanded, one
scrollbox, no preview/collapse state. Hosts embedding these standalone
must mount BOTH — the picker alone shows a plan the user cannot read.
registerTuiHostModulesFunction#
Map react / @opentui/* bare imports to THIS module's live instances
for everything imported afterwards — extensions included. Exported (and
idempotent) because ordering is the whole game: any host that imports
extension modules BEFORE runTui() (the dev CLI builds its inline
dev-extension list up front) must call this first, or those modules
resolve their own react copy and cache it forever — hooks then throw
against the reconciler's copy ("Invalid hook call" → failed renderer).
And it must live HERE, not in the caller: the caller's own react
import may itself resolve a different copy (the dev CLI's does — the
tui workspace carries its own node_modules/react), while this module's
static imports are by definition the ones the reconciler runs.
renderHintSpansFunction#
Pure renderer for a list of Hints as colored spans — the same
keys and labels as HintRow, HINT_GAP blank columns apart,
without the click targets.
Returns spans only (no enclosing <text>, no leading / trailing
whitespace) so the caller can wrap it in whichever container fits
their surface: the bottom-bar footer uses a single-row <text>, the
prompt-box overlay wraps it with leading + trailing spaces so the
outermost cells punch through the border like a native title would.
Pure function over an opaque ThemeColors, no internal hook calls so it
composes inside any <text> regardless of where the parent grabbed its
palette.
runTuiFunction#
Boots the full chat TUI and runs it until the user quits.
Resolves the config, loads inline and disk extensions, and starts the renderer. It never returns: it ends the process with process.exit(0) once the renderer is dismissed. Throws when called a second time in one process. To keep control after the renderer closes, mount App on your own renderer instead.
It exits the process because, after the renderer's destroy() restores the
terminal, React's reconciler and OpenTUI's internal listeners can keep the
event loop open. The script then hangs in bun run, and bun --watch run
waits forever for the child to exit.
Environment overrides:
ZIDANE_STORAGE_DIRsetsstorageDir.ZIDANE_PREFIXsetsprefix.ZIDANE_DEBUGenablesgatherStatson the renderer and writes the final fps and frame-time stats to stderr on exit.
Renderer fps comes from the targetFps setting (the settings modal offers
30, 60 and 120). The stored value seeds the renderer at boot and AppShell
mirrors later changes onto the live renderer.
SessionDetailsModalFunction#
Modal with the details of one session: usage, environment and space, with actions to delete, favorite and move it.
SessionsScreenFunction#
Screen that lists sessions with search and lets the user open or create one.
SettingsModalFunction#
The settings modal. It shows the settings categories, then the optional tabs the host wires: keybindings, authentication, skills, MCP servers, extensions and logs.
SkillsSettingsModalFunction#
List + toggle modal for discovered skills. State machine + keyboard +
row geometry live in <ToggleListModal>; this file just supplies the
skill-specific column (description) and the empty-state hint.
Pass onRefresh to expose ctrl+r (force re-scan) on the list. The
standalone modal is for embedders — the in-app flow goes through
<SettingsModal> which exposes the same affordance via its own
SettingsActions.onRefreshSkills plumbing.
SpinnerFunction#
Bar spinner in the warning color with an optional dim label.
StatusSpinnerFunction#
Bar spinner painted in a caller-chosen color: the session-title status icon and the folded-exchange marker. Five cells wide.
ToggleListModalFunction#
Generic list-with-checkboxes modal. Powers both the Skills and MCP server pickers — same state machine, same keyboard map, same row geometry. Per-feature variance flows in through props:
keyOf— extract the persisted identity (skill name, server name).settingKey—'enabledSkills'|'enabledMcps'.renderDetail— appended to each row in mute color (descriptions, transports, …). Optional.emptyState— replacement content whencatalogis empty.
Renderer-agnostic state machine lives in useEnabledToggleSet
(chat layer) — a GUI shell can build its own toggle list against the
same hook without pulling OpenTUI.
TranscriptFunction#
Transcript rows are keyed by array index on purpose.
Identity keys look more correct and are worse here: the streaming tail event
is a new object on every ~16ms drain tick (coalescing copies the accumulated
text into fresh state), so keying by identity hands the hot row a new key
every tick and remounts its whole <markdown> subtree — measured as the
markdown renderable id incrementing once per tick, versus staying put with
index keys.
The cost of index keys is that revealEarlierTranscriptEvents prepends a
page, shifting every index and remounting the list once. That is a rare,
user-initiated action; the streaming tail is 60 times a second. Fixing both
needs a stable id on StreamEvent itself, which is a data-model change
rather than a rendering one.
TuiExtensionHostProviderFunction#
Provides host to an extension's components.
TurnDetailsModalFunction#
Modal with the full content of one turn, with actions to fork, delete or edit it.
useExtensionFunction#
The extension the component belongs to. Throws outside a TuiExtensionHostProvider.
useExtensionRenderersFunction#
Live renderer tables for the transcript. Empty (all-fallback) when unmounted.
useMdStyleFunction#
Active markdown / syntax-highlighting style. Returns a single shared
SyntaxStyle instance for the active theme — built once at provider
mount, re-built on theme switch. A Settings.theme flip re-paints every
<markdown> that reads this hook.
Pass { selected: true } to get the on-selection variant where inline
code chips' background matches the row's selection surface (so the
chips blend into the highlight rather than reading as punched-out
rectangles).
Throws if used outside <MdStyleProvider> so a missing wiring shows up
loudly in development rather than silently rendering plain text.
useModalFunction#
Reads the modal API of the nearest ModalRoot. Throws when used outside one.
useModalAwareFocusFunction#
Focus computed against the modal layer.
Pass a component's preferred focus state and this returns false whenever a
modal is open — so focused inputs (textarea, selects) release their focus and
stop intercepting keys behind the overlay. Pair with focusable={false} on
"passive" focusables (scrollbox) so the renderer doesn't cycle focus into
them when the primary input blurs.
useRowVisibleFunction#
true once the row has been scrolled into the viewport (± margin
rows of warm-up) — LATCHED: it never flips back to false, because
its job is gating a row's INITIAL async work, not re-triggering on
every scroll flicker (re-run-on-revisit belongs to the author's own
state keys). Reports true immediately when no visibility tracker is
mounted. Throws outside an extension-rendered row.
useRpcFunction#
A typed RPC client for contract, stable across renders.
useSessionStateFunction#
Durable per-row state for renderer components — the cache that lets an async row fetch ONCE and rehydrate on reload instead of refetching:
const [data, setData] = useSessionState<Pr | null>('pr', null) useEffect(() => { if (visible && !data) fetchPr().then(setData) }, [visible, data])
Persisted in the session's extension-state bag under the OWNING
extension's namespace, keyed renderer:<localId>:<turnId>:<id> — the
same value the extension can inspect via ctx.session.get. Values must
be JSON-safe (enforced by the type). Rows without a turn id (shouldn't
exist — live rows pre-mint theirs) degrade to volatile memory state.
Throws when called outside an extension-rendered row.
useSettingsFunction#
The extension's effective settings values by key, live.
useStoreFunction#
A store's value and setter. The component draws again when the value changes, from this window or any other client.
Constants#
FooterConstant#
Footer status bar. Renders as a single row when the terminal is wide enough, otherwise stacks the context indicator beneath the hint row. Width tiering is driven by plain-text length estimates — close enough since the segments are ASCII-heavy.
Memoized: AppShell re-renders on every stream drain tick, and the footer's props settle between ticks (its clocks run on its own 1s tickers).
TitleOverlayConstant#
Colored title for a full-screen bordered surface. The title slot
rides titleColor (defaults to a per-character gradient across the
theme's throbber pair, fallback brand→accent) on the LEFT of the top
border; the optional meta slot rides on the RIGHT.
meta accepts either:
- a plain
string→ rendered entirely inCOLOR.dim(the simple "single dim label" case, e.g."5 sessions"). - a
MetaSegmentarray → rendered concatenated with each segment's own color, or as badges (build split values withmetaBadges).
Every span carries an explicit background (bg, defaulting to
SURFACE.background). OpenTUI leaves the existing cell char in place
when it paints a space with no background, so a transparent overlay
lets the underlying border ─ bleed through every gap in the title
(reads as a strikethrough) and lets a native title= on the same row
interleave its characters with the overlay's. Opaque spans overpaint
the whole run — which also means the bg has to MATCH the surface
under the decorated box (pass SURFACE.modal inside a modal panel, as
McpAuthorizingPanel does), or the overlay stamps a mismatched run
along the border row. Both directions are pinned in
test/tui/title-overlay-collision.test.tsx.
Responsive behavior (driven by useTerminalDimensions):
- Both fit → render both with a 2-cell visual gap between them.
- Meta doesn't fit → drop meta; title takes the full budget.
- Title doesn't fit → truncate with a trailing
…. - Terminal is degenerately narrow → render nothing.
The width math assumes the immediate parent fills the screen's
content area (the standard flexGrow: 1 flex column). Hosts running
the bordered box inside a narrower container should pass parentWidth
to override the terminal-width assumption.
Interfaces#
BadgePropsInterface#
Props of Badge.
ContextUsageInterface#
Context window usage shown in the footer.
EmptyStatePropsInterface#
Props of EmptyState.
ExtensionRenderersInterface#
Renderer lookups the transcript reads.
FooterUsageInterface#
Subscription-usage summary for the footer: the most-constrained window of
the active provider (see mostConstrainedWindow in
chat/provider-usage.ts). A view type, like ContextUsage. The footer
knows a label and a percent, not which endpoint they came from.
FormPropsInterface#
Props of Form.
KeyHintPropsInterface#
Props of KeyHint.
ListItemInterface#
One row of a List.
ListPropsInterface#
Props of List.
MarkdownPropsInterface#
Props of Markdown.
MetaSegmentInterface#
One run of styled text in a TitleOverlay meta segment array.
color defaults to COLOR.dim (the standard secondary tone for
meta info); supply an explicit color to accent a value — typical use
is a warn tint on a primary stat (turn count, session count) while
surrounding separators stay mute.
ModalPropsInterface#
Props of Modal.
PickedModelInterface#
A model choice: provider and model id.
QueuedPreviewInterface#
One entry in the queued-messages preview list shown above the prompt
input. Only the displayable text + the user-provided ref spans are
surfaced — the references carry-through happens entirely in the
App layer (see messageQueueRef); this view doesn't need to know
about them.
QueueShortcutsInterface#
Resolved keybinding specs the queue UI surfaces to the user. Each
field is the literal string from keybindings.json ("ctrl+return",
"backspace" — or whatever the user has configured) formatted for
display via formatBindingForDisplay at render time so any
customization (e.g. "cmd+up", "delete") shows the actual key.
RowViewportWindowInterface#
The scrollbox viewport's absolute rect.
RunTuiOptionsInterface#
Options accepted by runTui. Extends ChatOptions with the
launcher-only fields that hosts can't carry through the synchronous
resolveConfig surface (extension loading is async because user
setup() can be async).
SessionCompactResultInterface#
What onCompact returns. Drives the modal's success banner.
SessionDetailsModalActionsInterface#
Actions a host supplies to SessionDetailsModal.
SessionExportResultInterface#
Result handed back by the host's export handler — drives the success footer.
SettingsActionsInterface#
Callbacks the settings modal calls to act on the host, such as configuring a provider, picking models, and refreshing skills and MCP servers. Each is optional.
TuiExtensionHostInterface#
The host side of the kit hooks, one per extension. The TUI host integration implements it against the extension runtime.
TuiExtensionHostProviderPropsInterface#
Props of TuiExtensionHostProvider.
TuiExtensionInfoInterface#
The extension a component belongs to.
TuiHostStoreInterface#
A store as a component sees it: its value, live, and a setter.
TuiStoreLocationInterface#
Where a store lives: the scope and, for 'session', the session.
TurnDetailsModalActionsInterface#
Actions a host supplies to TurnDetailsModal.
Types#
BadgeToneType#
How a badge reads: the theme color of its text. neutral (the default)
is a plain value; mute recedes (ids, folders, ages); the rest carry a
role (success is the accent, info the model color, money the cost
color).
FormValuesType#
Values a form submits, by setting key. Model lists are comma-joined, as Settings stores them.
Re-exports#
These names are documented on the entry point that owns them.