The Output Style System: Bringing Brand Identity to Terminal Output

A deep dive into Claude Code's output styles — theme system, Markdown terminal rendering, syntax highlighting, and brand consistency

The Problem

Open Claude Code, and you'll notice the terminal has color — not the operating system's defaults, but a carefully designed color scheme. Claude's responses have orange borders, permission requests are purple, diffs use green/red highlighting, and code blocks get syntax highlighting. Switch to /theme light, and all colors seamlessly adapt to a light background. Type /output-style Learning, and Claude's response style shifts from concise to teaching mode, explaining the reasoning behind its decisions.

Behind this are two independent but cooperating systems:

  1. Visual Theme — Controls colors, borders, syntax highlighting, and other visual presentation, toggled via the /theme command
  2. Output Style — Controls the tone and behavioral patterns of Claude AI's responses, selected via the output style picker

This article dives deep into the design and implementation of both systems.


Dual-System Architecture

Visual Theme
/theme Command
ThemePicker Component
ThemeSetting
ThemeProvider (React Context)
Theme Color Object (90+ colors)
Terminal Rendering
Total ≈ 6 steps (parallel = faster)
Output Style
OutputStylePicker
outputStyle Setting
OutputStyleConfig
System Prompt Injection
Claude AI Behavior
Total ≈ 5 steps (parallel = faster)

The key distinction: the visual theme changes what colors you see, while the output style changes what the AI says. The two are completely orthogonal — you can use a dark theme with the Learning style, or a light theme with the default style.


Visual Theme System

Theme Type: 90+ Semantic Colors

src/utils/theme.ts:4-89
TypeScript
4export type Theme = {
5 autoAccept: string
6 bashBorder: string
7 claude: string
8 claudeShimmer: string
9 permission: string
10 permissionShimmer: string
11 text: string
12 inverseText: string
13 inactive: string
14 subtle: string
15 success: string
16 error: string
17 warning: string
18 diffAdded: string
19 diffRemoved: string
20 diffAddedWord: string
21 diffRemovedWord: string
22 // ... 70+ more color tokens
23}

This isn't a simple "foreground/background" setup — it's a complete design token system. Each color has clear semantics:

  • claude — Claude's brand orange, used for AI response borders
  • permission — Purple, used for permission requests
  • bashBorder — Pink, used for Bash tool output borders
  • success / error / warning — Semantic status colors
  • diffAdded / diffRemoved — Diff highlight colors
  • *Shimmer — Each primary color has a corresponding "shimmer" variant for loading animations

The shimmer variants are a subtle design detail: when the AI is thinking, the border alternates between the primary color and the shimmer color. Without shimmer variants, the animation would be either too jarring (two very different colors) or invisible (the same color).

6 Theme Variants

src/utils/theme.ts:91-98
TypeScript
91export const THEME_NAMES = [
92 'dark',
93 'light',
94 'light-daltonized',
95 'dark-daltonized',
96 'light-ansi',
97 'dark-ansi',
98] as const

Each variant is optimized for a specific scenario:

  • dark / light — Uses explicit RGB values for consistent appearance across all terminals
  • *-daltonized — Color vision deficiency-friendly versions that avoid relying on red/green distinctions
  • *-ansi — Uses ANSI color codes instead of RGB, respecting the user's custom terminal color scheme

Why does the light theme use RGB instead of ANSI? Because users might configure ANSI "red" as bright pink or dark maroon in their terminal — if we relied on ANSI colors, diff red and green could become indistinguishable. Using explicit RGB values ensures the colors Anthropic's designers carefully calibrated look the same on any terminal.

The ANSI variants exist because some users invest significant effort in crafting a perfect terminal color scheme and want all tools to use their palette rather than being overridden.

ThemeSetting: The Wisdom of 'auto'

src/utils/theme.ts:103-109
TypeScript
103export const THEME_SETTINGS = ['auto', ...THEME_NAMES] as const
104
105// A theme preference as stored in user config. 'auto' follows the system
106// dark/light mode and is resolved to a ThemeName at runtime.
107export type ThemeSetting = (typeof THEME_SETTINGS)[number]

ThemeSetting and ThemeName are different types. ThemeSetting adds an 'auto' option, which resolves to a concrete ThemeName at runtime.

src/components/design-system/ThemeProvider.tsx:81
TypeScript
81const currentTheme: ThemeName = activeSetting === 'auto'
82 ? systemTheme : activeSetting;

Auto mode queries the terminal's background color via the OSC 11 protocol to determine light or dark mode:

src/components/design-system/ThemeProvider.tsx:64-79
TypeScript
64useEffect(() => {
65 if (feature('AUTO_THEME')) {
66 if (activeSetting !== 'auto' || !internal_querier) return;
67 let cleanup: (() => void) | undefined;
68 let cancelled = false;
69 void import('../../utils/systemThemeWatcher.js').then(({
70 watchSystemTheme
71 }) => {
72 if (cancelled) return;
73 cleanup = watchSystemTheme(internal_querier, setSystemTheme);
74 });
75 return () => {
76 cancelled = true;
77 cleanup?.();
78 };
79 }
80}, [activeSetting, internal_querier]);

A few implementation details worth noting:

  1. Feature flag guard — The AUTO_THEME feature flag allows the entire systemThemeWatcher module to be dead-code eliminated in external builds
  2. Dynamic importimport('../../utils/systemThemeWatcher.js') avoids loading unnecessary code when not in auto mode
  3. Cancellation semantics — The cancelled flag prevents setting state after the component unmounts
  4. $COLORFGBG fallback — On initialization, an approximate value is obtained from the $COLORFGBG environment variable, then corrected by the subsequent OSC 11 query

ThemePicker: An Interactive Component with Live Preview

src/components/ThemePicker.tsx:19-29
TypeScript
19export type ThemePickerProps = {
20 onThemeSelect: (setting: ThemeSetting) => void;
21 showIntroText?: boolean;
22 helpText?: string;
23 showHelpTextBelow?: boolean;
24 hideEscToCancel?: boolean;
25 skipExitHandling?: boolean;
26 onCancel?: () => void;
27};

ThemePicker has a "preview" mechanism — as the user navigates the list, the theme switches in real time so they can see the effect, but it only saves when the user confirms their selection:

src/components/design-system/ThemeProvider.tsx:82-100
TypeScript
82const value = useMemo<ThemeContextValue>(() => ({
83 themeSetting,
84 setThemeSetting: (newSetting: ThemeSetting) => {
85 setThemeSetting(newSetting);
86 setPreviewTheme(null);
87 if (newSetting === 'auto') {
88 setSystemTheme(getSystemThemeName());
89 }
90 onThemeSave?.(newSetting);
91 },
92 setPreviewTheme: (newSetting: ThemeSetting) => {
93 setPreviewTheme(newSetting);
94 if (newSetting === 'auto') {
95 setSystemTheme(getSystemThemeName());
96 }
97 },
98 // ...

The three operations differ as follows:

  • setPreviewTheme(setting) — Temporarily switches without writing to config
  • savePreview() — Saves the current preview as the active theme
  • cancelPreview() — Reverts to the theme prior to previewing

This lets users see the effect of each option in real time while picking a theme, and pressing Escape returns to the original state.


/theme Command: The Simplest Slash Command

src/commands/theme/index.ts:1-10
TypeScript
1import type { Command } from '../../commands.js'
2
3const theme = {
4 type: 'local-jsx',
5 name: 'theme',
6 description: 'Change the theme',
7 load: () => import('./theme.js'),
8} satisfies Command

This is a local-jsx type command — it returns a React component rather than plain text. load: () => import('./theme.js') uses dynamic import for on-demand loading.

The actual execution logic is remarkably concise:

src/commands/theme/theme.tsx:54-56
TypeScript
54export const call: LocalJSXCommandCall = async (onDone, _context) => {
55 return <ThemePickerCommand onDone={onDone} />;
56};

The ThemePickerCommand component wraps ThemePicker, calling setTheme(setting) and onDone(Theme set to setting) when the user makes a selection:

src/commands/theme/theme.tsx:13-52
TypeScript
13function ThemePickerCommand({ onDone }: Props) {
14 const [, setTheme] = useTheme();
15 // ... selection handling
16 return (
17 <Pane color="permission">
18 <ThemePicker
19 onThemeSelect={setting => {
20 setTheme(setting);
21 onDone(`Theme set to ${setting}`);
22 }}
23 onCancel={() => {
24 onDone('Theme picker dismissed', { display: 'system' });
25 }}
26 skipExitHandling={true}
27 />
28 </Pane>
29 );
30}

Wrapping with Pane color="permission" gives the theme picker a purple border — maintaining visual consistency with other permission/settings interfaces.


Output Style System

Built-in Styles: Default, Explanatory, Learning

src/constants/outputStyles.ts:41-135
TypeScript
41export const OUTPUT_STYLE_CONFIG: OutputStyles = {
42 [DEFAULT_OUTPUT_STYLE_NAME]: null, // null means use default behavior
43 Explanatory: {
44 name: 'Explanatory',
45 source: 'built-in',
46 description:
47 'Claude explains its implementation choices and codebase patterns',
48 keepCodingInstructions: true,
49 prompt: `You are an interactive CLI tool that helps users with software
50engineering tasks. In addition to software engineering tasks, you should
51provide educational insights about the codebase along the way.
52...
53## Insights
54In order to encourage learning, before and after writing code, always
55provide brief educational explanations...`,
56 },
57 Learning: {
58 name: 'Learning',
59 source: 'built-in',
60 description:
61 'Claude pauses and asks you to write small pieces of code for hands-on practice',
62 keepCodingInstructions: true,
63 prompt: `...
64## Requesting Human Contributions
65In order to encourage learning, ask the human to contribute 2-10 line
66code pieces when generating 20+ lines involving:
67- Design decisions (error handling, data structures)
68- Business logic with multiple valid approaches
69- Key algorithms or interface definitions
70...`,
71 },
72}

The Default style has a value of null — it doesn't inject any additional prompt, and Claude uses its own default behavior. This design avoids the overhead of "even the default mode has a prompt."

keepCodingInstructions: true tells the system to preserve the underlying coding instruction prompt when switching to this style, rather than replacing it entirely. This is important for the Explanatory and Learning styles — they layer teaching capabilities on top of the default behavior, rather than replacing coding capabilities.

Explanatory Style's Insight Format

src/constants/outputStyles.ts:30-37
TypeScript
30const EXPLANATORY_FEATURE_PROMPT = `
31## Insights
32In order to encourage learning, before and after writing code, always
33provide brief educational explanations about implementation choices using
34(with backticks):
35"\`${figures.star} Insight ─────────────────────────────────────\`
36[2-3 key educational points]
37\`─────────────────────────────────────────────────\`"
38`

figures.star comes from the figures library, rendering an appropriate star character across different terminals. The entire Insight block uses backticks to render as monospaced text, ensuring the separator lines align in the terminal.

Learning Style's Interactive Mode

The most interesting part of the Learning style is that it asks the AI to pause and let the user practice:

Text
1${figures.bullet} **Learn by Doing**
2**Context:** [what's built and why this decision matters]
3**Your Task:** [specific function/section in file, mention file and TODO(human)]
4**Guidance:** [trade-offs and constraints to consider]

The prompt also instructs the AI to insert TODO(human) markers in code — a way to create links between the codebase and the conversation. The AI should not continue operating after issuing a "Learn by Doing" request, but instead wait for the user to implement it.


Custom Output Styles: Markdown File Loading

Style Sources (Priority Low to High)
Built-in Styles (Default/Explanatory/Learning)
Plugin Styles (plugin output-styles/)
User Styles (~/.claude/output-styles/*.md)
Project Styles (.claude/output-styles/*.md)
Policy Styles (managed settings)

Custom styles are defined through Markdown files placed in the .claude/output-styles/ directory. The loading logic is in loadOutputStylesDir.ts:

src/outputStyles/loadOutputStylesDir.ts:26-92
TypeScript
26export const getOutputStyleDirStyles = memoize(
27 async (cwd: string): Promise<OutputStyleConfig[]> => {
28 try {
29 const markdownFiles = await loadMarkdownFilesForSubdir(
30 'output-styles',
31 cwd,
32 )
33
34 const styles = markdownFiles
35 .map(({ filePath, frontmatter, content, source }) => {
36 try {
37 const fileName = basename(filePath)
38 const styleName = fileName.replace(/\.md$/, '')
39
40 const name = (frontmatter['name'] || styleName) as string
41 const description =
42 coerceDescriptionToString(
43 frontmatter['description'],
44 styleName,
45 ) ??
46 extractDescriptionFromMarkdown(
47 content,
48 `Custom ${styleName} output style`,
49 )
50
51 const keepCodingInstructionsRaw =
52 frontmatter['keep-coding-instructions']
53 const keepCodingInstructions =
54 keepCodingInstructionsRaw === true ||
55 keepCodingInstructionsRaw === 'true'
56 ? true
57 : keepCodingInstructionsRaw === false ||
58 keepCodingInstructionsRaw === 'false'
59 ? false
60 : undefined
61
62 return {
63 name,
64 description,
65 prompt: content.trim(),
66 source,
67 keepCodingInstructions,
68 }
69 } catch (error) {
70 logError(error)
71 return null
72 }
73 })
74 .filter(style => style !== null)
75
76 return styles
77 } catch (error) {
78 logError(error)
79 return []
80 }
81 },
82)

Loading Flow in Detail

  1. Call loadMarkdownFilesForSubdir('output-styles', cwd) — This shared utility function simultaneously searches the user directory (~/.claude/output-styles/), project directory (.claude/output-styles/), and policy-managed directories for custom output styles

  2. Parse frontmatter — The Markdown file's YAML header provides the name and description:

    MARKDOWN
    1---
    2name: Concise
    3description: Short and sweet responses
    4keep-coding-instructions: true
    5---
    6Your style prompt content here...
  3. Filename as fallback — If the frontmatter lacks a name field, the filename is used (with the .md suffix removed)

  4. keep-coding-instructions handling — Supports both boolean and string values (true / 'true'), since YAML frontmatter type parsing isn't always consistent

  5. memoize caching — Uses lodash's memoize to avoid redundant filesystem scans

Style Merge Priority

src/constants/outputStyles.ts:137-175
TypeScript
137export const getAllOutputStyles = memoize(async function getAllOutputStyles(
138 cwd: string,
139): Promise<{ [styleName: string]: OutputStyleConfig | null }> {
140 const customStyles = await getOutputStyleDirStyles(cwd)
141 const pluginStyles = await loadPluginOutputStyles()
142
143 const allStyles = {
144 ...OUTPUT_STYLE_CONFIG, // Built-in styles as the base
145 }
146
147 const managedStyles = customStyles.filter(
148 style => style.source === 'policySettings',
149 )
150 const userStyles = customStyles.filter(
151 style => style.source === 'userSettings',
152 )
153 const projectStyles = customStyles.filter(
154 style => style.source === 'projectSettings',
155 )
156
157 // Priority from low to high: built-in, plugin, user, project, managed
158 const styleGroups = [pluginStyles, userStyles, projectStyles, managedStyles]
159
160 for (const styles of styleGroups) {
161 for (const style of styles) {
162 allStyles[style.name] = { ... }
163 }
164 }
165
166 return allStyles
167})

The priority order is built-in < plugin < user < project < managed. This means:

  • Projects can define styles with the same name as built-in styles to override them
  • Enterprise policies (managed) have the highest priority — even if a project defines a style with the same name, the policy version wins
  • Same-name replacement, not merging — later-loaded styles completely replace earlier ones

Markdown Config Loader: Shared Infrastructure

The output style file loading isn't implemented independently; it reuses the shared infrastructure in markdownConfigLoader.ts. This loader simultaneously serves multiple subdirectories including commands, agents, output-styles, and skills:

src/utils/markdownConfigLoader.ts:29-36
TypeScript
29export const CLAUDE_CONFIG_DIRECTORIES = [
30 'commands',
31 'agents',
32 'output-styles',
33 'skills',
34 'workflows',
35 ...(feature('TEMPLATES') ? (['templates'] as const) : []),
36] as const

Directory Traversal Strategy

src/utils/markdownConfigLoader.ts:234-289
TypeScript
234export function getProjectDirsUpToHome(
235 subdir: ClaudeConfigDirectory,
236 cwd: string,
237): string[] {
238 const home = resolve(homedir()).normalize('NFC')
239 const gitRoot = resolveStopBoundary(cwd)
240 let current = resolve(cwd)
241 const dirs: string[] = []
242
243 while (true) {
244 if (
245 normalizePathForComparison(current) ===
246 normalizePathForComparison(home)
247 ) {
248 break
249 }
250
251 const claudeSubdir = join(current, '.claude', subdir)
252 try {
253 statSync(claudeSubdir)
254 dirs.push(claudeSubdir)
255 } catch (e: unknown) {
256 if (!isFsInaccessible(e)) throw e
257 }
258
259 if (
260 gitRoot &&
261 normalizePathForComparison(current) ===
262 normalizePathForComparison(gitRoot)
263 ) {
264 break
265 }
266
267 const parent = dirname(current)
268 if (parent === current) break
269 current = parent
270 }
271
272 return dirs
273}

It traverses upward from the current directory until it hits the git root or the home directory. Stopping at the git root is a security decision — it prevents .claude/ configurations in parent directories from accidentally leaking into child projects.

For example, given this directory structure:

Text
1~/projects/.claude/output-styles/verbose.md
2~/projects/my-repo/.claude/output-styles/concise.md

When working in my-repo, if my-repo is a git repository, only concise.md will be loaded — verbose.md from the ~/projects/ level won't be included.

File Search: Dual-Engine Strategy

src/utils/markdownConfigLoader.ts:553-568
TypeScript
553const useNative = isEnvTruthy(process.env.CLAUDE_CODE_USE_NATIVE_FILE_SEARCH)
554const signal = AbortSignal.timeout(3000)
555let files: string[]
556try {
557 files = useNative
558 ? await findMarkdownFilesNative(dir, signal)
559 : await ripGrep(
560 ['--files', '--hidden', '--follow', '--no-ignore',
561 '--glob', '*.md'],
562 dir,
563 signal,
564 )
565} catch (e: unknown) {
566 if (isFsInaccessible(e)) return []
567 throw e
568}

By default it uses ripgrep to find .md files (faster), but provides a Node.js native implementation as a fallback. The differences between the two search engines:

  • ripgrep — Faster, but has higher startup overhead in native builds
  • Node.js native — Starts quickly with no external process needed, but slower at scanning large directories

The 3-second timeout (AbortSignal.timeout(3000)) prevents hanging on enormous .claude/output-styles/ directories.

Deduplication: Precise Inode-Level Deduplication

src/utils/markdownConfigLoader.ts:384-407
TypeScript
384const fileIdentities = await Promise.all(
385 allFiles.map(file => getFileIdentity(file.filePath)),
386)
387
388const seenFileIds = new Map<string, SettingSource>()
389const deduplicatedFiles: MarkdownFile[] = []
390
391for (const [i, file] of allFiles.entries()) {
392 const fileId = fileIdentities[i] ?? null
393 if (fileId === null) {
394 deduplicatedFiles.push(file) // fail open
395 continue
396 }
397 const existingSource = seenFileIds.get(fileId)
398 if (existingSource !== undefined) {
399 logForDebugging(
400 `Skipping duplicate file '${file.filePath}' from ${file.source}
401 (same inode already loaded from ${existingSource})`,
402 )
403 continue
404 }
405 seenFileIds.set(fileId, file.source)
406 deduplicatedFiles.push(file)
407}

Deduplication uses device:inode identifiers, which can detect paths pointing to the same physical file via symlinks or hard links. For example, if ~/.claude is a symlink to a directory inside the project, the same output-style file might be discovered twice — once as user settings and once as project settings. Inode deduplication ensures it's loaded only once.

getFileIdentity calls lstat with bigint: true because some filesystems (like ExFAT) can have inode numbers that exceed JavaScript's Number precision (53 bits).


Plugin Output Styles

src/utils/plugins/loadPluginOutputStyles.ts:15-33
TypeScript
15async function loadOutputStylesFromDirectory(
16 outputStylesPath: string,
17 pluginName: string,
18 loadedPaths: Set<string>,
19): Promise<OutputStyleConfig[]> {
20 const styles: OutputStyleConfig[] = []
21 await walkPluginMarkdown(
22 outputStylesPath,
23 async fullPath => {
24 const style = await loadOutputStyleFromFile(
25 fullPath,
26 pluginName,
27 loadedPaths,
28 )
29 if (style) styles.push(style)
30 },
31 { logLabel: 'output-styles' },
32 )
33 return styles
34}

Plugin output styles have a key distinction — namespacing:

src/utils/plugins/loadPluginOutputStyles.ts:53-55
TypeScript
53const baseStyleName = (frontmatter.name as string) || fileName
54const name = `${pluginName}:${baseStyleName}`

Plugin style names are automatically prefixed with pluginName:, e.g., my-plugin:concise. This prevents naming collisions between styles from different plugins.

force-for-plugin Mechanism

src/utils/plugins/loadPluginOutputStyles.ts:64-70
TypeScript
64const forceRaw = frontmatter['force-for-plugin']
65const forceForPlugin =
66 forceRaw === true || forceRaw === 'true'
67 ? true
68 : forceRaw === false || forceRaw === 'false'
69 ? false
70 : undefined

Plugins can set force-for-plugin: true in the frontmatter to automatically apply their output style when the plugin is enabled, without requiring the user to manually select it. If multiple plugins set force, only the first is used and a warning is logged:

src/constants/outputStyles.ts:194-199
TypeScript
194if (forcedStyles.length > 1) {
195 logForDebugging(
196 `Multiple plugins have forced output styles:
197 ${forcedStyles.map(s => s.name).join(', ')}.
198 Using: ${firstForcedStyle.name}`,
199 { level: 'warn' },
200 )
201}

force-for-plugin only takes effect for plugin-sourced styles. If a user's own output-style file sets this field, a debug-level warning is emitted.


OutputStylePicker: Style Selection UI

src/components/OutputStylePicker.tsx:28-111
TypeScript
28export function OutputStylePicker({
29 initialStyle,
30 onComplete,
31 onCancel,
32 isStandaloneCommand,
33}: OutputStylePickerProps) {
34 const [styleOptions, setStyleOptions] = useState([])
35 const [isLoading, setIsLoading] = useState(true)
36
37 useEffect(() => {
38 getAllOutputStyles(getCwd())
39 .then(allStyles => {
40 const options = mapConfigsToOptions(allStyles)
41 setStyleOptions(options)
42 setIsLoading(false)
43 })
44 .catch(() => {
45 // Fall back to built-in styles on error
46 const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG)
47 setStyleOptions(builtInOptions)
48 setIsLoading(false)
49 })
50 }, [])

It asynchronously loads all styles (including custom and plugin styles), falling back to built-in styles if loading fails. A Loading output styles... message is shown during loading.

mapConfigsToOptions converts style configurations into the format expected by the Select component:

src/components/OutputStylePicker.tsx:13-21
TypeScript
13function mapConfigsToOptions(styles) {
14 return Object.entries(styles).map(([style, config]) => ({
15 label: config?.name ?? DEFAULT_OUTPUT_STYLE_LABEL,
16 value: style,
17 description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION
18 }));
19}

The Default style's config is null, so ?? is needed to provide fallback labels and descriptions.


Cache Clearing: Global Coordination

src/outputStyles/loadOutputStylesDir.ts:94-98
TypeScript
94export function clearOutputStyleCaches(): void {
95 getOutputStyleDirStyles.cache?.clear?.()
96 loadMarkdownFilesForSubdir.cache?.clear?.()
97 clearPluginOutputStyleCache()
98}
src/constants/outputStyles.ts:177-179
TypeScript
177export function clearAllOutputStylesCache(): void {
178 getAllOutputStyles.cache?.clear?.()
179}

Multiple layers of memoize caches in the system need coordinated clearing:

  1. getOutputStyleDirStyles — Directory-level style loading cache
  2. loadMarkdownFilesForSubdir — Shared Markdown file search cache
  3. loadPluginOutputStyles — Plugin style cache
  4. getAllOutputStyles — Final merged result cache

clearOutputStyleCaches() clears the first three layers at once, while clearAllOutputStylesCache() clears the top layer. These functions need to be called when the user modifies files under .claude/output-styles/ so that new styles take effect.

.cache?.clear?.() uses optional chaining — if the memoize implementation doesn't expose a cache object, it silently skips without throwing an error.


Analytics Integration: Tracking Style Usage

src/utils/promptCategory.ts:36-49
TypeScript
36export function getQuerySourceForREPL(): QuerySource {
37 const settings = getSettings_DEPRECATED()
38 const style = settings?.outputStyle ?? DEFAULT_OUTPUT_STYLE_NAME
39
40 if (style === DEFAULT_OUTPUT_STYLE_NAME) {
41 return 'repl_main_thread'
42 }
43
44 const isBuiltIn = style in OUTPUT_STYLE_CONFIG
45 return isBuiltIn
46 ? (`repl_main_thread:outputStyle:${style}` as QuerySource)
47 : 'repl_main_thread:outputStyle:custom'
48}

Analytics events distinguish three cases:

  1. Default stylerepl_main_thread (no suffix)
  2. Non-default built-in stylerepl_main_thread:outputStyle:Explanatory (includes style name)
  3. Custom stylerepl_main_thread:outputStyle:custom (doesn't leak the user's custom style name)

The privacy consideration in the third case is important — custom style names might contain team names, project names, or other sensitive information.


Style Passing During System Initialization

src/utils/messages/systemInit.ts:53-56
TypeScript
53export function buildSystemInitMessage(inputs: SystemInitInputs): SDKMessage {
54 const settings = getSettings_DEPRECATED()
55 const outputStyle = (settings?.outputStyle ??
56 DEFAULT_OUTPUT_STYLE_NAME) as string

The system initialization message (system/init) includes the current output style name, passing it to SDK consumers (such as the VS Code extension). This way remote clients know which style the current session uses and can display it in the UI or provide a switching option.


Design Summary

Visual Layer
6 Themes + auto
ThemeProvider
90+ Design Tokens
Ink Component Rendering
Syntax Highlighting
Diff Highlighting
Behavioral Layer
3 Built-in Styles
getAllOutputStyles()
Custom .md Files
Plugin Styles
Active Style
System Prompt Injection
Infrastructure
markdownConfigLoader
ripgrep / native fs
inode Deduplication

Claude Code's output style system demonstrates several design patterns worth studying:

Separation of concerns — The visual theme and output style are two independent systems controlled through different interfaces. The visual theme is a React Context + CSS-in-JS-style token system; the output style is prompt engineering. Neither depends on the other.

Layered configuration — Built-in < plugin < user < project < policy, where each layer can override the one below. In enterprise environments, policies have the final say.

Security boundaries — The git root prevents parent directory configuration leakage; inode deduplication prevents duplicates caused by symlinks; analytics don't leak custom style names.

Progressive enhancement — The default style has zero overhead (null prompt); custom styles load on demand; when ripgrep is unavailable, the system falls back to Node.js native implementation; if theme selection fails, the current theme is preserved.

Developer experience — Writing a Markdown file and placing it in .claude/output-styles/ creates a custom output style. Frontmatter provides metadata, file content is the prompt. No code changes needed, no config files to modify — the filename is the style name.

This "Markdown as configuration" pattern is widely reused throughout Claude Code — commands, agents, skills, and output styles all use the same markdownConfigLoader infrastructure. A single shared loader serves multiple subsystems, with each subsystem only needing to define its own frontmatter parsing logic and configuration types.