本章总览
src/components/ 是 Claude Code 终端 UI 的 React 组件层(约 400 文件),与 screens/REPL.tsx 共同构成用户可见的交互界面。本模块按子主题拆分:REPL 编排壳、消息渲染、权限弹窗、底部输入框。每篇独立成文,含真实源码引用与 SourceTree 导航。
总览图
学完本章你应该能
- 区分 components/ 与 screens/ 的职责边界
- 建立「REPL 编排 → Messages 列表 → 单条 Message 组件」的渲染链路心智模型
- 会从子章节进入 REPL、messages、permissions、PromptInput 深度讲解
- 理解权限 UI 与 useCanUseTool 的接缝位置
建议学习步骤
- 浏览下方源码目录树,点击文件名跳转到对应子章节
- 先读 REPL 子章节,理解状态机与 overlay 焦点
- 再读 message-components,对照 Messages.tsx 的 MessageRow
- 最后读 permissions-ui 与 prompt-input,串起 tool_use 全链路
模块在架构中的位置
components 是 src/ 下的一级目录,共 406 个文件、82,023 行。一句话概括:React + Ink 渲染的 TUI:消息流、权限弹窗、Spinner、任务面板、MCP 配置界面。
概览
| 指标 | 数值 |
|---|---|
| 行数 | 82,023 |
| 文件 | 406 |
子章节导航
| 子章节 | 主题 | 核心路径 |
|---|---|---|
| REPL 主屏 | 会话编排、权限队列、布局 | screens/REPL.tsx |
| 消息组件 | transcript 行渲染 | components/messages/ |
| 权限 UI | tool_use 确认弹窗 | permissions/PermissionRequest.tsx |
| PromptInput | 底部输入与提交 | PromptInput/PromptInput.tsx |
UI 壳三层分工
Claude Code 终端 UI 可按「壳层」理解,而不是按文件夹平铺。最外层是 screens/REPL.tsx:它持有 session 状态、多个确认队列,并决定 FullscreenLayout 的 scrollable / bottom / overlay / modal 四个槽位。中间层是编排组件:Messages.tsx 负责列表、虚拟滚动与 brief 过滤;PermissionRequest.tsx 只做 tool → 子组件路由;PromptInput/ 负责输入与 footer。最内层是叶子:messages/* 按 message type 渲染单行,*PermissionRequest/ 展示具体 Allow/Deny UI。
REPL.tsx
├─ Messages + VirtualMessageList → components/messages/*
├─ PermissionRequest overlay → components/permissions/*
└─ PromptInput (bottom slot) → components/PromptInput/*
读源码时先问「问题在哪一层」:卡顿在 REPL 状态、列表策略,还是某条消息的 Ink 输出?这一分层也解释为何 REPL 很长但仍可维护,业务 JSX 被下沉到 components,通用壳又被抽到 design-system。
components 与 screens 的分工
screens/ 目录目前以 REPL 为主入口(约 5000 行),承担:
- 会话生命周期:query 循环、loading、abort、turn 完成回调
- 全局状态订阅:
useAppState、MCP、agent、teammate 视图 - 多队列协调:
toolUseConfirmQueue、promptQueue、sandbox 权限、elicitation - 布局骨架:
FullscreenLayout、transcript 模式、companion 精灵位
components/ 则是可复用的展示与交互单元:
Messages.tsx/MessageRow.tsx:消息列表与单行分发messages/:按 message type 拆分的叶子组件(UserPrompt、AssistantToolUse 等)permissions/:按 Tool 类型拆分的权限对话框PromptInput/:输入框、footer、历史搜索、模式指示器
这种拆分使 REPL 保持「导演」角色,避免在单文件内堆积所有 JSX。新增一种消息类型时,通常改 MessageRow 分发 + messages/ 新文件,而非改 REPL。
与 hooks 模块的接缝
REPL 通过 hooks 完成横切逻辑,组件层消费其结果:
| 接缝 | Hook / 工具 | 组件消费方 |
|---|---|---|
| 权限决策 | useCanUseTool | PermissionRequest |
| 输入历史 | useArrowKeyHistory | PromptInput |
| 终端尺寸 | useTerminalSize | 多数 layout 组件 |
| 全局快捷键 | useGlobalKeybindings | REPL 包裹的 Handler |
阅读 components 专题时,建议对照 hooks 模块 中的 useCanUseTool 章节,理解 Promise 权限队列如何变成 toolUseConfirmQueue 状态。
design-system 子域深度拆解(实现方式)
components/design-system/ 是终端 UI 的原子层,当前约 15 个 TSX 文件,承载「布局壳 + 提示文案 + 主题 token」三类基础能力。该子域不是业务组件仓库,而是把重复 UI 语义抽为低耦合组件,供 permissions、PromptInput、/config、/help 等多处复用。
核心实现模式:
Dialog = 交互壳(键位 + 标题 + 输入引导)
- 内部统一绑定
confirm:no(Esc)与 Ctrl+C/D 二次确认 isCancelActive支持嵌套 TextInput 临时接管键盘hideBorder允许挂在 Pane 内,避免双边框
- 内部统一绑定
Pane = 结构壳(分隔线 + 横向内边距)
- 默认渲染顶部 Divider + 内容区 padding
- 在 FullscreenLayout modal 槽中探测
useIsInsideModal(),切到单层容器模式 - 注释明确记录了 flexShrink=0 修复(避免 /permissions 在方向键重渲染时空白)
Divider / Byline / KeyboardShortcutHint = 文本微组件
- Divider 按终端宽度与 ANSI 文本宽度计算左右线段,保持标题居中
- Byline 专门做 metadata 串联(
·分隔),自动过滤空子元素 - KeyboardShortcutHint 把快捷键文案标准化,避免各页面拼接字符串
为什么这一层重要:
- 把复杂键盘行为从业务页抽离,降低 REPL/Permission 组件体积
- 统一「确认/取消/帮助提示」语气和视觉结构,减少 UX 漂移
- 为 React Compiler 产物提供稳定边界:业务组件只组合 design-system,不重复实现细节
源码引用: src/components/design-system/Dialog.tsx · 第 34–99 行(共 101 行)
34| export function Dialog({
35| title,
36| subtitle,
37| children,
38| onCancel,
39| color = 'permission',
40| hideInputGuide,
41| hideBorder,
42| inputGuide,
43| isCancelActive = true,
44| }: DialogProps): React.ReactNode {
45| const exitState = useExitOnCtrlCDWithKeybindings(
46| undefined,
47| undefined,
48| isCancelActive,
49| )
50|
51| // Use configurable keybinding for ESC to cancel.
52| // isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
53| // an embedded TextInput is focused, so that keys like 'n' reach the field
54| // instead of being consumed here.
55| useKeybinding('confirm:no', onCancel, {
56| context: 'Confirmation',
57| isActive: isCancelActive,
58| })
59|
60| const defaultInputGuide = exitState.pending ? (
61| <Text>Press {exitState.keyName} again to exit</Text>
62| ) : (
63| <Byline>
64| <KeyboardShortcutHint shortcut="Enter" action="confirm" />
65| <ConfigurableShortcutHint
66| action="confirm:no"
67| context="Confirmation"
68| fallback="Esc"
69| description="cancel"
70| />
71| </Byline>
72| )
73|
74| const content = (
75| <>
76| <Box flexDirection="column" gap={1}>
77| <Box flexDirection="column">
78| <Text bold color={color}>
79| {title}
80| </Text>
81| {subtitle && <Text dimColor>{subtitle}</Text>}
82| </Box>
83| {children}
84| </Box>
85| {!hideInputGuide && (
86| <Box marginTop={1}>
87| <Text dimColor italic>
88| {inputGuide ? inputGuide(exitState) : defaultInputGuide}
89| </Text>
90| </Box>
91| )}
92| </>
93| )
94|
95| if (hideBorder) {
96| return content
97| }
98|
99| return <Pane color={color}>{content}</Pane>
源码引用: src/components/design-system/Pane.tsx · 第 33–56 行(共 58 行)
33| export function Pane({ children, color }: PaneProps): React.ReactNode {
34| // When rendered inside FullscreenLayout's modal slot, its ▔ divider IS
35| // the frame. Skip our own Divider (would double-frame) and the extra top
36| // padding. This lets slash-command screens that wrap in Pane (e.g.
37| // /model → ModelPicker) route through the modal slot unchanged.
38| if (useIsInsideModal()) {
39| // flexShrink=0: the modal slot's absolute Box has no explicit height
40| // (grows to fit, maxHeight cap). With flexGrow=1, re-renders cause
41| // yoga to resolve this Box's height to 0 against the undetermined
42| // parent — /permissions body blanks on Down arrow. See #23592.
43| return (
44| <Box flexDirection="column" paddingX={1} flexShrink={0}>
45| {children}
46| </Box>
47| )
48| }
49| return (
50| <Box flexDirection="column" paddingTop={1}>
51| <Divider color={color} />
52| <Box flexDirection="column" paddingX={2}>
53| {children}
54| </Box>
55| </Box>
56| )
源码引用: src/components/design-system/Divider.tsx · 第 66–97 行(共 98 行)
66| export function Divider({
67| width,
68| color,
69| char = '─',
70| padding = 0,
71| title,
72| }: DividerProps): React.ReactNode {
73| const { columns: terminalWidth } = useTerminalSize()
74| const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
75|
76| if (title) {
77| const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
78| const sideWidth = Math.max(0, effectiveWidth - titleWidth)
79| const leftWidth = Math.floor(sideWidth / 2)
80| const rightWidth = sideWidth - leftWidth
81| return (
82| <Text color={color} dimColor={!color}>
83| {char.repeat(leftWidth)}{' '}
84| <Text dimColor>
85| <Ansi>{title}</Ansi>
86| </Text>{' '}
87| {char.repeat(rightWidth)}
88| </Text>
89| )
90| }
91|
92| return (
93| <Text color={color} dimColor={!color}>
94| {char.repeat(effectiveWidth)}
95| </Text>
96| )
97| }
源码引用: src/components/design-system/Byline.tsx · 第 37–58 行(共 58 行)
37| export function Byline({ children }: Props): React.ReactNode {
38| // Children.toArray already filters out null, undefined, and booleans
39| const validChildren = Children.toArray(children)
40|
41| if (validChildren.length === 0) {
42| return null
43| }
44|
45| return (
46| <>
47| {validChildren.map((child, index) => (
48| <React.Fragment
49| key={isValidElement(child) ? (child.key ?? index) : index}
50| >
51| {index > 0 && <Text dimColor> · </Text>}
52| {child}
53| </React.Fragment>
54| ))}
55| </>
56| )
57| }
58|
源码引用: src/components/design-system/KeyboardShortcutHint.tsx · 第 38–59 行(共 59 行)
38| export function KeyboardShortcutHint({
39| shortcut,
40| action,
41| parens = false,
42| bold = false,
43| }: Props): React.ReactNode {
44| const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
45|
46| if (parens) {
47| return (
48| <Text>
49| ({shortcutText} to {action})
50| </Text>
51| )
52| }
53| return (
54| <Text>
55| {shortcutText} to {action}
56| </Text>
57| )
58| }
59|
读反编译 UI 的 React Compiler 规则
反编译后的 TSX 常出现 $、_c,或源码注释里的 memoCache、Compiler bailout。教学上记三条即可:
- memoCache:Compiler 会把 props 钉在 fiber 缓存里,因此 Messages 刻意不把整段
renderableMessages传给每行 MessageRow,否则多轮对话会累积历史版本。 - bailout:组件内有 Compiler 无法证明安全的模式时,不会自动 memo,需要看源码里的手动
useMemo/React.memo。 - 性能注释优先读:例如 BashPermissionRequest 把 shimmer 抽成
ClassifierCheckingSubtitle,比纠结$符号更有价值。
后续读 REPL、Messages、permissions-ui 三章时,遇到 Compiler 产物不要跳过,也不要把它当业务变量;先找源码旁边的性能注释和组件拆分意图。
目录树同时包含 screens/REPL.tsx(编排入口)与 components/ 子树。点击文件将跳转到对应子章节或内联源码锚点。
本章小结与延伸
components 是 Ink 终端 UI 的主体。建议阅读顺序:REPL → 消息组件 → 权限 UI → PromptInput。 继续学习: