Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions docs/27-组件与设计系统.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

Claude Code 面对的正是这样的复杂度。它的 UI 不是静态的信息打印,而是一个**高度动态的交互界面**。在第 26 章中我们看到了 forked Ink 框架如何在终端中运行 React;本章则关注在这个框架之上,团队如何构建了一套**可复用的组件化设计系统**。

> 体例:本书的"章"与"篇"为同一概念,凡跨章引用统一写作"第 N 章"。

这套设计系统回答三个核心问题:
1. **颜色从哪来?** — 6 套主题 × 80+ 语义化颜色 token 的主题系统
2. **组件怎么组合?** — 15 个设计系统组件 + 1 个颜色工具函数的职责划分
Expand Down Expand Up @@ -53,7 +55,12 @@ export type Theme = {
// Agent 色板(8 色,用于区分多个并行 Agent)
red_FOR_SUBAGENTS_ONLY: string
blue_FOR_SUBAGENTS_ONLY: string
// ... 还有 green, yellow, purple, orange, pink, cyan
green_FOR_SUBAGENTS_ONLY: string
yellow_FOR_SUBAGENTS_ONLY: string
purple_FOR_SUBAGENTS_ONLY: string
orange_FOR_SUBAGENTS_ONLY: string
pink_FOR_SUBAGENTS_ONLY: string
cyan_FOR_SUBAGENTS_ONLY: string

// 彩虹色(用于 ultrathink 关键词动画,7 色 + 7 shimmer)
rainbow_red: string
Expand All @@ -64,7 +71,7 @@ export type Theme = {

几个值得注意的设计决策:

**命名即约束**:`red_FOR_SUBAGENTS_ONLY` 这种"尖叫式"命名不是偶然的。它在**类型层面**告诉消费者:这个颜色只该用于子 Agent 的视觉区分,不要在其他地方用红色。这比注释更有效 —— 你在代码审查中会立刻注意到 `theme.red_FOR_SUBAGENTS_ONLY` 出现在非 Agent 的上下文中。
**命名即约束**:`red_FOR_SUBAGENTS_ONLY` 这种"尖叫式"命名不是偶然的。`Theme` 类型本身只规定它是 `string`(见 utils/theme.ts:40-47),约束并不在类型系统里;约束写在字段名上:这个颜色只该用于子 Agent 的视觉区分,不要在其他地方用红色。比起把语义放在注释里,命名能在代码审查中直接被人眼捕捉到 —— `theme.red_FOR_SUBAGENTS_ONLY` 出现在非 Agent 的上下文中,差异是肉眼级别的

**Shimmer 配对**:几乎每个主色都有一个对应的 `*Shimmer` 变体(更浅的版本)。这是为微光动画效果准备的 —— 在两个颜色之间交替切换产生闪烁效果。

Expand Down Expand Up @@ -372,14 +379,15 @@ export function Dialog({

### 5.1 Tabs:三层焦点协作模型

Tabs 组件是设计系统中最复杂的组件,它实现了一个**三层焦点协作模型**:
Tabs 组件是设计系统中最复杂的组件,它实现了一个**三层焦点协作模型** —— Header 是默认层;Content 通过 `useTabHeaderFocus()` 注册为第二层;`navFromContent` 进一步把切换权放给 Content(第三层)

```
┌─ Header focused ─────────────────────┐
┌─ Header focused(默认层)──────────┐
│ Model [Config] Permissions Stats │ ← Tab/←/→ 切换标签
├──────────────────────────────────────┤
│ Content area │ ← ↓ 进入内容(需 opt-in
Content focused(opt-in 第二层) │ ← ↓ 进入(需 useTabHeaderFocus 注册
│ (Select list, form, etc.) │ ← ↑ 回到 Header
│ ③ navFromContent=true(第三层) │ ← Content 聚焦时仍能 Tab/←/→ 切标签
└──────────────────────────────────────┘
```

Expand Down
16 changes: 16 additions & 0 deletions docs/28-Keybindings-Vim与Voice输入.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,22 @@ export type VimState =

INSERT 里没有任何状态机,就是普通的文本输入;NORMAL 里挂着上面那个 11 变体的子状态机。模式之间靠 `Escape` 与 `i` / `a` / `o` 等键切换。

下面把 11 个变体一一对到一个最小输入序列,方便边读边复现:

| # | 变体 | 触发序列 | 含义 |
|---|------|--------|------|
| 1 | `idle` | (初始) | NORMAL 下未按任何键 |
| 2 | `count` | `3` | 已输入计数前缀,等下一个动作 |
| 3 | `operator` | `d` | 已输入算子,等 motion |
| 4 | `operatorCount` | `d3` | 算子之后再输入计数(最终如 `d3w`) |
| 5 | `operatorFind` | `df` | 算子 + `f`/`F`/`t`/`T`,等下一个字符(如 `df,`) |
| 6 | `operatorTextObj` | `di` | 算子 + `i`/`a`,等 text object(如 `diw`、`ci"`) |
| 7 | `find` | `f` | NORMAL 下 `f`/`F`/`t`/`T`,等目标字符 |
| 8 | `g` | `g` | `g` 前缀,等下一个键(如 `gg`) |
| 9 | `operatorG` | `dg` | 算子 + `g` 前缀(如 `dgg`) |
| 10 | `replace` | `r` | `r` 前缀,等替换字符 |
| 11 | `indent` | `>` 或 `<` | 缩进算子,等 motion(如 `>>`、`<j`) |

### 2.2 从 idle 走到一个完整命令

`vim/transitions.ts` 是这台状态机的派发表。`transition` 函数是一个 switch,按当前状态名字分派到对应的 `fromXxx` 函数:
Expand Down
8 changes: 5 additions & 3 deletions docs/29-Buddy宠物.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
4. **没开 Buddy 的渠道,能不能让这一整摊代码彻底不进二进制?** 一个彩蛋功能要是占了 critical path,是说不过去的。
5. **怎么让人发现这个隐藏功能、又不打扰那些不想要它的人?** 弹个明黄色公告会被骂,藏到 `--help` 里又没人看。

Claude Code 的答案可以一句话概括:**把"骨"和"魂"切开存,把渲染、出现、声明、命令、入口五件事分别接进现成的子系统,再用两道编译期门加一道运行期门,把整个 Buddy 在大多数构建里藏到一字节都不剩**。`buddy/` 目录里六个文件加起来千余行,刚好对应这五个问题一一作答:
Claude Code 的答案可以一句话概括:**把"骨"和"魂"切开存,把渲染、出现、声明、命令、入口五件事分别接进现成的子系统,再用两道编译期门加一道运行期门,把整个 Buddy 在大多数构建里藏到一字节都不剩**。`buddy/` 目录里六个文件加起来 1298 行(`companion.ts` 133、`prompt.ts` 36、`sprites.ts` 514、`types.ts` 148、`CompanionSprite.tsx` 370、`useBuddyNotification.tsx` 97),刚好对应这五个问题一一作答:

- `companion.ts` 管"骨"和"魂"的拆分与生成
- `types.ts` 管物种与稀有度的小词典
Expand Down Expand Up @@ -141,7 +141,7 @@ export type Species = (typeof SPECIES)[number];

同文件还有两张配套表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★`,渲染时直接拼在名字旁边。

读到这里你会想问:直接写 `'duck'` 不香吗,为什么非要拿 `String.fromCharCode` 一个个字符码拼?答案在仓库根目录的字符串扫描脚本里。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary`、`Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋,**绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸**。把名字写成字符码常量数组,编译期 TypeScript 不动它、运行期 V8 会把它拼起来、扫描器看到的只是一串数字字面量,完全认不出来。
读到这里你会想问:直接写 `'duck'` 不香吗,为什么非要拿 `String.fromCharCode` 一个个字符码拼?答案在仓库根目录的字符串扫描脚本里。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary`、`Sproink` 之类)就 fail。Buddy 是个只在内部构建(`USER_TYPE === 'ant'`)里完整开放的彩蛋,对外发行版要么完全 dead-code-eliminate 掉、要么仅在 2026 年 4 月之后的时间窗内激活,**绝大多数对外构建里它需要 DCE 干净到不剩字符串残骸**。把名字写成字符码常量数组,编译期 TypeScript 不动它、运行期 V8 会把它拼起来、扫描器看到的只是一串数字字面量,完全认不出来。

### 2.1 五档稀有度的累积权重

Expand Down Expand Up @@ -187,7 +187,7 @@ const RARITY_FLOOR: Record<Rarity, number> = {

`rollFrom(rng)` 在 `buddy/companion.ts:91-102` 把这一切串起来:先 `rollRarity` 决定稀有度档位,再从 `SPECIES`、`EYES` 各掷一个下标拿物种和眼神;帽子的有无完全由稀有度档位决定——`common` 永远是字符串 `'none'`,非 common 才在 `HATS` 里掷一个下标;接下来 `shiny = rng() < 0.01` 是一道独立的 1% 闪光判定;最后 `rollStats(rng, rarity)` 把五维填齐。

帽子这一档是 hard branch(硬分支),不是概率门。注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`——没有"18% 概率给戴帽子"这种事。`shiny` 1% 的独立概率,五维属性最后再掷一遍收尾。
帽子这一档是 hard branch(硬分支),不是概率门。注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`——没有"18% 概率给戴帽子"这种事。`shiny` 是一道**和稀有度无关**的独立 1% 概率:`buddy/companion.ts:98` 直接写 `shiny: rng() < 0.01`,common 到 legendary 五档都同一概率,没有"只有传说级才能闪光"这种额外门。五维属性最后再掷一遍收尾。

帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸。它在渲染时需要避开主体本身就有的纹理,所以帽子和物种像素画是要做空间互让的——这件事 §三 会接着看。

Expand Down Expand Up @@ -355,6 +355,8 @@ case 'companion_intro':

> Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load.

(译:用本地日期而不是 UTC——24 小时跨时区滚动波。让 Twitter 上的讨论像滚雪球一样持续,而不是在 UTC 午夜出现一个集中尖峰,对后端"灵魂生成"的负载也更温和。)"Twitter buzz" 在原注释里就是字面上的"推上讨论度"——把"发现彩蛋"这件事铺到 24 个时区里慢慢发酵,比所有人在同一秒挤上去更有传播效果。

用本地时区铺开 24 小时滚动波,能让东亚和美西错峰孵化,避开一个 UTC 午夜的集中尖峰。`isBuddyTeaserWindow` 决定"要不要弹那个发现公告":2026 年 4 月 1 日到 7 日(`getDate() <= 7`)这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用":2026 年 4 月以后一直能用。

两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。
Expand Down
12 changes: 6 additions & 6 deletions docs/30-Doctor屏与OutputStyle体验.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 第 30 章:screens/ 三屏 — Doctor、Output Style 与 ResumeConversation

> 本章是《深入 Claude Code 源码》系列对终端 UI 一族的最后一篇。前面几章把 Ink 怎么把 React 搬进终端、设计系统如何收敛颜色与边距、键盘事件如何注入 React 树都讲透了。这一篇换一个角度:当 CLI 真的出问题时,用户怎么自己看清"问题在哪里";当用户想换一种说话方式时,怎么用一份 markdown 把模型的开场白替换掉。
> 本章是《深入 Claude Code 源码》系列对终端 UI 一族的最后一篇。前面几章已经讲清楚三件事:Ink 怎么把 React 搬进终端、设计系统怎么收敛颜色与边距、键盘事件怎么注入 React 。这一篇换一个角度:当 CLI 真的出问题时,用户怎么自己看清"问题在哪里";当用户想换一种说话方式时,怎么用一份 markdown 把模型的开场白替换掉。

## 为什么把这三屏放在同一章?

Expand Down Expand Up @@ -42,10 +42,10 @@ const doctor: Command = {
3. **跑一遍 `checkContextWarnings()`**,把"CLAUDE.md 过大、agent 描述超 token 阈值、MCP 工具超 token 阈值、permission rule 被遮蔽"这四类警告一次性算出来。
4. **如果启用了 PID-based locking,跑一次 `cleanupStaleLocks` 并读出当前 `LockInfo[]`**——这是 native installer 的并发版本锁,Doctor 顺便替你扫尸。

值得停一下的是第 1 步里那条预热 `Promise`:
值得停一下的是第 1 步里那条预热 `Promise`。下面贴的不是源码本身,而是 **React Compiler 自动生成的 memoization 包装**——`$[2]` 是组件的 memo 缓存槽位(slot 2),`_temp6` 是被提到外部、跨次 render 复用的回调,整个 if 块的语义就是"如果这个槽位还没缓存,就调一次 `getDoctorDiagnostic()` 并把 promise 存进去"。原始代码作者只写了 `const distTagsPromise = useMemo(() => getDoctorDiagnostic().then(handler), [])`,编译器替它展开成下面这种显式槽位的形式

```typescript
// Doctor.tsx:124-131
// Doctor.tsx:124-131(React Compiler 输出形态)
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = getDoctorDiagnostic().then(_temp6);
Expand All @@ -54,7 +54,7 @@ if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
const distTagsPromise = t2;
```

`_temp6` 是 React Compiler 编译出来的稳定回调,做的事是根据 `diag.installationType` 决定调 `getGcsDistTags`(native 装法走 GCS)还是 `getNpmDistTags`(其余走 npm registry),失败时降级成 `{ latest: null, stable: null }`。这个 promise 在外层被 `<Suspense fallback={null}><DistTagsDisplay promise={distTagsPromise} /></Suspense>` 包住,走的是 React 18 的 `use(promise)` 通道。主屏不会因为它阻塞,但一旦它落地,"Latest version: ..." / "Stable version: ..." 两行就会无感地插进版面里。Doctor 把"诊断本体"和"对版本号"做了一次轻量的并发拆分,这是它写得最讨喜的细节之一。
`_temp6` 这个被提升出去的回调做的事是根据 `diag.installationType` 决定调 `getGcsDistTags`(native 装法走 GCS)还是 `getNpmDistTags`(其余走 npm registry),失败时降级成 `{ latest: null, stable: null }`。这个 promise 在外层被 `<Suspense fallback={null}><DistTagsDisplay promise={distTagsPromise} /></Suspense>` 包住,走的是 React 18 的 `use(promise)` 通道。主屏不会因为它阻塞,但一旦它落地,"Latest version: ..." / "Stable version: ..." 两行就会无感地插进版面里。Doctor 把"诊断本体"和"对版本号"做了一次轻量的并发拆分,这是它写得最讨喜的细节之一。

### 1.3 `getDoctorDiagnostic()`:把"我是怎么被装上来的"翻一遍

Expand All @@ -66,7 +66,7 @@ const distTagsPromise = t2;

**第三段是配置警告**——`detectConfigurationIssues()`(`doctorDiagnostic.ts:317-485`)。这一段是为"装好了但用不上"准备的:native 装了但 `~/.local/bin` 不在 PATH 里,于是根据用户的 shell 类型给出该改哪个 rc 文件的具体命令;npm-local 装了但 PATH 里既找不到 `claude` 也没有有效 alias;npm-global 装了但同时存在一个 local 安装;npm-global 没有写权限,提示用户要么重装 node 不用 sudo、要么换 native installer。最特别的是开头那段对 `managed-settings.json` 的 `strictPluginOnlyCustomization` 字段校验——managed settings 的 schema 用 `.catch(undefined)` 兜了一手未来才会有的枚举值,但 Doctor 不能让管理员对此一无所知,所以它直接读 raw JSON 自己做一遍差分,把"你写了 N 个我不认识的 surface 名"显式列出来。

**第四段是 ripgrep 状态与 Linux glob warning**。ripgrep 在 Claude Code 里既可能是 system 路径上的,也可能是 vendor 进来的,还可能是 bundled 进二进制里的,Doctor 把这三种模式各自展示成 `bundled` / `vendor` / `system path` 三种字面量。Linux 上 sandbox 的 glob pattern 支持不全,这一块也会被翻译成一条用户能看懂的 fix 建议。
**第四段是 ripgrep 状态与 Linux glob warning**。ripgrep 在 Claude Code 里有三种来源:和二进制一起打包的(bundled)、随安装包附带在邻近 vendor 目录里的(vendor),或是从系统 PATH 上找到的(system path);这三种来源对应 `ripgrepStatus` 字段的三种字面量取值,下面字段表里写的"ripgrep 三态"指的就是这三种。Linux 上 sandbox 的 glob pattern 支持不全,这一块也会被翻译成一条用户能看懂的 fix 建议。

把这四段事实揉合后,最后那个 `DiagnosticInfo` 对象(`doctorDiagnostic.ts:54-71`)就是 Doctor 主屏要消费的全部数据:

Expand Down Expand Up @@ -155,7 +155,7 @@ You are an interactive agent that helps users ${outputStyleConfig !== null
: 'with software engineering tasks.'}
```

这就是 Output Style 的真正作用域——**它换的是模型的"人格开场白"以及"具体回话风格",但不会替你换掉工具列表、不会换掉权限提示词、不会换掉 BashTool 的安全规约**。后面这些都在 prompts.ts 里另外几段被无条件拼上。Output Style 是一个**风格层的旁路**:它让用户能换 tone,但不让用户能扩权。
这就是 Output Style 的真正作用域——**它换的是模型的"人格开场白"以及"具体回话风格",但不会替你换掉工具列表、不会换掉权限提示词、不会换掉 BashTool 的安全规约**。后面这些都在 prompts.ts 里另外几段被无条件拼上。Output Style 是一个**风格层的旁路**:它让用户能换"说话语气",但不让用户能扩权。

`getSimpleIntroSection(outputStyleConfig)` 和那个 `outputStyleConfig === null || outputStyleConfig.keepCodingInstructions === true` 的分支(`constants/prompts.ts:564-566`)补上了第二个细节。`default` 这一档在 `constants/outputStyles.ts:42` 直接被映射成 `null`,靠 `=== null` 的左半边保留 coding instructions;内置 Explanatory 和 Learning 不是 `null`,而是分别在 `constants/outputStyles.ts:48` 与 `:61` 标了 `keepCodingInstructions: true`,靠右半边保留;只有用户自己写的、且没开这个开关的 output style,coding 指令才会被整段拿掉。这一刀切得很谨慎——默认情况下用户换风格不会丢失 Claude Code 作为 coding agent 的硬约束。

Expand Down
Loading
Loading