Skip to content

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).

TypeScript
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 (inactive without 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 or rm the 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:

Text
▶ 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.

Responsive modal — picks a width (and optionally a height) based on the live terminal size.

  • On a wide terminal, the modal grows to maxWidth so 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 maxHeight is set, the same tier logic applies on the vertical axis — anything beyond is the consumer's job (typically a scrollbox child 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_DIR sets storageDir.
  • ZIDANE_PREFIX sets prefix.
  • ZIDANE_DEBUG enables gatherStats on 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 when catalog is 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#

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 in COLOR.dim (the simple "single dim label" case, e.g. "5 sessions").
  • a MetaSegment array → rendered concatenated with each segment's own color, or as badges (build split values with metaBadges).

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.