markdown.engineering
Lesson 35

Dialog and UI Component System

How Claude Code renders permission requests, settings screens, and interactive dialogs entirely inside a terminal using React and Ink.

01 Overview

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.

Source files covered
dialogLaunchers.tsxinteractiveHelpers.tsxcomponents/design-system/Dialog.tsxcomponents/design-system/Pane.tsxcomponents/permissions/PermissionRequest.tsxcomponents/permissions/PermissionDialog.tsxcomponents/permissions/PermissionPrompt.tsxcomponents/wizard/components/CustomSelect/select.tsxcomponents/Onboarding.tsx

The whole UI system can be understood as four nested layers:

Layer 1

Launchers

dialogLaunchers.tsx — async functions that dynamically import components and resolve a Promise when the user is done

Layer 2

Helpers

interactiveHelpers.tsxshowDialog / showSetupDialog wrap renders in AppStateProvider + KeybindingSetup

Layer 3

Design System

Dialog, Pane, PermissionDialog — opinionated Ink wrappers for consistent chrome and keybindings

Layer 4

Feature Components

Per-tool permission requests, onboarding steps, wizard pages — each composed from Layer 3 primitives

02 Launching Dialogs — 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.

One exception: launchResumeChooser
Most launchers use 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]);
03 Interactive Helpers — interactiveHelpers.tsx

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

04 Design System Primitives

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

Design decision
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>
05 Architecture Diagram
flowchart TD A["main.tsx\nsetupScreens()"] -->|"await"| B["dialogLaunchers.tsx\nlaunchXxx(root, props)"] B -->|"dynamic import"| C["Component Module\ne.g. SnapshotUpdateDialog"] B -->|"calls"| D["interactiveHelpers.tsx\nshowSetupDialog()"] D -->|"wraps in"| E["AppStateProvider\n+ KeybindingSetup"] E -->|"root.render()"| F["Dialog / PermissionDialog / Pane\ndesign-system primitives"] F --> G["Feature Component\ne.g. InvalidSettingsDialog,\nBashPermissionRequest"] G --> H["CustomSelect\nor TextInput"] H -->|"user selects"| I["done(result)\nresolves Promise"] I -->|"returns typed value"| A style B fill:#b8965e,color:#141211 style D fill:#22201d,color:#b8b0a4 style F fill:#1a1816,color:#8e82ad style I fill:#6e9468,color:#141211
06 Permission Requests

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 Select with 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:

Auto-approve

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.

Rule generation

Allow rules

Computes "allow this prefix forever" rules from the command, offering them as options via getSimpleCommandPrefix and getCompoundCommandPrefixesStatic.

Destructive check

Warning display

Calls getDestructiveCommandWarning and renders a highlighted warning if the command is detected as destructive (rm -rf, etc.).

Sandbox

Sandbox fallback

If shouldUseSandbox returns true, the component can route execution to SandboxManager instead of requiring explicit user approval.

Performance insight
The 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.
07 CustomSelect — the Universal Input Widget

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.

08 Wizard Pattern — Multi-step Flows

The components/wizard/ directory implements a lightweight multi-step wizard that is used by installation flows (assistant setup, onboarding, etc.).

WizardProvider

State container

Holds currentStepIndex, wizardData, navigation history, and completion state. Exposes these via WizardContext.

useWizard

Consumer hook

Any component inside the wizard tree calls useWizard() to get goNext, goBack, setData, and current step metadata.

WizardDialogLayout

Per-step chrome

Wraps each step in a <Dialog> with an auto-computed title like "Setup (2/4)". Passes goBack as onCancel.

WizardNavigationFooter

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.

09 Onboarding — Wiring It All Together

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.

Trust dialog
The 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.
10 Full Dialog Lifecycle Diagram
sequenceDiagram participant M as main.tsx participant DL as dialogLaunchers.tsx participant IH as interactiveHelpers.tsx participant R as Ink Root participant C as Dialog Component participant U as User M->>DL: await launchXxxDialog(root, props) DL->>DL: dynamic import(component) DL->>IH: showSetupDialog(root, renderer) IH->>R: root.render(<AppStateProvider><KeybindingSetup>...</></>) R->>C: mount Dialog/PermissionDialog with keybindings C->>U: display in terminal U-->>C: keypress (Enter / Esc / arrow) C->>C: useKeybinding fires C->>IH: done(selectedValue) IH->>DL: Promise resolves with typed value DL->>M: returns typed result M->>M: branch on result, continue boot

Key Takeaways

  • dialogLaunchers.tsx converts every dialog into an async function returning a typed Promise — callers never touch Ink internals directly.
  • showSetupDialog is the single place where AppStateProvider and KeybindingSetup are added; all dialogs get them for free.
  • Dialog, Pane, and PermissionDialog are three distinct primitives with clearly documented use cases — they are not interchangeable.
  • The isCancelActive prop on Dialog solves 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 switch in PermissionRequest.tsx; feature-flagged tools use conditional require().
  • PermissionPrompt is the shared UX engine for all tool approvals — it handles feedback input, analytics, and keybindings so individual request components stay lean.
  • The 'input'-type CustomSelect option 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.
  • ClassifierCheckingSubtitle was extracted purely to prevent a 20fps shimmer clock from re-rendering the entire bash permission dialog tree — a documented performance win.

Knowledge Check

Q1. What does showDialog in interactiveHelpers.tsx return?
Q2. Why does WizardDialogLayout set isCancelActive={false} on the inner Dialog?
Q3. Where in the codebase is the mapping from a tool instance (e.g. BashTool) to its permission request UI component defined?
Q4. What is the purpose of extracting ClassifierCheckingSubtitle into its own component in BashPermissionRequest?
Q5. What distinguishes an 'input'-type option in CustomSelect from a regular 'text' option?
0/5