输出样式系统:让终端输出也有品牌感

深入 Claude Code 的输出样式——主题系统、Markdown 终端渲染、语法高亮、品牌一致性

问题引入

打开 Claude Code,你会注意到终端有颜色——不是操作系统的默认颜色,而是精心设计的色彩方案。Claude 的回答有橙色边框,权限请求是紫色,diff 用绿/红高亮,代码块有语法高亮。切换到 /theme light,所有颜色无缝适配浅色背景。输入 /output-style Learning,Claude 的回答风格从简洁变成教学模式,开始解释决策背后的原因。

这背后有两个独立但协作的系统:

  1. Visual Theme(视觉主题) — 控制颜色、边框、语法高亮等视觉呈现,通过 /theme 命令切换
  2. Output Style(输出样式) — 控制 Claude AI 的回答风格和行为模式,通过 output style picker 选择

本文深入分析这两个系统的设计和实现。


双系统架构

视觉主题 (Visual Theme)
/theme 命令
ThemePicker 组件
ThemeSetting
ThemeProvider (React Context)
Theme 色彩对象 (90+ 颜色)
终端渲染
总耗时 ≈ 6 步(并行 = 更快)
输出样式 (Output Style)
OutputStylePicker
outputStyle 设置
OutputStyleConfig
系统 Prompt 注入
Claude AI 行为
总耗时 ≈ 5 步(并行 = 更快)

关键区别:视觉主题改变的是你看到什么颜色,输出样式改变的是AI 说什么话。两者完全正交——你可以用深色主题配合 Learning 样式,也可以用浅色主题配合默认样式。


视觉主题系统

Theme 类型:90+ 语义化颜色

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}

这不是简单的"前景色/背景色",而是一个完整的设计令牌(design token)系统。每个颜色都有明确的语义:

  • claude — Claude 品牌橙色,用于 AI 回答的边框
  • permission — 紫色,用于权限请求
  • bashBorder — 粉色,用于 Bash 工具的输出边框
  • success / error / warning — 语义状态色
  • diffAdded / diffRemoved — diff 高亮色
  • *Shimmer — 每个主色都有对应的"微光"变体,用于加载动画

Shimmer 变体是一个细节设计:当 AI 正在思考时,边框会在主色和微光色之间交替闪烁。没有微光变体,动画要么太突兀(两个差异很大的颜色),要么不可见(同一个颜色)。

6 个主题变体

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

每个变体都针对特定场景优化:

  • dark / light — 使用明确的 RGB 值,在所有终端上看起来一致
  • *-daltonized — 色觉障碍友好版本,避免依赖红/绿区分
  • *-ansi — 使用 ANSI 颜色代码而非 RGB,尊重用户自定义的终端配色方案

为什么 light 主题用 RGB 而不用 ANSI?因为用户可能在终端中把 ANSI "红色" 配置成亮粉色或暗红色——如果依赖 ANSI 颜色,diff 的红绿可能变得难以区分。用明确的 RGB 值保证了 Anthropic 设计师精心调配的色彩在任何终端上都一样。

而 ANSI 变体存在的意义是:有些用户投入大量精力调配了完美的终端配色,他们希望所有工具都使用自己的配色而不是被覆盖。

ThemeSetting:'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]

ThemeSettingThemeName 是不同的类型。ThemeSetting 多了一个 'auto' 选项,它在运行时解析为具体的 ThemeName

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

Auto 模式通过 OSC 11 协议查询终端的背景色来判断深浅模式:

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]);

几个实现细节:

  1. Feature flag 守护AUTO_THEME feature flag 让整个 systemThemeWatcher 模块在外部构建中被死代码消除
  2. 动态导入import('../../utils/systemThemeWatcher.js') 避免在非 auto 模式下加载多余代码
  3. 取消语义cancelled 标志防止组件卸载后设置状态
  4. $COLORFGBG 回退 — 初始化时从环境变量 $COLORFGBG 获取近似值,后续 OSC 11 查询纠正

ThemePicker:实时预览的交互组件

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 有一个"预览"机制——用户在列表中导航时,主题实时切换让用户看到效果,但只有确认选择后才保存:

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

三个操作的区别:

  • setPreviewTheme(setting) — 临时切换,不写入 config
  • savePreview() — 把当前预览保存为正式主题
  • cancelPreview() — 恢复到预览前的主题

这样用户在挑选主题时可以实时看到每个选项的效果,按 Escape 就回到原来的状态。


/theme 命令:最简斜杠命令

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

这是一个 local-jsx 类型的命令——它返回一个 React 组件而不是纯文本。load: () => import('./theme.js') 使用动态导入实现按需加载。

实际执行逻辑非常简洁:

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

ThemePickerCommand 组件包装了 ThemePicker,在用户选择后调用 setTheme(setting) 和 onDone(Theme set to setting) 完成操作:

src/commands/theme/theme.tsx:13-52
TypeScript
13function ThemePickerCommand({ onDone }: Props) {
14 const [, setTheme] = useTheme();
15 // ... 选择处理
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}

Pane color="permission" 包裹,让主题选择器使用紫色边框——与其他权限/设置类界面保持视觉一致。


输出样式系统

内置样式:Default、Explanatory、Learning

src/constants/outputStyles.ts:41-135
TypeScript
41export const OUTPUT_STYLE_CONFIG: OutputStyles = {
42 [DEFAULT_OUTPUT_STYLE_NAME]: null, // null 表示使用默认行为
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}

Default 样式的值是 null——它不注入任何额外 prompt,Claude 使用自己的默认行为。这个设计避免了"默认模式也有 prompt 开销"的问题。

keepCodingInstructions: true 告诉系统在切换到该样式时保留底层的编码指令 prompt,而不是完全替换。这对 Explanatory 和 Learning 样式很重要——它们是在默认行为基础上叠加教学能力,而不是替换编码能力。

Explanatory 样式的 Insight 格式

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 来自 figures 库,在不同终端上渲染为合适的星号字符。整个 Insight 块使用反引号渲染为等宽文本,确保分隔线在终端中对齐。

Learning 样式的交互模式

Learning 样式最有趣的部分是它要求 AI 暂停并让用户动手

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]

Prompt 还指导 AI 在代码中插入 TODO(human) 标记——一种在代码库和对话之间创建链接的方式。AI 不应该在发出"Learn by Doing"请求后继续操作,而是等待用户实现。


自定义输出样式:Markdown 文件加载

样式来源 (优先级从低到高)
内置样式 (Default/Explanatory/Learning)
插件样式 (plugin output-styles/)
用户样式 (~/.claude/output-styles/*.md)
项目样式 (.claude/output-styles/*.md)
策略样式 (managed settings)

自定义样式通过 Markdown 文件定义,放在 .claude/output-styles/ 目录下。加载逻辑在 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)

加载流程详解

  1. 调用 loadMarkdownFilesForSubdir('output-styles', cwd) — 这个通用函数同时在自定义输出样式中搜索用户目录(~/.claude/output-styles/)、项目目录(.claude/output-styles/)以及策略管理目录

  2. 解析 Frontmatter — Markdown 文件的 YAML 头部提供名称和描述:

    MARKDOWN
    1---
    2name: Concise
    3description: Short and sweet responses
    4keep-coding-instructions: true
    5---
    6Your style prompt content here...
  3. 文件名作为回退 — 如果 frontmatter 没有 name 字段,使用文件名(去掉 .md 后缀)

  4. keep-coding-instructions 处理 — 支持布尔值和字符串值(true / 'true'),因为 YAML frontmatter 的类型解析不总是一致的

  5. memoize 缓存 — 使用 lodash 的 memoize 避免重复扫描文件系统

样式合并优先级

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, // 内置样式作为基础
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 // 优先级从低到高: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})

优先级顺序是 built-in < plugin < user < project < managed。这意味着:

  • 项目可以定义与内置同名的样式来覆盖它
  • 企业策略(managed)拥有最高优先级——即使项目定义了同名样式,策略的版本也会胜出
  • 同名覆盖而非合并——后加载的完全替换先加载的

Markdown 配置加载器:通用基础设施

输出样式的文件加载不是独立实现的,而是复用了 markdownConfigLoader.ts 中的通用基础设施。这个加载器同时服务于 commands、agents、output-styles、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

目录遍历策略

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}

从当前目录向上遍历,直到遇到 git 根目录或 home 目录。在 git 根目录处停止是一个安全决策——防止父目录中的 .claude/ 配置意外泄漏到子项目中。

例如,如果目录结构是:

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

当在 my-repo 中工作时,如果 my-repo 是一个 git 仓库,只会加载 concise.md,不会加载 ~/projects/ 级别的 verbose.md

文件搜索:双引擎策略

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}

默认使用 ripgrep 查找 .md 文件(更快),但提供了 Node.js 原生实现作为回退。两个搜索引擎的差异:

  • ripgrep — 更快,但在 native build 中启动开销较大
  • Node.js 原生 — 启动快,无需外部进程,但扫描大目录慢

3 秒超时(AbortSignal.timeout(3000))防止在巨大的 .claude/output-styles/ 目录上挂住。

去重:Inode 级别的精确去重

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}

使用 device:inode 标识符进行去重,这能检测到通过符号链接或硬链接指向同一物理文件的路径。例如,如果 ~/.claude 是一个指向项目内目录的符号链接,同一个 output-style 文件可能被发现两次——一次作为 user settings,一次作为 project settings。inode 去重确保只加载一次。

getFileIdentity 使用 bigint: true 调用 lstat,因为某些文件系统(如 ExFAT)的 inode 编号可能超过 JavaScript 的 Number 精度(53 位)。


插件输出样式

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}

插件输出样式有一个关键区别——命名空间化:

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

插件样式名会自动加上 pluginName: 前缀,比如 my-plugin:concise。这防止不同插件的同名样式冲突。

force-for-plugin 机制

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

插件可以在 frontmatter 中设置 force-for-plugin: true,当该插件启用时自动应用其输出样式,无需用户手动选择。如果多个插件都设置了 force,只会使用第一个并记录警告:

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 只对插件源的样式有效。如果用户自己的 output-style 文件设置了这个字段,会收到一个 debug 级别的警告。


OutputStylePicker:样式选择 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 // 错误时回退到内置样式
46 const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG)
47 setStyleOptions(builtInOptions)
48 setIsLoading(false)
49 })
50 }, [])

异步加载所有样式(包括自定义和插件),如果加载失败则降级到内置样式。加载过程中显示 Loading output styles... 提示。

mapConfigsToOptions 将样式配置转换为 Select 组件需要的格式:

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}

Default 样式的 config 是 null,所以需要 ?? 提供回退标签和描述。


缓存清理:全局联动

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}

系统中有多层 memoize 缓存需要协调清理:

  1. getOutputStyleDirStyles — 目录级样式加载缓存
  2. loadMarkdownFilesForSubdir — 通用 Markdown 文件搜索缓存
  3. loadPluginOutputStyles — 插件样式缓存
  4. getAllOutputStyles — 最终合并结果缓存

clearOutputStyleCaches() 一次性清理前三层,clearAllOutputStylesCache() 清理最顶层。当用户修改了 .claude/output-styles/ 下的文件时,需要调用这些函数让新样式生效。

.cache?.clear?.() 使用可选链——如果 memoize 的实现没有暴露 cache 对象,静默跳过而不报错。


分析集成:追踪样式使用

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}

分析事件区分三种情况:

  1. Default 样式repl_main_thread(无后缀)
  2. 内置非默认样式repl_main_thread:outputStyle:Explanatory(包含样式名)
  3. 自定义样式repl_main_thread:outputStyle:custom(不泄漏用户自定义样式的名称)

第三种情况的隐私考虑很重要——自定义样式名可能包含团队名或项目名等敏感信息。


系统初始化中的样式传递

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

系统初始化消息(system/init)包含当前输出样式名称,传递给 SDK 消费者(如 VS Code 扩展)。这样远程客户端知道当前 session 使用什么样式,可以在 UI 中显示或提供切换选项。


设计总结

视觉层
6 个主题 + auto
ThemeProvider
90+ 设计令牌
Ink 组件渲染
语法高亮
Diff 高亮
行为层
3 个内置样式
getAllOutputStyles()
自定义 .md 文件
插件样式
活跃样式
System Prompt 注入
基础设施
markdownConfigLoader
ripgrep / native fs
inode 去重

Claude Code 的输出样式系统展示了几个值得借鉴的设计模式:

关注点分离 — 视觉主题和输出样式是两个独立系统,通过不同的接口控制。视觉主题是 React Context + CSS-in-JS 风格的令牌系统,输出样式是 prompt 工程。两者不互相依赖。

层级化配置 — 内置 < 插件 < 用户 < 项目 < 策略,每层可以覆盖下层。企业环境下策略拥有最终决定权。

安全边界 — Git 根目录阻止父目录配置泄漏;inode 去重防止符号链接引起的重复;分析中不泄漏自定义样式名称。

渐进增强 — 默认样式零开销(null prompt);自定义样式按需加载;ripgrep 不可用时回退到 Node.js 原生实现;主题选择失败时保持当前主题。

开发者体验 — 写一个 Markdown 文件放到 .claude/output-styles/ 就能创建自定义输出样式,frontmatter 提供元数据,文件内容就是 prompt。不需要写代码,不需要改配置文件,文件名就是样式名。

这种"Markdown 即配置"的模式在 Claude Code 中被广泛复用——命令、代理、技能、输出样式都使用相同的 markdownConfigLoader 基础设施。一个通用加载器服务于多个子系统,每个子系统只需定义自己的 frontmatter 解析逻辑和配置类型。