Chapter 12: Component Library & Design System
What You'll Learn
By the end of this chapter, you will be able to:
- Navigate the
src/components/directory and know which subdirectory owns each category of UI concern - Identify the four core design-system primitives — Dialog, Tabs, FuzzyPicker, ThemedBox — and describe what each one is responsible for
- Trace how an
AssistantMessage, aToolUseMessage, and aToolResultMessageeach get rendered, and understand why the rendering logic for each is different - Read the permission dialog structure and explain how the per-tool specialization works (different UI for BashTool vs FileEditTool)
- Understand how Claude Code's theme system degrades gracefully across terminals of varying color capability
- Walk through FuzzyPicker's internal architecture in enough depth to modify or extend it
12.1 Directory Organization
src/components/ is the home for all UI components in the codebase, containing approximately 389 files as of the current snapshot. The directory is not a flat bag of components — it is organized by responsibility, and that organization is worth understanding before reading individual files.
The subdirectory structure:
messages/ contains a dedicated rendering component for every message type that can appear in the conversation history. Each file in this directory corresponds to one Message variant from the core type system (Chapter 3). When the REPL (Chapter 11) needs to render a message, it dispatches to the appropriate component in this directory based on the message's type field.
permissions/ contains the dialogs that block execution when a tool requires user approval. These are the interactive prompts that ask "Bash wants to run: npm install — allow once, allow always, or deny?" Each dialog here is specialized for a particular tool.
design-system/ contains low-level UI primitives that are not specific to any one feature. These are the building blocks that components throughout the rest of src/components/ compose. If you are adding a new component that needs a modal, a tab switcher, a fuzzy search picker, or a theme-aware container, you start here.
agents/ contains components for visualizing multi-agent workflows — the teammate-style display when Claude Code spawns a sub-agent to handle a delegated task.
mcp/ contains components for displaying MCP (Model Context Protocol) server status, connections, and tool registrations.
PromptInput/ is the user input component covered in Chapter 11. It lives in src/components/PromptInput/ rather than in design-system/ because it is specific to the top-level REPL interaction model, not a general-purpose primitive.
Top-level components in src/components/ that do not fit a subdirectory include StatusBar.tsx, TaskPanel.tsx, and the cost display component. These are "singletons" — one instance per session, used directly by the top-level application layout.
The key principle underlying this organization is that design-system/ has no imports from any sibling directory. It knows nothing about messages, permissions, agents, or MCP. Everything else can import from design-system/, but design-system/ cannot import from them. This prevents circular dependencies and keeps the primitives genuinely reusable.
12.2 Design System Primitives
The four primitives in src/components/design-system/ cover the most common structural needs in a terminal UI: presenting a focused overlay (Dialog), switching between categorized views (Tabs), selecting from a filtered list (FuzzyPicker), and rendering a themed container (ThemedBox).
12.2.1 Dialog
src/components/design-system/Dialog.tsx
A "modal" in a terminal is a conceptual challenge. There is no z-axis — you cannot float a layer above existing content the way a browser can. Ink's rendering pipeline (Chapter 10) simply paints rows top to bottom; there is no concept of overlapping layers.
Dialog's solution is to simulate the visual effect of a modal by drawing a Box with a border over the content that occupies the same terminal rows. When a Dialog is active, it is rendered by the parent component in place of (or immediately after) the content it is logically "above". The border drawn by Ink's borderStyle prop creates the visual impression of separation.
Dialog exposes three compositional slots: a title area at the top, a content area in the middle, and a button area at the bottom. This three-slot structure maps directly to the visual anatomy of every permission dialog and confirmation prompt in the application — which is precisely why all of those dialogs build on Dialog rather than recreating the border and layout from scratch.
// src/components/design-system/Dialog.tsx — simplified props interface
type DialogProps = {
title: string
children: React.ReactNode // content slot
buttons?: React.ReactNode // bottom action area slot
}The border is implemented with Ink's <Box borderStyle="round"> (or a similar style string). "Round" corners give it a slightly softer look than the default sharp-corner ASCII art, which is a deliberate aesthetic choice: permission dialogs are already interruptive; making them visually harsh would increase friction.
12.2.2 Tabs
src/components/design-system/Tabs.tsx
Tabs implement horizontal tab switching for views that have multiple named sections. The /config command is the primary consumer: the configuration interface has sections for API keys, model selection, and permission settings, and tabs let the user navigate among them without leaving the current screen.
The active tab is highlighted with either underline styling or reverse-video (background and foreground colors swapped), depending on what the terminal supports. Keyboard navigation uses Left and Right arrow keys (← / →) to move between tabs. The component manages activeIndex state internally and calls an onTabChange callback so the parent can render the appropriate content for the selected tab.
// src/components/design-system/Tabs.tsx — simplified interface
type TabsProps = {
tabs: string[]
activeIndex: number
onTabChange: (index: number) => void
}Tabs does not render tab content — it only renders the tab bar itself. The parent component is responsible for conditionally rendering the right content based on activeIndex. This separation keeps Tabs lightweight and avoids coupling the navigation control to any specific content.
12.2.3 FuzzyPicker
src/components/design-system/FuzzyPicker.tsx
FuzzyPicker is the most architecturally interesting primitive in the design system, and the most widely reused. It powers command completion (when the user types / and sees a filtered list of slash commands), file selection, and any other context where the user needs to pick from a long list by typing a partial string.
Its design philosophy is worth stating explicitly: FuzzyPicker is generic over the item type, it delegates all rendering of individual items to a callback, and it owns only the filtering and navigation logic. This makes it composable without requiring a centralized registry of "picker modes".
FuzzyPicker is covered in depth in Section 12.5.
12.2.4 ThemedBox
src/components/design-system/ThemedBox.tsx
ThemedBox is a thin wrapper around Ink's <Box> that adds theme awareness. Instead of hardcoding color values inline, components that need a background color or a border color use ThemedBox and receive the right values for the current terminal's color capability automatically.
// src/components/design-system/ThemedBox.tsx — simplified interface
type ThemedBoxProps = {
variant: 'default' | 'info' | 'warning' | 'error' | 'success'
children: React.ReactNode
// ...Ink Box layout props passed through
}The variant prop selects a named color role from the active theme rather than a raw color string. "Error" maps to red in any theme; the exact hex value, ANSI code, or 256-color index depends on what the terminal supports. The theme system (Section 12.4) handles that resolution. Components that use ThemedBox never need to know whether they are running in a 256-color xterm or a 16-color SSH session.
12.3 Message Rendering System
Every entry in the conversation history is one of several Message variants. The REPL iterates the message list and dispatches each message to its corresponding rendering component. This section describes how the three most important variants are rendered.
12.3.1 AssistantMessage
src/components/messages/AssistantMessage.tsx
The assistant's responses contain Markdown, and rendering Markdown in a terminal is a non-trivial problem. The browser has native Markdown-adjacent rendering; the terminal has only characters and ANSI escape codes.
AssistantMessage handles four structural elements:
Headings (# Heading, ## Subheading, etc.) are rendered with bold formatting and, if the terminal supports it, a slightly brighter foreground color. There is no visual weight difference between # and ## in the way a browser would use font-size; the hierarchy is expressed through indentation of the content that follows.
Bold and italic text (**bold**, _italic_) are rendered using the corresponding ANSI attributes — bold via \x1B[1m and italic via \x1B[3m (where supported; not all terminals honor \x1B[3m). The component falls back gracefully when italic is not supported by rendering the text in a dimmer foreground instead.
Code blocks (triple-backtick fenced regions) receive the most attention. The component applies syntax highlighting using chalk colors: keywords in one color, string literals in another, comments dimmed. Line numbers are shown to the left of the code, which is important in practice because Claude Code frequently produces multi-file diffs and numbered references to specific lines.
Streaming render is the part that matters most for perceived performance. As the model streams tokens, AssistantMessage re-renders with each new character appended to the current paragraph. Because Ink's reconciler (Chapter 10) does differential rendering — only re-painting rows that changed — this append-only pattern costs roughly one row repaint per token, not a full-screen repaint. The "typing" effect the user sees is the direct consequence of this interaction between streaming state updates and Ink's differential output.
Very long responses are not displayed in full by default. AssistantMessage measures its rendered line count and, if it exceeds a configurable threshold, truncates the display and appends a [expand] toggle. The user can press Enter on the toggle to reveal the full content. This keeps the terminal from being overwhelmed by a thousand-line code dump that the user has already scrolled past.
12.3.2 ToolUseMessage
src/components/messages/ToolUseMessage.tsx
When the model decides to call a tool, the resulting ToolUseMessage is rendered before the tool actually executes. This gives the user a real-time view of what the model is about to do — and is also the entry point for permission dialogs if the tool requires approval.
The rendering is not uniform across tools. ToolUseMessage inspects the tool name and applies specialized formatting:
For BashTool, the command string is rendered in a dark background box with syntax highlighting. The visual treatment is deliberately similar to a code block, reinforcing the mental model that a BashTool call is "running this code". Commands that include potentially destructive operations (anything matching patterns like rm -rf, git reset --hard, DROP TABLE) are flagged with a warning color.
For FileReadTool, the rendered display shows the file path and, if a line range was specified, the range. This is visually minimal because a read is the least destructive action — no need for visual weight.
For FileEditTool, the display shows the diff that is about to be applied: lines removed in red (prefixed with -), lines added in green (prefixed with +). The diff is rendered inline, not in a separate pane. This is the primary UX surface for reviewing changes before they are committed to disk.
For AgentTool, the display shows a short message along the lines of "Launching sub-agent..." with the task description visible beneath it. The agent's configuration — which tools it will have access to, what its system prompt says — is summarized but not shown in full, since the details are typically too long to be useful at a glance.
12.3.3 ToolResultMessage
src/components/messages/ToolResultMessage.tsx
After a tool executes, its result is rendered as a ToolResultMessage. The visual treatment conveys success or failure at a glance:
Successful results are wrapped in a ThemedBox with variant: 'success', which renders a green border. Very long results — the output of a shell command that produced thousands of lines, or the content of a large file — are truncated at a configurable line count. A [show full output] toggle is appended if content was cut. This truncation is important: without it, a single tool result could push the rest of the conversation off-screen.
Failed results use variant: 'error', rendering a red border. Error messages are shown in full without truncation, because the complete error text is usually necessary to diagnose the failure. A truncated error message that cuts off the relevant part of a stack trace would be worse than useless.
Image results arise when FileReadTool reads an image file. Claude Code renders a low-resolution ASCII art representation of the image using a character mapping of pixel brightness values. The result is recognizable for simple images — logos, diagrams — and clearly signals "this is an image, not text" even when the representation is too crude to be useful. The fallback to ASCII art means the component never needs to assume sixel graphics or kitty protocol support.
12.4 Theme System
Claude Code's theme system is designed around a single constraint: the application must look correct and usable on any terminal, from a 24-bit Truecolor modern terminal emulator to a 16-color legacy SSH session. This rules out hardcoding any specific color values in component code.
12.4.1 Terminal Color Detection
At startup, the theme system detects the terminal's color capability by inspecting environment variables and terminal capabilities. The detection produces one of four tiers:
Truecolor (24-bit RGB): The terminal supports arbitrary RGB colors via
\x1B[38;2;r;g;bm. Modern terminals (iTerm2, Windows Terminal, most Linux terminal emulators) fall into this tier. The theme can express any color from the design palette exactly.256-color: The terminal supports a 256-color palette via
\x1B[38;5;nm. Colors are approximated by selecting the closest entry in the 256-color table. Hue and saturation may shift slightly, but the result is visually coherent.16-color ANSI: Only the eight standard colors and their bright variants are available. The theme maps semantic color roles to the closest named ANSI color: "success" maps to ANSI green, "error" to ANSI red, "info" to ANSI cyan. The result is blunter but functional.
No color (plain text): The terminal does not support color, or the user has set
NO_COLOR=1. All color codes are suppressed. Bold and italic ANSI attributes may still be used for emphasis, but no foreground or background colors are applied.
Claude Code also detects whether the terminal background is light or dark. On a dark background, the default theme uses lighter foreground colors; on a light background, it uses darker ones. This auto-switching means the same application looks correct on both a developer's dark-mode terminal and a light-mode terminal in a classroom setting.
12.4.2 The useTheme() Hook
Theme variables are defined centrally in the design system and accessed via a useTheme() React hook. Components never reference raw color codes — they reference semantic names:
// Inside any component that needs theme-aware colors
const theme = useTheme()
// Use semantic names, not raw color strings
<Text color={theme.colors.success}>Operation completed.</Text>
<Text color={theme.colors.errorForeground}>Permission denied.</Text>
<Box borderColor={theme.colors.border}>{children}</Box>The hook reads from a React context that is populated once at startup by the color detection logic. Because the hook abstracts the resolution, the same component code works correctly at all four color tiers without any conditional logic inside the component itself.
This matters when modifying or extending the component library. The rule is: any color reference in a component must go through useTheme(). Hardcoded color strings are a bug waiting to happen — they will look fine in the developer's Truecolor terminal and break in a 16-color environment.
12.5 Representative Component Walkthroughs
12.5.1 FuzzyPicker: Architecture Deep Dive
FuzzyPicker at src/components/design-system/FuzzyPicker.tsx is the best single example of the design system philosophy because it exhibits all three virtues that the design system aims for: it is generic, it is composable, and it is entirely keyboard-driven.
The props interface
FuzzyPicker is generic over its item type:
// src/components/design-system/FuzzyPicker.tsx — props interface
type FuzzyPickerProps<T> = {
items: T[]
renderItem: (item: T, isSelected: boolean) => React.ReactNode
onSelect: (item: T) => void
onCancel?: () => void
placeholder?: string
initialFilterText?: string
getItemText: (item: T) => string // used for fuzzy matching
}items is the full list to search. renderItem is a callback — FuzzyPicker does not know how to display a slash command, a file path, or an agent name; it delegates that to the caller. getItemText extracts a plain string from each item so the fuzzy matching algorithm has something to match against. onSelect is called with the chosen item when the user presses Enter; onCancel is called when the user presses Escape.
This separation of concerns means FuzzyPicker can serve as the foundation for multiple distinct completion workflows without modification. The slash-command completer passes Command objects; the file picker passes file path strings (where getItemText is identity); they share the exact same component.
Internal state
FuzzyPicker maintains two pieces of state:
const [filterText, setFilterText] = React.useState(initialFilterText ?? '')
const [selectedIndex, setSelectedIndex] = React.useState(0)filterText is whatever the user has typed so far. It feeds directly into the fuzzy matching call on every render — there is no debounce, because the terminal has no concept of a "typing" event that warrants a delay, and the list is typically short enough that re-filtering on every keystroke is imperceptible.
selectedIndex is the position of the highlighted item in the filtered list. It is reset to 0 whenever filterText changes (a new filter means the old selection position may no longer be valid).
Fuzzy matching
FuzzyPicker uses fuse.js (or a functionally equivalent library) to perform fuzzy search. The key characteristic of fuse.js that matters here is that it returns match indices — the positions within each item string where the query characters matched. FuzzyPicker uses these indices to render matching characters in a highlighted color, giving the user clear feedback about why a particular item appeared in the results.
// Simplified matching logic inside FuzzyPicker
const fuse = new Fuse(items, {
keys: [{ name: 'text', getFn: getItemText }],
includeMatches: true,
threshold: 0.4, // controls how fuzzy the match is
})
const results = filterText.length > 0
? fuse.search(filterText)
: items.map(item => ({ item, matches: [] }))When filterText is empty, all items are shown in their original order. When the user starts typing, fuse.js reorders by match score — best matches first.
Keyboard handling
FuzzyPicker registers a keyboard handler using Ink's useInput() hook (Chapter 10, Section 10.5):
useInput((input, key) => {
if (key.upArrow) {
setSelectedIndex(i => Math.max(0, i - 1))
return
}
if (key.downArrow) {
setSelectedIndex(i => Math.min(filteredItems.length - 1, i + 1))
return
}
if (key.return) {
if (filteredItems[selectedIndex]) {
onSelect(filteredItems[selectedIndex].item)
}
return
}
if (key.escape) {
onCancel?.()
return
}
if (key.backspace || key.delete) {
setFilterText(t => t.slice(0, -1))
return
}
// Any printable character extends the filter
if (input && !key.ctrl && !key.meta) {
setFilterText(t => t + input)
}
})Up and Down navigate the selection. Enter confirms. Escape cancels. Backspace trims the filter string. Any other printable character is appended to filterText, which triggers a re-filter and re-render. The keyboard contract is exactly what users expect from a fuzzy picker in any modern tool.
Virtual list rendering
When the item list is long — thousands of file paths, for instance — rendering every filtered item as a React node would be wasteful. FuzzyPicker implements a simple virtual list: it computes a visible window around selectedIndex and renders only the items within that window.
// Simplified virtual windowing
const VISIBLE_ROWS = 10
const windowStart = Math.max(0, selectedIndex - Math.floor(VISIBLE_ROWS / 2))
const windowEnd = Math.min(filteredItems.length, windowStart + VISIBLE_ROWS)
const visibleItems = filteredItems.slice(windowStart, windowEnd)The selected item is always kept in the center of the visible window when possible, which means the list scrolls as the user navigates rather than jumping to a new page. Items above and below the window are not rendered at all; their removal from the React tree is handled by Ink's reconciler without any special cleanup.
12.5.2 Permission Dialog: Bash Tool Specialization
src/components/permissions/ contains the dialogs that surface when a tool requires user approval. The general structure, shared across all permission dialogs, uses Dialog from the design system: a title bar showing the tool name and action summary, a details area showing the specific parameters, and a button row with "allow once", "allow always", and "deny" options.
The keyboard shortcuts are consistent across all permission dialogs: y for allow once, a for allow always, and n for deny. This consistency means users who have seen one permission dialog have effectively seen all of them.
What varies is the details area for each tool. The BashTool permission dialog is worth examining because it handles the most dangerous possible operation — arbitrary shell command execution.
// src/components/permissions/BashPermission.tsx — conceptual structure
function BashPermissionDialog({ command, onAllow, onAllowAlways, onDeny }) {
const theme = useTheme()
const isSandboxEscape = detectsSandboxEscape(command)
return (
<Dialog
title="Bash wants to run a command"
buttons={<PermissionButtons onAllow={onAllow} onAllowAlways={onAllowAlways} onDeny={onDeny} />}
>
{/* Full command string, syntax-highlighted */}
<SyntaxHighlightedCommand command={command} />
{/* Warning if the command looks dangerous */}
{isSandboxEscape && (
<ThemedBox variant="warning">
<Text>This command may access files outside the project directory.</Text>
</ThemedBox>
)}
</Dialog>
)
}The full command string is shown with syntax highlighting — not truncated. If a user is being asked to approve rm -rf node_modules && npm install, they should be able to see the entire command before deciding. Truncating it at 80 characters would undermine the purpose of the permission dialog.
The detectsSandboxEscape check inspects the command string for patterns that indicate the command will operate outside the current working directory: absolute paths to locations outside the project root, cd to an external directory, curl or wget writing to arbitrary locations. When this check triggers, the warning box appears beneath the command. The warning is informational, not blocking — the user can still choose "allow" — but it ensures the action is not taken unconsciously.
The FileEditTool permission dialog (src/components/permissions/FileEditPermission.tsx) follows the same Dialog shell but replaces the command display with a rendered diff. The diff component is shared with ToolUseMessage (Section 12.3.2), which is why you can see the same ± line formatting in both the pre-execution preview and the already-rendered turn history.
12.5.3 AssistantMessage Streaming: The Render Loop
A subtler walkthrough worth following is how AssistantMessage handles streaming — because it reveals how React state, Ink's differential renderer, and the token stream cooperate.
When the model begins streaming a response, the agentic loop updates the message store with each new token. The store update causes React to schedule a re-render of AssistantMessage. Ink's reconciler performs the re-render and then runs its differential output pass: it compares the new terminal row content against the previous terminal row content and writes only the changed rows.
The consequence is that streaming a long response does not get slower as the response grows. If the response has already filled 300 rows and the cursor is on row 301, the differential renderer touches only row 301 per new token. The 300 completed rows are unchanged and are never re-painted.
This property is not accidental. It is the reason Chapter 10 devotes Section 10.1.1 to the performance motivation for the Ink fork. The streaming render behavior in AssistantMessage depends directly on the fork's differential rendering, which the upstream Ink library does not implement.
The truncation toggle interacts with this in an interesting way. When the user activates [expand] on a truncated message, the component transitions from displaying N rows to displaying the full row count. This causes the terminal to reflow — rows below the expanded message shift down. Ink handles this by repainting from the expansion point downward. It is one of the few cases where the differential renderer must paint more than a constant number of rows per interaction.
12.6 Adding a New Component: Practical Guidance
If you are extending the Claude Code UI — adding a new message type, a new tool with its own permission dialog, or a new design-system primitive — the directory organization described in this chapter tells you where the file should live. The theme system and the design-system primitives tell you how to write it.
A few practical rules:
Start from ThemedBox and Dialog rather than raw Ink <Box>. ThemedBox ensures your new component inherits the color degradation behavior automatically. Dialog ensures that any overlay or confirmation UI follows the same visual grammar as the rest of the application.
Route all color references through useTheme(). If you find yourself writing color="red" directly in JSX, stop and find the appropriate semantic name in the theme object instead.
If your component needs a list picker of any kind, use FuzzyPicker with a custom renderItem callback rather than writing a new list-navigation component. The keyboard contract (Up/Down/Enter/Esc) is already what users expect, and the virtual windowing handles performance for you.
When writing a new permission dialog, compose from the existing Dialog structure and the shared PermissionButtons component. Do not write new keyboard binding logic — reuse the y/a/n shortcuts so every permission dialog behaves identically from the user's perspective.
Key Takeaways
src/components/ is organized by responsibility, not by feature. design-system/ is the isolated foundation; messages/ and permissions/ build on it. The isolation is enforced by the rule that design-system/ has no imports from sibling directories.
The four design-system primitives — Dialog, Tabs, FuzzyPicker, ThemedBox — cover the structural needs of nearly every component in the codebase. Before writing layout or interaction logic from scratch, check whether one of these primitives already solves the problem.
The message rendering components (AssistantMessage, ToolUseMessage, ToolResultMessage) are not interchangeable. Each has rendering logic tailored to its content type. ToolUseMessage is where tool-specific display formatting lives; ToolResultMessage is where success/failure visual treatment lives.
Permission dialogs share a structural shell (Dialog + PermissionButtons + consistent keyboard shortcuts) but have specialized details areas per tool. The shared shell ensures behavioral consistency; the specialization ensures the user always has enough information to make an informed decision.
The theme system is a degradation hierarchy, not a fixed palette. Components access colors through useTheme() semantic names; the theme resolves those names to the best color representation the current terminal supports. This is the only correct way to reference colors in any component.
FuzzyPicker's generic design — items typed by the caller, rendering delegated to a callback, filtering provided by fuse.js — means it can serve every list-selection context in the application without modification. When in doubt, reach for FuzzyPicker before writing a new navigation component.