Claude Code has no browser window. Every interactive UI element — from a bash approval prompt to a multi-step installation wizard — is rendered in the terminal through React + Ink. This lesson traces how dialogs are launched, how the design-system primitives are composed, how permission requests are wired to specific tools, and how the wizard pattern allows multi-step flows.
dialogLaunchers.tsx →
interactiveHelpers.tsx →
components/design-system/Dialog.tsx →
components/design-system/Pane.tsx →
components/permissions/PermissionRequest.tsx →
components/permissions/PermissionDialog.tsx →
components/permissions/PermissionPrompt.tsx →
components/wizard/ →
components/CustomSelect/select.tsx →
components/Onboarding.tsx
The whole UI system can be understood as four nested layers:
Launchers
dialogLaunchers.tsx — async functions that dynamically import components and resolve a Promise when the user is done
Helpers
interactiveHelpers.tsx — showDialog / showSetupDialog wrap renders in AppStateProvider + KeybindingSetup
Design System
Dialog, Pane, PermissionDialog — opinionated Ink wrappers for consistent chrome and keybindings
Feature Components
Per-tool permission requests, onboarding steps, wizard pages — each composed from Layer 3 primitives
dialogLaunchers.tsx
Before this file existed, every dialog was inlined directly inside main.tsx. The extraction
was done to keep main.tsx leaner and to enable code splitting: each launcher
dynamically imports its component only when needed, so the JS for, say,
TeleportResumeWrapper is never parsed on a normal startup.
The pattern is identical for every launcher: call showSetupDialog (from
interactiveHelpers.tsx), pass a render factory that wires the done
callback to the component's onComplete / onCancel props, and return
a typed Promise.
// dialogLaunchers.tsx — SnapshotUpdateDialog launcher
export async function launchSnapshotUpdateDialog(
root: Root,
props: { agentType: string; scope: AgentMemoryScope; snapshotTimestamp: string }
): Promise<'merge' | 'keep' | 'replace'> {
const { SnapshotUpdateDialog } = await import('./components/agents/SnapshotUpdateDialog.js');
return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done =>
<SnapshotUpdateDialog
agentType={props.agentType}
scope={props.scope}
snapshotTimestamp={props.snapshotTimestamp}
onComplete={done}
onCancel={() => done('keep')}
/>
);
}
The caller gets back a plain Promise. It awaits it and branches on the typed result. No event listeners, no global state — the dialog lifetime is fully expressed as an async function call.
showSetupDialog. The resume conversation picker uses
renderAndRun instead because it needs to mount the full <App>
tree (including FPS tracking, stats, and KeybindingSetup) rather than a bare dialog.
The comment in the source explicitly notes this is to "preserve original Promise.all parallelism
between getWorktreePaths and imports."
One launcher stands apart from the rest: launchAssistantInstallWizard wraps two
Promises in a Promise.race. If installation throws an error the race rejects,
allowing the caller to distinguish user cancelled (resolves to null) from
install failed (rejects with an Error).
// dialogLaunchers.tsx — error vs cancel distinction
let rejectWithError: (reason: Error) => void;
const errorPromise = new Promise<never>((_, reject) => {
rejectWithError = reject;
});
const resultPromise = showSetupDialog<string | null>(root, done =>
<NewInstallWizard
defaultDir={defaultDir}
onInstalled={dir => done(dir)}
onCancel={() => done(null)}
onError={message => rejectWithError(new Error(`Installation failed: ${message}`))}
/>
);
return Promise.race([resultPromise, errorPromise]);
interactiveHelpers.tsxThis file provides four foundational utilities that all dialog machinery depends on.
showDialog — the primitive
The root building block. It accepts a render factory, creates a Promise, passes the resolver as
done, and renders whatever the factory returns into the Ink root.
// interactiveHelpers.tsx
export function showDialog<T = void>(
root: Root,
renderer: (done: (result: T) => void) => React.ReactNode
): Promise<T> {
return new Promise<T>(resolve => {
const done = (result: T): void => void resolve(result);
root.render(renderer(done));
});
}
When the user selects an option the component calls done(result), which resolves the
Promise. There is no unmount call here — Ink replaces the current render tree the
next time root.render() is called by the next phase of the flow.
showSetupDialog — the standard wrapper
Wraps showDialog with two providers every dialog needs:
AppStateProvider (global UI state) and KeybindingSetup (keyboard
shortcut registry). This is what 90% of launchers call.
export function showSetupDialog<T = void>(
root: Root,
renderer: (done: (result: T) => void) => React.ReactNode
): Promise<T> {
return showDialog<T>(root, done =>
<AppStateProvider>
<KeybindingSetup>{renderer(done)}</KeybindingSetup>
</AppStateProvider>
);
}
exitWithError / exitWithMessage
These handle fatal errors after Ink has taken over the terminal. Since Ink patches
console.error, plain console.error would be swallowed. The helpers
render an Ink <Text> node, unmount the root, run an optional cleanup hook,
then call process.exit. The return type is Promise<never>
— TypeScript knows the function does not return.
The components/design-system/ directory contains opinionated Ink wrappers used
everywhere. Three are critical to dialog UX.
Dialog
The standard chrome for any confirm/cancel interaction. On construction it registers two
keybindings: confirm:no (mapped to Esc and n) calls onCancel,
and app:exit / app:interrupt (Ctrl-C/D) exits the process. The
isCancelActive prop disables these bindings while an embedded text input is focused
so that Ctrl-C reaches the input's own handler instead.
// Dialog.tsx — keybinding wiring (simplified source)
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
useKeybinding("confirm:no", onCancel, { context: "Confirmation", isActive: isCancelActive });
// The input guide renders either "Press Ctrl-C again to exit"
// OR "Enter confirm / Esc cancel" depending on exitState.pending
const defaultInputGuide = exitState.pending
? <Text>Press {exitState.keyName} again to exit</Text>
: <Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" fallback="Esc" description="cancel" />
</Byline>;
The color prop accepts any key from the Theme type and is forwarded to
the Ink <Text> that renders the bold title, providing consistent semantic
coloring ("warning", "permission", "suggestion", etc.).
Dialog registers its own keybindings. Pane does not. The comment in
Pane.tsx explains the split: "For confirm/cancel dialogs (Esc to dismiss, Enter to
confirm), use <Dialog>. Submenus rendered inside a Pane should use
hideBorder on their Dialog so the Pane's border remains the single frame."
Pane
A borderless region used by slash-command screens (/config, /help,
/sandbox, etc.). It renders a colored divider line as its top border and horizontal
padding. When rendered inside a modal (detected via useIsInsideModal())
it skips the divider entirely so it does not break the enclosing modal's visual frame.
PermissionDialog
A specialized frame used only by tool permission requests. Unlike Dialog, it does
not register any keybindings — those are handled by PermissionPrompt nested inside
it. Its visual signature is a round top border (left/right/bottom borders suppressed) in the
"permission" theme color, with a PermissionRequestTitle header that
can show a WorkerBadge (indicating which sub-agent triggered the request).
// PermissionDialog.tsx — terminal chrome (simplified source)
<Box
flexDirection="column"
borderStyle="round"
borderColor={color}
borderLeft={false}
borderRight={false}
borderBottom={false}
marginTop={1}
>
<Box paddingX={1} flexDirection="column">
<Box justifyContent="space-between">
<PermissionRequestTitle title={title} subtitle={subtitle} workerBadge={workerBadge} />
{titleRight}
</Box>
</Box>
<Box flexDirection="column" paddingX={innerPaddingX}>
{children}
</Box>
</Box>
Every time Claude wants to run a tool that requires user approval, a permission request UI is
shown. The routing from tool type to UI component is a single switch statement in
PermissionRequest.tsx:
// PermissionRequest.tsx — tool-to-component routing
function permissionComponentForTool(tool: Tool) {
switch (tool) {
case FileEditTool: return FileEditPermissionRequest;
case FileWriteTool: return FileWritePermissionRequest;
case BashTool: return BashPermissionRequest;
case PowerShellTool: return PowerShellPermissionRequest;
case WebFetchTool: return WebFetchPermissionRequest;
case SkillTool: return SkillPermissionRequest;
// …more tools…
default: return FallbackPermissionRequest;
}
}
Feature-flagged tools (ReviewArtifactTool, WorkflowTool,
MonitorTool) use conditional require() calls so their modules are only
loaded when the relevant feature gate is open.
PermissionPrompt — the shared UX engine
Most permission request components delegate their interactive portion to PermissionPrompt.
It handles:
- Rendering a
Selectwith typed option values - Optional inline feedback text input (Tab to expand, for accept/reject rationale)
- Focus tracking so keybindings behave correctly while a feedback field is active
- Analytics events for every feedback interaction
- Default question text: "Do you want to proceed?"
// PermissionPrompt.tsx — option shape
export type PermissionPromptOption<T extends string> = {
value: T;
label: ReactNode;
feedbackConfig?: {
type: 'accept' | 'reject';
placeholder?: string;
};
keybinding?: KeybindingAction; // optional hotkey shortcut
};
BashPermissionRequest — the most complex case
Bash commands receive the richest UX. BashPermissionRequest adds:
Classifier integration
Calls the bash classifier to check if the command is safe. While checking, shows an animated shimmer subtitle — isolated in ClassifierCheckingSubtitle to avoid re-rendering the whole dialog at 20 fps.
Allow rules
Computes "allow this prefix forever" rules from the command, offering them as options via getSimpleCommandPrefix and getCompoundCommandPrefixesStatic.
Warning display
Calls getDestructiveCommandWarning and renders a highlighted warning if the command is detected as destructive (rm -rf, etc.).
Sandbox fallback
If shouldUseSandbox returns true, the component can route execution to SandboxManager instead of requiring explicit user approval.
ClassifierCheckingSubtitle extraction is explicitly commented in the source:
"Before this extraction, useShimmerAnimation lived inside the 535-line Inner body, so every 50ms
clock tick re-rendered the entire dialog… Inner also has a Compiler bailout, so nothing was
auto-memoized — the full JSX tree was reconstructed 20–60 times per classifier check."
This is a rare, documented case of manually splitting a component purely for render performance.
Almost every dialog uses components/CustomSelect/select.tsx rather than a raw
text input. The OptionWithDescription union type is what makes it powerful:
// select.tsx — option type
type BaseOption<T> = {
label: ReactNode;
value: T;
description?: string;
disabled?: boolean;
};
// Two variants of OptionWithDescription:
type TextOption<T> = BaseOption<T> & { type?: 'text' };
type InputOption<T> = BaseOption<T> & {
type: 'input';
onChange: (value: string) => void;
placeholder?: string;
allowEmptySubmitToCancel?: boolean;
showLabelWithValue?: boolean;
resetCursorOnUpdate?: boolean;
};
An 'input'-type option embeds a live text field inside the select list. This is
how "Yes, and allow this command forever: [type prefix here]" is implemented in
the bash approval dialog — the option itself contains an editable field.
The components/wizard/ directory implements a lightweight multi-step wizard that
is used by installation flows (assistant setup, onboarding, etc.).
State container
Holds currentStepIndex, wizardData, navigation history, and completion state. Exposes these via WizardContext.
Consumer hook
Any component inside the wizard tree calls useWizard() to get goNext, goBack, setData, and current step metadata.
Per-step chrome
Wraps each step in a <Dialog> with an auto-computed title like "Setup (2/4)". Passes goBack as onCancel.
Footer hints
Renders contextual instructions below each step (e.g. "Tab to skip / Enter to continue").
// WizardDialogLayout.tsx — how title + step counter are composed
const { currentStepIndex, totalSteps, title: providerTitle, goBack } = useWizard();
const title = titleOverride || providerTitle || "Wizard";
const stepSuffix = showStepCounter !== false
? ` (${currentStepIndex + 1}/${totalSteps})`
: "";
return <>
<Dialog
title={`${title}${stepSuffix}`}
subtitle={subtitle}
onCancel={goBack} // back = cancel for each step
isCancelActive={false} // wizard manages its own exit
hideInputGuide={true}
>{children}</Dialog>
<WizardNavigationFooter instructions={footerText} />
</>
Note isCancelActive={false} — the wizard disables Dialog's built-in Esc handler
because WizardProvider registers its own exit handler via
useExitOnCtrlCDWithKeybindings(), ensuring Ctrl-C exits the whole wizard rather
than just the current step.
Onboarding.tsx is the largest dialog-like component and a good example of all
the patterns above working together. It manages its own step array with typed
StepId values, logs analytics on each transition, and conditionally includes
steps based on runtime environment.
type StepId = 'preflight' | 'theme' | 'oauth' | 'api-key' | 'security' | 'terminal-setup';
interface OnboardingStep {
id: StepId;
component: React.ReactNode;
}
Steps are plain objects with an id and a pre-rendered component.
The current step's component is rendered conditionally. On each goToNextStep()
call the step's id is sent to analytics as
'tengu_onboarding_step'. When all steps are exhausted, onDone()
is called, which resolves the outer dialog Promise and returns control to main.tsx.
TrustDialog is a permission request variant that runs before the REPL session
starts. It uses PermissionDialog (not Dialog) and a Select
to let users choose whether to trust the current project directory. It reads hook sources,
MCP server configs, bash permission sources, and dangerous env vars to generate a
context-aware list of security concerns the user is approving.
Key Takeaways
dialogLaunchers.tsxconverts every dialog into an async function returning a typed Promise — callers never touch Ink internals directly.showSetupDialogis the single place whereAppStateProviderandKeybindingSetupare added; all dialogs get them for free.Dialog,Pane, andPermissionDialogare three distinct primitives with clearly documented use cases — they are not interchangeable.- The
isCancelActiveprop onDialogsolves a real conflict: embedded text inputs need Ctrl-C for their own cancel, not for process exit. - Permission requests route tool type to UI component via a single
switchinPermissionRequest.tsx; feature-flagged tools use conditionalrequire(). PermissionPromptis the shared UX engine for all tool approvals — it handles feedback input, analytics, and keybindings so individual request components stay lean.- The
'input'-typeCustomSelectoption embeds a live text field inside the selection list — this is the mechanism behind "allow prefix" options in bash approval. - The wizard pattern uses
WizardProvider+useWizard+WizardDialogLayout; it disables Dialog's Esc handler and manages its own exit lifecycle. ClassifierCheckingSubtitlewas extracted purely to prevent a 20fps shimmer clock from re-rendering the entire bash permission dialog tree — a documented performance win.
Knowledge Check
showDialog in interactiveHelpers.tsx return?WizardDialogLayout set isCancelActive={false} on the inner Dialog?BashTool) to its permission request UI component defined?ClassifierCheckingSubtitle into its own component in BashPermissionRequest?'input'-type option in CustomSelect from a regular 'text' option?