Compare commits

...

49 Commits

Author SHA1 Message Date
claude-code-best
5895362178 chore: 2.2.0 2026-05-07 08:29:07 +08:00
claude-code-best
8cfe9b6dc3 feat: 启用 COORDINATOR_MODE feature flag
AgentSummary 30s fork 循环的内存泄露已在 commit 52b61c2c 修复(闭包
引用丢弃 + 上下文重建 + 消息/字符上限),重新启用该 feature。

用户可通过 /coordinator 命令或 CLAUDE_CODE_COORDINATOR_MODE=1 环境变量
激活多 worker 编排模式。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 08:28:28 +08:00
claude-code-best
12f5aedf99 fix: 恢复消息流中 diff 高亮渲染功能
还原 commit 51b8ad46 删除的 diff highlight 显示:FileEdit/FileWrite 工具
执行成功后重新展示 StructuredDiffList,拒绝时重新展示高亮代码预览或
带上下文的 diff 视图。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 08:28:28 +08:00
claude-code-best
c7efac6b8d Merge pull request #423 from znygugeyx-ctrl/feat/statusline-refresh-interval
feat: 状态栏支持 refreshInterval 定时刷新
2026-05-06 23:53:36 +08:00
zny
2f150d3ecd feat: 状态栏支持 refreshInterval 定时刷新
- Zod schema 补齐 refreshInterval 字段
- 通过 scheduleUpdate 复用 300ms debounce,event/settings/time 三路触发单飞
- 新增 docs/features/status-line.mdx 调研文档
2026-05-06 22:50:11 +08:00
claude-code-best
68c7ebb242 Merge pull request #419 from suger-m/codex/sub-agents-docs
docs: expand sub-agent architecture guide
2026-05-06 20:30:42 +08:00
claude-code-best
9e299a7208 Merge pull request #420 from claude-code-best/fix/third-party-api-user-id
fix: third-party API user_id validation error (DeepSeek, etc.)
2026-05-06 20:30:12 +08:00
Simple6K
941bcbd240 fix: third-party API user_id validation error (DeepSeek, etc.)
When ANTHROPIC_BASE_URL points to a non-Anthropic endpoint (e.g.
DeepSeek), the JSON-formatted user_id containing {, ", : characters
fails validation against ^[a-zA-Z0-9_-]+$. Send only the hex device_id
for third-party providers.
2026-05-06 17:47:31 +08:00
suger-m
fd66ddc45f docs: expand sub-agent architecture guide 2026-05-06 17:26:49 +08:00
znygugeyx-ctrl
5c107e5f8c Merge pull request #416 from znygugeyx-ctrl/feat/subagent-fork-render
feat: 参考 claude code 官方实现,改进 sub agent 以及 fork agent 的渲染方式
2026-05-06 09:57:52 +08:00
claude-code-best
c4e9efb7a8 Merge pull request #417 from shaleloop/sync/mcp-transform-2.1.128
fix: align mcp transform pipeline with Anthropic Claude Code 2.1.128
2026-05-06 09:51:37 +08:00
shaleloop
26ddbda849 fix: align mcp transform pipeline with Anthropic Claude Code 2.1.128
Add ImageLimits type and plumb optional limits through the chain:
callMCPTool/callMCPToolWithUrlElicitationRetry -> processMCPResult ->
transformMCPResult -> transformResultContent -> maybeResizeAndDownsampleImageBuffer.
When provided, limits override the module-level defaults
(IMAGE_TARGET_RAW_SIZE, IMAGE_MAX_WIDTH, IMAGE_MAX_HEIGHT,
API_IMAGE_MAX_BASE64_SIZE) inside maybeResizeAndDownsampleImageBuffer.
When undefined, behavior is unchanged for current callers.

Add _meta preservation in the text-block case of transformResultContent
(only when the caller opts in via includeMeta=true). transformMCPResult
passes includeMeta=true on the tool-result path; the prompt-handler call
site keeps the default false, preserving prior behavior.

Add skipLargeOutput early-return in processMCPResult after the IDE check:
when the caller passes skipLargeOutput=true and the content has no images,
the function returns content directly without large-output handling.

Add unwrap-to-text in processMCPResult for the persisted-content path:
when the large-string format gate is enabled
(MCP_TRUNCATION_PROMPT_OVERRIDE env var, or
tengu_mcp_subagent_prompt Statsig gate), and the content is a single
bare text block (no annotations, no _meta), unwrap to raw text and
switch the format description to 'Plain text'. Default-off; gate-off
behavior is unchanged.

Verified structurally against the 2.1.128 binary: function signatures,
the IDE check, gate logic, _meta-unwrap pattern, and imageLimits
plumbing match this implementation.
2026-05-05 14:53:57 -07:00
claude-code-best
872ee280e3 chore: 2.1.0 2026-05-05 21:22:20 +08:00
claude-code-best
f5c9880d7d Merge pull request #413 from claude-code-best/performance/20260505/memory-leak-fix
feat: 继续进行内存泄露修复及垃圾代码清理
2026-05-05 21:21:22 +08:00
claude-code-best
3f1c8468bf fix: 调小 snapshots 的范围 2026-05-05 21:09:02 +08:00
claude-code-best
100e9d2da0 chore: 2.0.5 2026-05-05 20:16:44 +08:00
claude-code-best
0ad6349434 chore: 清理 18 处未使用导入、变量和函数
移除未使用的导入(getSubscriptionType、isEnvDefinedFalsy、
getClaudeConfigHomeDir 等)、未使用的常量(ACCENT_COLOR、
NAME_MATCH_BONUS、CLIPBOARD_THRESHOLD)和死函数
(getOpus41Option、pasteViaClipboard),
为未使用参数添加 _ 前缀。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:15:51 +08:00
claude-code-best
1ac18aec0d chore: 清理 4 处遗漏的未使用导入和参数
移除 autonomyStatus/betas/claudemd/executorCrossPlatform 中
未使用的导入和参数。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:05:50 +08:00
claude-code-best
fcbc882232 chore: 清理 src 下 113 项未使用导入和死代码
删除未使用的文件(BuiltinStatusLine.tsx、4 个重复的 .ts stub)、
移除约 55 个文件中未使用的 React 导入、
清理约 50 处未使用的导入/变量/参数。
净减少 ~296 行代码,precheck 4077 测试全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:05:15 +08:00
claude-code-best
a1108870e3 Merge pull request #412 from claude-code-best/feature/20260504/improve
feat: 内存碎片及死代码清理
2026-05-05 19:48:12 +08:00
claude-code-best
87b96199f9 feat: ai 的随机修复 2026-05-05 19:36:38 +08:00
claude-code-best
18d6656a6a feat: 尝试改进 Error 处理以提升内存管理效率 2026-05-05 18:18:13 +08:00
claude-code-best
d0915fc880 chore: 清理 src 下 33 项死代码和类型断言
删除未使用的文件/目录(mcp/adapter、cli/update.ts 等)、
未使用的重导出文件(design-system/color.ts 等 12 个)、
7 个零引用的导出函数、修复 5 处 as any 为精确类型。
净减少 ~1194 行代码,precheck 4077 测试全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 16:07:30 +08:00
claude-code-best
cf2bf29dcd feat: 尝试深度拷贝数据以分离引用 2026-05-05 12:41:01 +08:00
claude-code-best
75952bde9c fix: 尝试请求参数克隆以解除闭包引用 2026-05-05 09:29:09 +08:00
claude-code-best
e7220c530f fix: eliminate memory leak in promptCacheBreakDetection by replacing closures with pre-computed strings
The buildDiffableContent and buildPrevDiffableContent fields were closures
capturing full system prompt and tool schema arrays (~300KB each). With 10
map entries × 2 closures, this held ~6MB of GC-unreachable memory.

Since recordPromptState already serializes the same data for hashing,
pre-computing the diffable content string has negligible marginal cost.
2026-05-05 08:54:04 +08:00
claude-code-best
6ff839d625 fix: 优化压缩错误消息和自动压缩提示的可理解性
- "Not enough messages" 添加 "Send a few more messages first" 引导
- "Conversation too long" 提示从模糊的 "Press esc twice" 改为建议 /compact 或 /clear
- 自动压缩标题从 "Compact summary" 改为 "Conversation summarized to free up context"
- 快捷键提示从 "expand" 改为 "view summary"
- 新增 7 个测试覆盖压缩相关消息

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 07:19:19 +08:00
claude-code-best
88057b10d4 fix: 优化 ModelPicker 副标题和 resume 错误提示的可操作性
- ModelPicker 副标题从技术说明改为操作提示(effort 调整、1M 切换)
- /resume 错误提示添加 "Run /resume to browse" 操作引导
- 新增 6 个测试覆盖模型选择器、会话恢复和 cost 消息文案

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 02:14:07 +08:00
claude-code-best
4d0048a60a fix: 优化权限提示用词和 Help 页面新手引导
- Help General 页添加 3 步 Getting started 引导,替代单段描述
- 权限对话框底部 "Esc to cancel" → "Esc to reject","Tab to amend" → "Tab to add feedback"
- .claude/ 文件夹权限选项标签从 60 字符缩至 49 字符,避免窄终端截断
- 新增 10 个测试覆盖权限提示文案和帮助页引导内容

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 00:09:27 +08:00
claude-code-best
8a5ef8c9cb fix: 优化用户交互文案,为错误消息添加可操作提示
- 为 budget/turns/structured-output 三种错误消息添加 Tip 提示,指导用户如何继续
- Onboarding 安全步骤标题从 "Security notes" 改为更友好的 "Before you start, keep in mind"
- Trust Dialog 精简为两句核心信息,降低认知负荷
- 新增 7 个测试验证消息内容包含关键引导信息

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 00:05:35 +08:00
claude-code-best
f8a289b868 fix: 尝试修复 OTEL 的问题 2026-05-04 23:53:28 +08:00
claude-code-best
45c892fc18 revert: 恢复 HISTORY_SNIP 2026-05-04 23:51:36 +08:00
claude-code-best
5b333e2246 refactor: 从 package.json 动态读取版本号,避免版本漂移
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:44:47 +08:00
claude-code-best
5e215bb061 chore: v2.0.4 2026-05-04 23:25:20 +08:00
claude-code-best
b28de717dd perf: 优化内存与遥测管理,启用 Vite minify
- 禁用 HISTORY_SNIP feature flag 并新增 proactiveTruncate 防止无 compact_boundary 时内存无限增长
- 跳过未启用 telemetry 时的 OTel 初始化,防止长会话 PerformanceMeasure 堆积
- OTel 导出遇 401/403 自动关闭 reader,防止 handle 泄漏
- Vite 构建启用 minify

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:23:25 +08:00
claude-code-best
5c1be19511 docs: update contributors 2026-05-04 08:44:14 +00:00
claude-code-best
2545dcabfd fix: ccb update 使用 bun install -g @latest 替代 bun update -g
bun update -g 只更新到 package.json 版本范围内的最新版,无法跨版本升级。
改为 bun install -g @latest 与 npm 侧行为一致,强制拉取最新发布版。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 19:28:16 +08:00
claude-code-best
40fbc4afc4 chore: 2.0.3 2026-05-03 17:33:14 +08:00
claude-code-best
d3eebfed15 build: Vite 单文件构建 + 修复 doubaoime-asr 打包后 WASM 加载失败
- vite.config.ts: codeSplitting: false 替代多 chunk 输出,产出单文件 dist/cli.js
- vite.config.ts: ssr.external 排除 doubaoime-asr/opus-encdec,避免 require.resolve 路径失效
- scripts/post-build.ts: 简化为直接处理单文件 dist/cli.js
- src/services/doubaoSTT.ts: 改进错误信息,输出具体异常内容便于排查

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:13:20 +08:00
claude-code-best
6becb8b2d4 fix: 修复 tasks.test.ts 类型错误与并发测试失败
- jsonStringify mock 参数类型改为 Parameters<typeof JSON.stringify>[1][] 消除 TS2769
- 并发测试改为顺序执行以适配 Bun 下 proper-lockfile 的 advisory lock 行为

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:24:01 +08:00
claude-code-best
3a2b6dde7c perf: 表格渲染效率升级 2026-05-03 10:10:47 +08:00
claude-code-best
4ca7a4895a test: 新增 tasks.ts CRUD 测试覆盖(37 个测试)及 code review 进度记录
审查任务管理 CRUD 模块:创建/读取/更新/删除、高水位标记、文件锁并发安全、
claimTask 竞态保护、resetTaskList、通知信号、并发创建唯一 ID 验证。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 03:29:06 +08:00
claude-code-best
ba74e0976c feat: fork-agent-redesign — 新增 AgentTool fork 参数与 spec 设计文档
为 AgentTool 引入 fork 布尔参数,支持子代理从父对话上下文中 fork 出独立分支,
继承完整历史、系统提示和模型配置。重构 inputSchema 条件逻辑以适配 fork 模式。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 23:39:43 +08:00
claude-code-best
86df024e75 fix: 修复模型的问题 2026-05-02 22:39:57 +08:00
claude-code-best
c3af45023d chore: v2.0.2 2026-05-02 20:37:46 +08:00
claude-code-best
2847cab787 docs: 压缩内存分析报告(720→120 行,保留全部可操作信息)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:37:14 +08:00
claude-code-best
198c09b263 fix: 内存优化 — 预测性 compact 阈值、增量 lookups orphaned 修复、deferred slice 引用优化
- P0: REPL.tsx 用 useMemo 包裹 deferred messages slice,避免每次渲染创建新数组引用导致不必要的后台重渲染
- P1: 预测性 compact 阈值改用 effectiveContextWindow - growth,消除与 autocompact buffer 的双重预留;TOOL_RESULT_GROWTH_ESTIMATE 从 20K 降至 15K
- P2: 增量 lookups 增加 lastAssistantMsgId 一致性检查和 orphaned server_tool_use/mcp_tool_use 扫描,防止 UI 永久 loading
- P3: reactiveCompact 类型断言改为直接使用 'compact' 字面量
- docs: CLAUDE.md 统一使用 precheck 替代分散的 typecheck/lint/test 命令

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:32:00 +08:00
claude-code-best
4cbf406c70 Merge pull request #403 from ymonster/fix/deepseek-empty-reasoning-content
fix: 保留 DeepSeek v4 thinking mode 的空 reasoning_content (#399)
2026-05-02 16:02:06 +08:00
ymonster
1b10ea391a fix: preserve empty reasoning_content for DeepSeek v4 thinking mode (#399)
DeepSeek v4 in thinking mode sometimes returns reasoning_content: ""
when the model answers directly without internal reasoning. Two places
were filtering the empty string out, which dropped the thinking block
from the assistant turn entirely. The next request then omitted
reasoning_content for that prior turn, and DeepSeek rejected with
400 "reasoning_content ... must be passed back to the API".

Fix:
- openaiStreamAdapter: open a thinking block whenever reasoning_content
  is present (including ""); skip the empty thinking_delta event since
  the empty value is already conveyed by the block's initial state.
- openaiConvertMessages: preserve empty thinking blocks as
  reasoning_content: "" when serializing assistant messages back to
  the OpenAI/DeepSeek format.

Tests:
- New: empty reasoning_content opens a thinking block (adapter).
- Updated: empty thinking blocks now round-trip as reasoning_content: ""
  instead of being dropped.
- New: assistant messages with no thinking block still omit
  reasoning_content (regression guard for non-thinking models).
2026-05-02 14:58:29 +08:00
241 changed files with 5049 additions and 2452 deletions

View File

@@ -1 +1 @@
bunx lint-staged
npx lint-staged

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) and other AI coding
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bun run precheck` 必须零错误通过**(包含 typecheck + lint fix + test
## Git Commit Message Convention
@@ -47,7 +47,7 @@ bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
# Lint & Format (Biome)
# Lint & Format (Biome) — 日常开发用 precheck 代替单独调用
bun run lint # lint check (全项目)
bun run lint:fix # auto-fix lint issues
bun run format # format all (全项目)
@@ -60,7 +60,7 @@ bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint fix + test) — run after completing any task
# Full check (typecheck + lint fix + test) — 任务完成后必须运行
bun run precheck
# Remote Control Server
@@ -311,7 +311,7 @@ mock.module("src/utils/debug.ts", debugMock);
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bun run typecheck
bun run precheck
```
**类型规范**
@@ -324,14 +324,14 @@ bun run typecheck
## Working with This Codebase
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
- **precheck must pass** — `bun run precheck`typecheck + lint fix + test必须零错误,任何修改都不能引入新的类型/lint/测试错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
- **Biome 配置** — 42 条 lint 规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/``scripts/``packages/`,含 `packages/@ant/`)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐2-space 缩进)。修改任何代码后应运行 `bun run check` 确认无 lint/格式问题pre-commit hook 会自动拦截不合格提交。
- **Biome 配置** — 42 条 lint 规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/``scripts/``packages/`,含 `packages/@ant/`)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐2-space 缩进)。修改任何代码后应运行 `bun run precheck` 确认无类型/lint/格式/测试问题pre-commit hook 会自动拦截不合格提交。
- **tsc 与 Biome 冲突处理** — 当 tsc 要求声明属性(赋值使用)但 biome 报 `noUnusedPrivateClassMembers`(只写不读)时,用 `// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>` 抑制 lint 警告,保留类型声明。`biome ci` 必须零 warnings。
- **`@ts-expect-error` 维护** — 只在下方代码确实有类型错误时保留 `@ts-expect-error`。如果类型系统已更新导致 directive 变为 unusedTS2578直接移除注释。MACRO 替换产生的永假比较(如 `'production' === 'development'`)仍需保留 `@ts-expect-error`
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。

View File

@@ -21,7 +21,13 @@ const result = await Bun.build({
outdir,
target: 'bun',
splitting: true,
define: getMacroDefines(),
define: {
...getMacroDefines(),
// React production mode — eliminates _debugStack Error objects
// (6,889 objects × ~1.7KB = 12MB in development builds) and removes
// prop-type / key warnings not useful in a production CLI tool.
'process.env.NODE_ENV': JSON.stringify('production'),
},
features,
})

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
# Background Agent Selector — 底部统一后台 Agent 切换器
> Feature Flag: 无(直接启用)
> 实现状态:完整可用
> 依赖:`viewingAgentTaskId` / `enterTeammateView` / `exitTeammateView` 已有机制
## 一、功能概述
Background Agent Selector 是渲染在 PromptInput 下方的常驻状态条,列出当前所有 **backgrounded 的 local_agent 任务**(包括 `/fork` 派生的 fork agent 和 Task/AgentTool 调用 `run_in_background: true` 派生的子 agent。用户可以用 ↑/↓ 方向键在 `main` 和各 agent 之间切换焦点,按 Enter 把 REPL 主视图替换为所选 agent 的实时 transcript再按 Enter 选中 `main` 即可回到主对话。
整个机制完全复用官方已有的 teammate transcript 查看基础设施,不引入新的视图层 / 数据流,仅新增一条 footer pill 类型。
### 核心特性
- **统一入口**`/fork`、Task 派生的 subagent、所有 `run_in_background: true` 的 agent 都在同一栏显示
- **就地切换**prompt 为空时按 ↓ 溢出进入底部 selector↑↓ 选中某行Enter 即切主视图
- **实时状态**:每行显示 agent 类型 + 描述 + 运行时长 + 已消耗 tokenrunning 时圆点为绿色
- **Keep-alive 视图**agent 完成后在 `evictAfter` grace 窗口内保留一段时间,用户可回看
- **零界面侵入**tasks 数为 0 时 selector 完全不渲染,不占屏幕高度
- **与旧 Dialog 共存**Shift+↓ 打开的 `BackgroundTasksDialog` 原有行为保留selector 只作为展示 + 快捷切换
## 二、用户交互
### 触发方式
有任何 background agent 时selector 自动出现在 `bypass permissions on` 行下方:
```
claude-code | Opus 4.7 (1M context) | ctx:4%
▶▶ bypass permissions on (shift+tab to cycle)
○ main ↑/↓ to select · Enter to view
● Explore Research src/hooks 23s · ↓ 10.9k tokens
○ Explore Research src/components 22s · ↓ 9.5k tokens
○ Explore Research src/utils 21s · ↓ 13.6k tokens
```
### 键盘路由
| 位置 / 状态 | 按键 | 行为 |
|---|---|---|
| PromptInput 非空 | ↑↓ | 光标移动 / 翻历史(不变) |
| PromptInput 空 + 历史底部 | ↓ | 焦点下放到 selector高亮到 `● main` |
| Selector 聚焦(`footerSelection === 'bg_agent'` | ↓ | 高亮下移,-1 → 0 → ... → N-1 |
| Selector 聚焦 | ↑ | 高亮上移;在 `main` 再 ↑ → 焦点回 PromptInput |
| Selector 聚焦 | Enter | `-1``exitTeammateView``>=0``enterTeammateView(agentId)`。焦点保留在 pill |
| Selector 聚焦 | Esc | `footer:clearSelection`,焦点回 PromptInput |
### 视觉规则
- `● main` / `● <agent>`:当前被**查看**viewingAgentTaskId 指向)或被**光标聚焦**pill focused 时以光标为准)的一行
- running 状态的 agent圆点渲染为 `success` 色(绿色),与 `BackgroundTasksDialog` 状态语义对齐
- 右上角 hint 随状态变化:
- pill 聚焦:`↑/↓ to select · Enter to view`
- 已选中 running agent`shift+↓ to manage · x to stop`
- 已选中 terminal agent`shift+↓ to manage · x to clear`
- 未选中任何 agent`shift+↓ to manage background agents`
## 三、实现架构
### 3.1 数据层:`useBackgroundAgentTasks`
文件:`src/hooks/useBackgroundAgentTasks.ts`
封装对 `useAppState(s => s.tasks)` 的过滤:
```ts
export function useBackgroundAgentTasks(): LocalAgentTaskState[] {
const tasks = useAppState(s => s.tasks)
return useMemo(() => {
const now = Date.now()
return Object.values(tasks)
.filter(isLocalAgentTask)
.filter(t => t.agentType !== 'main-session')
.filter(t => t.isBackgrounded !== false)
.filter(t => t.evictAfter === undefined || t.evictAfter > now)
.sort((a, b) => a.startTime - b.startTime)
}, [tasks])
}
```
`/fork``AgentTool``run_in_background: true` 底层都走 `registerAsyncAgent → runAsyncAgentLifecycle`,最终写入同一个 `appState.tasks` Map此 hook 是唯一数据源Selector 和 PromptInput 的 `bgAgentList` 都消费它。
### 3.2 状态层:新增两个字段
文件:`src/state/AppStateStore.ts`
```ts
export type FooterItem =
| 'tasks' | 'tmux' | 'bagel' | 'teams' | 'bridge' | 'companion'
| 'bg_agent' // ← 新增
export type AppState = DeepImmutable<{
// ...
selectedBgAgentIndex: number // -1 = main, 0..N-1 = 选中的 agent
}>
```
- `'bg_agent'` 作为 `FooterItem` 加入 footer pill 体系,享受既有的 `footer:up` / `footer:down` / `footer:openSelected` keybinding 路由
- `selectedBgAgentIndex` 记录 selector 的光标位置,与 `viewingAgentTaskId`"正在看什么")独立;它不可从 `viewingAgentTaskId` 派生——Enter 后光标留在 pill 继续导航,查看目标才变
### 3.3 键盘路由PromptInput footer pill 分支
文件:`src/components/PromptInput/PromptInput.tsx`
1. **`bg_agent` 进入 footerItems[0]**:保证 prompt ↓ 溢出时(`handleHistoryDown``selectFooterItem(footerItems[0])`)直接进入 selector而不是 `tasks` 等其他 pill
2. **`footer:up` 分支**`bgAgentSelected``selectedBgAgentIndex > -1` 则递减;在 -1 → `selectFooterItem(null)` 退出 pill
3. **`footer:down` 分支**`selectedBgAgentIndex < bgAgentList.length - 1` 则递增,到底 clamp
4. **`footer:openSelected` 分支**index === -1 → `exitTeammateView`;否则 `enterTeammateView(bgAgentList[i].agentId)`。**不清理 pill 焦点**,光标留在 selector 上继续导航
5. **`selectFooterItem('bg_agent')`**:入 pill 时重置 `selectedBgAgentIndex = -1`(光标落到 `main`
### 3.4 渲染层:`BackgroundAgentSelector`
文件:`src/components/tasks/BackgroundAgentSelector.tsx`
纯展示组件,不订阅键盘:
```tsx
const tasks = useBackgroundAgentTasks()
const viewingId = useAppState(s => s.viewingAgentTaskId)
const footerSelection = useAppState(s => s.footerSelection)
const selectedBgIndex = useAppState(s => s.selectedBgAgentIndex)
if (tasks.length === 0) return null
const pillFocused = footerSelection === 'bg_agent'
const highlightedId = pillFocused
? (selectedBgIndex === -1 ? null : tasks[selectedBgIndex]?.agentId ?? null)
: (viewingId ?? null)
```
**高亮派生规则**pill 聚焦 → 跟 `selectedBgAgentIndex`;未聚焦 → 镜像 `viewingAgentTaskId`。这样当用户通过 Shift+↓ Dialog 或 `enterTeammateView` 其它途径切换视图时selector 也会正确反映。
### 3.5 主视图切换:复用 `viewingAgentTaskId`
REPL.tsx 主体仍复用原有查看逻辑:
```ts
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
const viewedAgentTask = ... (isLocalAgentTask(viewedTask) ? viewedTask : undefined)
const displayedMessages = viewedAgentTask ? displayedAgentMessages : messages
```
`enterTeammateView(agentId)``viewingAgentTaskId` 设成某个 local_agent 的 id
- `viewedAgentTask` 解析成该 agent
- `displayedMessages` 切换到 agent 的 messages
- 消息列表、spinner、unseen divider 等一整套组件自动用 agent transcript 重渲染
- 主对话流被"暂停"(并非销毁,回到 `main` 时仍在原处)
`enterTeammateView` 同步负责:设 `retain: true` 阻止 eviction、清 `evictAfter`、触发 disk bootstrap 从 `agent-<id>.jsonl` 加载完整 transcript 到 `task.messages`
#### Fork agent prompt 归一化
`/fork` agent 的 transcript 和普通 subagent 不同:它继承 main agent 的上下文,真实初始消息形态是:
```text
...parent messages
assistant([...tool_use])
user([tool_result..., text("<fork-boilerplate>...Your directive: <prompt>")])
...fork live messages
```
这里的 prompt 文本混在 `[tool_result..., text]` 多 block user message 里。消息渲染管线会优先把这条 user message 当作 tool-result plumbing 来处理,导致 `<fork-boilerplate>` 里的用户 prompt 不稳定可见。为保证切换到 fork agent 时总能看到用户发起的 fork promptREPL.tsx 对 fork 视图做一次展示层归一化:
1. 仅当 `viewedAgentTask.agentType === 'fork'` 时启用,不影响普通 Explore / Task subagent。
2. 从原始 messages 中识别包含 `<fork-boilerplate>` 的 carrier message。
3. 剥离 carrier message 里的 boilerplate text block但保留 `tool_result` blocks避免破坏父 assistant `tool_use` 的承接关系。
4. 强制插入一条独立 `createUserMessage({ content: viewedAgentTask.prompt })` 作为可见用户 prompt。
5. 插入位置优先为 boilerplate carrier 后;如果 sidechain bootstrap 还没读到 carrier则插到最后一条 inherited `assistant tool_use` 后面,确保 prompt 接在 main 上下文之后,而不是跑到视图顶部。
这个归一化只影响 UI 展示用的 `displayedAgentMessages`,不回写 `task.messages`,也不改变发送给模型的 fork transcript。
### 3.6 生命周期
完全复用官方既有机制:
- **运行中**`isBackgroundTask()` 谓词为真selector 列出
- **完成 / 失败 / 中止**`completeAgentTask` / `failAgentTask` / `killAsyncAgent``status` 为 terminal
- **回访后退出**`exitTeammateView``release(task)`——清 `retain`、清 `messages`、terminal 状态下设 `evictAfter = now + PANEL_GRACE_MS (30s)`
- **evictAfter 过期**`useBackgroundAgentTasks` 过滤时自然剔除selector 行消失
- **手动清除**`stopOrDismissAgent(taskId)``evictAfter = 0`,立即消失
## 四、设计决策
1. **数据源单一**`useBackgroundAgentTasks` 是唯一过滤点PromptInput 也复用,避免过滤条件散落
2. **pill 聚焦保留**Enter 切视图后不松焦,让 ↑↓ 连续导航,贴近官方体验
3. **`bg_agent` 放 footerItems[0]**:确保 ↓ 溢出直接进入 selector 而非其它 pill
4. **selector 不订阅键盘**:所有按键路由集中在 PromptInput 的 `footer:*` 分支,避免 selector 组件和 PromptInput 双重 `useInput` 的冲突
5. **`selectedBgAgentIndex` 存 AppState 而非局部 state**selector 和 PromptInput 分别在两棵不同子树,需要全局字段协调;该值不能从 `viewingAgentTaskId` 派生
6. **与 `BackgroundTasksDialog` 共存**Shift+↓ 行为完全不变selector 是补充快捷入口Dialog 仍管 shell / workflow / monitor_mcp 等 selector 不显示的 task 类型
7. **fork prompt 展示层兜底**fork prompt 不依赖 boilerplate 自身渲染,统一在 `displayedAgentMessages` 中合成独立用户消息;普通 subagent 不走该分支,避免 prompt 重复
## 五、关键 API 复用
| 官方已有能力 | selector 如何使用 |
|---|---|
| `AppState.tasks` | 单一数据源,无需 file watcher / output JSONL 订阅 |
| `registerAsyncAgent` | `/fork` 和 AgentTool 共用selector 不区分来源 |
| `enterTeammateView(id)` | Enter 时调用,负责 retain + disk bootstrap |
| `exitTeammateView` | Enter 选中 `main` 时调用 |
| `release(task)` + `PANEL_GRACE_MS` | 30s keep-aliveselector 自动生效 |
| `useElapsedTime` | 每行时长显示,非 running 自动停 interval |
| `formatTokens` (`utils/format.ts`) | token 数 1k 缩写 |
| `footer:up` / `footer:down` / `footer:openSelected` keybinding | 键盘路由复用 Footer context |
## 六、文件索引
| 文件 | 职责 |
|------|------|
| `src/hooks/useBackgroundAgentTasks.ts` | 数据过滤 hookbackgrounded local_agent + evictAfter 过滤 + startTime 排序) |
| `src/components/tasks/BackgroundAgentSelector.tsx` | 底部 selector UI纯展示 |
| `src/components/PromptInput/PromptInput.tsx` | 新增 `'bg_agent'` footer pill + 对应的 `footer:up/down/openSelected` 分支 |
| `src/state/AppStateStore.ts` | `FooterItem``'bg_agent'`;新增 `selectedBgAgentIndex` 字段 |
| `src/main.tsx` | `getDefaultAppState` 同步初始化 `selectedBgAgentIndex: -1` |
| `src/screens/REPL.tsx` | 在 PromptInput + SessionBackgroundHint 之后挂载 `<BackgroundAgentSelector />`;切换 agent 主视图;对 fork transcript 做 prompt 归一化 |
| `src/components/messages/AssistantToolUseMessage.tsx` | 新增 `defaultCollapsed?: boolean` prop为后续详情视图默认折叠工具块预留 |
| `src/components/messages/UserTextMessage.tsx` | 识别 `<fork-boilerplate>`,交给 fork 专用 renderer 处理 |
| `src/components/messages/UserForkBoilerplateMessage.tsx` | 将 fork boilerplate text 折叠为纯用户 prompt作为 transcript 中原位渲染的兼容路径 |
## 七、已知限制
- `Date.now()``useBackgroundAgentTasks` 的 useMemo 里冻结于 `[tasks]` 触发时:若长时间没有新 task 变更事件,某个 terminal agent 的 grace 期过期后不会立即从 selector 消失,要等下一次 tasks 变化才刷新。在典型使用(主对话一直在产生消息)下感知不到,暂不额外加 interval。
- Selector 当前不处理 Shell Task / Workflow / Monitor MCP 等类型——这些仍走 `BackgroundTasksDialog`Shift+↓)管理。
- `AssistantToolUseMessage``defaultCollapsed` prop 目前无调用方传值,保留作为后续"agent 详情视图内工具块默认折叠"扩展点。

View File

@@ -0,0 +1,275 @@
---
title: "StatusLine 底部状态栏 - 自定义 shell 渲染管线"
description: "从源码角度解析 Claude Code 底部状态栏:自定义 shell 脚本 + JSON stdin 协议、三种触发源event / settings / time、debounce + abort、信任与 hook 开关、以及本仓库 refreshInterval 缺失修复。"
keywords: ["statusLine", "状态栏", "自定义提示符", "refreshInterval", "Hooks"]
---
{/* 本章目标:完整讲清 StatusLine 的渲染管线、触发模型、协议契约与安全网关,并记录本仓库相对官方版本的已知缺口与修复 */}
## 概述
StatusLine 是 Claude Code REPL 底部显示的一行自定义文本,由**用户提供的 shell 命令**渲染。主进程把运行时状态模型、工作目录、token、限流、会话元数据等打包成 JSON 通过 stdin 喂给脚本,脚本在 stdout 输出一行字符串Ink 侧以 ANSI 转义渲染到 footer。
核心设计哲学:**语言无关 + 进程隔离 + Unix 管道**。用户可用 bash / python / node / 任意语言写脚本;脚本崩溃不影响主进程;输入输出都是纯文本,可以离线测试(`echo '{...}' | ./script.sh`)。
## 配置
`~/.claude/settings.json` 里添加 `statusLine` 字段:
```json
{
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline-command.sh",
"refreshInterval": 1,
"padding": 0
}
}
```
| 字段 | 类型 | 作用 |
|------|------|------|
| `type` | `"command"` | 目前仅支持 command 型 |
| `command` | `string` | shell 命令字符串;主进程用系统 shell 解释执行 |
| `refreshInterval` | `number` (秒) | 定时刷新周期;缺省/0 表示不定时刷新 |
| `padding` | `number` | 左右 padding单位为 Ink cell |
Schema 定义在 `src/utils/settings/types.ts:550``statusLine` Zod object
## 渲染管线(整体图)
```
┌─────────────────────── Ink 侧 ───────────────────────┐ ┌──────── 用户侧 ────────┐
│ │ │ │
│ buildStatusLineCommandInput() ──┐ │ │ ~/.claude/ │
│ 收集运行时状态 │ │ │ statusline-*.sh │
│ ▼ │ │ │
│ executeStatusLineCommand() ─── JSON via stdin ────────────► jq '.model...' │
│ execCommandHook() 拉起 shell │ │ 计算、格式化 │
│ ▲ │ │ │
│ stdout ◄──────────────────── 一行文本 ──────────────── printf '...' │
│ │ │ │ │
│ setAppState({ statusLineText }) ─┘ │ └────────────────────────┘
│ zustand 存字段,组件 memo 订阅 │
│ │
│ <StatusLine /> → <Text><Ansi>{text}</Ansi></Text> │
│ │
└──────────────────────────────────────────────────────┘
```
## Input 协议:主进程 → 脚本
`buildStatusLineCommandInput``src/components/StatusLine.tsx:53`)构造的 JSON 对象字段如下,**这是脚本可以 `jq` 读取的全部内容**
| 字段 | 来源 | 备注 |
|------|------|------|
| `session_id` | `getSessionId()` | UUID用于脚本侧 per-session 状态隔离 |
| `session_name` | `getCurrentSessionTitle(sessionId)` | 用户命名的会话标题(可选) |
| `model.id` / `model.display_name` | `getRuntimeMainLoopModel()` | 运行时真实模型(经 permission mode 降级/200k 升级) |
| `workspace.current_dir` / `project_dir` / `added_dirs` | `getCwd()` / `getOriginalCwd()` / permission context | current_dir 随 `cd` 变化 |
| `version` | `MACRO.VERSION` | 构建注入,如 `2.1.888` |
| `output_style.name` | `settings.outputStyle` | 缺省 `DEFAULT_OUTPUT_STYLE_NAME` |
| `cost.total_cost_usd` / `total_duration_ms` / `total_api_duration_ms` / `total_lines_added` / `total_lines_removed` | `cost-tracker.js` 聚合 | 会话累计 |
| `context_window.total_input_tokens` / `total_output_tokens` | 同上 | 累计 token |
| `context_window.context_window_size` | `getContextWindowForModel()` | 模型上下文上限 |
| `context_window.current_usage` | `getCurrentUsage(messages)` | **最新一次 assistant message 的 usage**;含 `input_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens` / `output_tokens` |
| `context_window.used_percentage` / `remaining_percentage` | `calculateContextPercentages()` | 0-100 浮点 |
| `exceeds_200k_tokens` | 检查最近 assistant message | 用于 1M 上下文模型的展示 |
| `rate_limits.five_hour` / `seven_day` | `getRawUtilization()` | `{ used_percentage, resets_at }`,来自 Claude.ai 限流 API |
| `vim.mode` | 启用 vim 模式时 | `INSERT` / `NORMAL` / ... |
| `agent.name` | 主线程 agent 类型 | 子 agent fork 时非空 |
| `remote.session_id` | Bridge / Remote Control 模式 | 远程会话 |
| `worktree` | 当前 worktree 元信息 | `name` / `path` / `branch` / `original_cwd` / `original_branch` |
类型签名目前在 `src/types/statusLine.ts` 是 `any` 的 stub反编译残留实际字段以上表为准。
## Output 协议:脚本 → 主进程
`executeStatusLineCommand``src/utils/hooks.ts:4752`)对脚本 stdout 做如下处理:
1. `trim()` 首尾空白
2. 按 `\n` 拆行,每行再 `trim()`
3. 空行丢弃,剩余用 `\n` 重新拼接
多行输出会被**保留为多行**Ink 渲染时 `<Text>` 允许换行),但设计推荐**单行**——多行会挤占 REPL 高度fullscreen 模式下可能挤掉 ScrollBox 行。
状态码约定:
- `exit 0` + 有 stdout → 显示
- `exit 0` + 空 stdout → 清空 statusLine显示为空
- 非 0 → 忽略,保留上次内容;`logResult=true` 时 warn 级日志
- 超时(默认 5000ms → 忽略
- 被 AbortController 取消 → 忽略
ANSI 颜色可用Ink 通过 `<Ansi>{text}</Ansi>` 组件解析 SGR 序列。
## 三种触发源
StatusLine 的重算由**三类事件**驱动,全部经同一个 debounce 队列:
### 1. Event-driven`src/components/StatusLine.tsx:275`
监听这些状态变化,触发 `scheduleUpdate()`
- `lastAssistantMessageId` — 新助手回复出现
- `permissionMode` — `/mode` 切换权限模式
- `vimMode` — vim insert/normal 切换
- `mainLoopModel` — `/model` 切换
### 2. Settings-driven`src/components/StatusLine.tsx:294`
`settings.statusLine.command` 字符串变化时(热重载 settings.json标记下一次结果 log 并立即 `doUpdate()`。
### 3. Time-driven`src/components/StatusLine.tsx:292`,本仓库补丁)
读取 `settings.statusLine.refreshInterval`(秒),`setInterval` 每到点走一次 `scheduleUpdate()`。配置为 0 或缺省时不启定时器(零开销)。
> **本仓库历史缺口**:反编译出的 `StatusLine.tsx` 最初没有 Time-driven 触发路径,`refreshInterval` 字段也不在 Zod schema 里。导致脚本里 TTL 倒计时、时钟类动态内容不会秒刷,只有助手回复出现时才重算。已在 2026-05-06 补齐,细节见下方"已知缺口与修复"。
## Debounce + Abort
三种触发源都走 `scheduleUpdate``src/components/StatusLine.tsx:259`
```
scheduleUpdate() → setTimeout(300ms) → doUpdate()
└─ 再次 schedule 会 clearTimeout 前次
```
300ms debounce 合并抖动事件(例如短时间连续切 vim/permission
`doUpdate()` 里:
```
abortControllerRef.current?.abort() // 取消上一次 in-flight shell
controller = new AbortController()
executeStatusLineCommand(..., controller.signal, ...)
```
**单飞single-flight语义**:任何新触发都会 abort 上一次未完成的 shell 调用,保证同一时刻最多一个子进程。这对 `refreshInterval: 1` 尤其关键——若脚本执行 > 1 秒,新 tick 到来时老进程被 kill不会堆积。
## 安全网关
`executeStatusLineCommand``src/utils/hooks.ts:4752`)在执行前有**三层拦截**
1. `shouldDisableAllHooksIncludingManaged()` → managed settings 全局禁用 hooks 时直接返回
2. `shouldSkipHookDueToTrust()` → **工作区未接受信任对话框时跳过**,避免打开未知仓库时执行任意 shell 命令RCE 防护)
3. `shouldAllowManagedHooksOnly()` → 非 managed settings 禁用 hooks 但 managed 未禁用时,只读取 policySettings 源的 statusLine
组件侧配合(`src/components/StatusLine.tsx:318`):未接受 trust 时在通知中心提示 `"statusline skipped · restart to fix"`。
另外,`statusLineShouldDisplay``src/components/StatusLine.tsx:46`)在 **Kairos assistant mode** 下直接返回 false——因为那时 statusline 字段反映的是 REPL/daemon 进程状态,不是 agent 子进程在跑的东西,显示出来会误导用户。
## 渲染细节
### memo 隔离
```tsx
export const StatusLine = memo(StatusLineInner)
```
父组件 `PromptInputFooter` 每次 `setMessages` 都 rerender但 `StatusLine` 的 props 只有 `lastAssistantMessageId` 会变,`memo` 阻断了无意义的重渲染。此前(未 memo 版本)一个 session 内大约 18 次冗余渲染。
### 订阅粒度
```tsx
const statusLineText = useAppState(s => s.statusLineText)
```
`useAppState` 是选择器订阅,仅在 `statusLineText` 字段变化时触发 rerender`doUpdate()` 里还做了幂等检查(`prev.statusLineText === text` 则直接返回原 state**文本不变就不更新 zustand**,连一次 notify 都省掉。
### Fullscreen 占位
```tsx
{statusLineText ? (
<Text dimColor wrap="truncate"><Ansi>{statusLineText}</Ansi></Text>
) : isFullscreenEnvEnabled() ? (
<Text> </Text> // 占位一行
) : null}
```
Fullscreen 模式下 footer `flexShrink:0`statusline 从 0 行变 1 行会挤掉 ScrollBox 一行内容导致抖动。首次脚本还没返回时,用空格文本占住一行高度,脚本返回后原位替换。
## 内置 `/statusline` slash command
`src/commands/statusline.tsx` 定义了一个 **prompt 型 command**,展开成自然语言指令喂给主 Agent
```
Create an AgentTool with subagent_type "statusline-setup" and the prompt "<user-args>"
```
默认 prompt 是 `"Configure my statusLine from my shell PS1 configuration"`。主 Agent 收到后会调用内置子 agent `statusline-setup`。该子 agent 权限极小:
- **Tools**: 仅 `Read`、`Edit`
- **Allowed paths**: `Read(~/**)`、`Edit(~/.claude/settings.json)`
也就是说它**不能 Write 新文件、不能跑 Bash**。典型工作是读用户的 shell 配置、读/改 `settings.json`、增量编辑已有的 statusline 脚本。
## 编写自定义脚本的要点
1. **脚本必须无状态** — 每次 tick 主进程 fork 一次新 shell进程内变量不跨调用保留。需要跨 tick 的状态(上次时间戳、上次 token 数)用 `~/.claude/statusline-state/<hash>.state` 文件持久化。
2. **按 `session_id` 哈希隔离状态文件** — 多会话同时开着时共享一个 state 文件会串。典型做法:`md5(session_id) | head -c 16` 作为文件名。
3. **防御性读取** — state 文件可能损坏/被截断,按行 read + 字段校验(数字字段用 `case "$var" in ''|*[!0-9]*) invalid ;;`)。
4. **`refreshInterval` 不等于"脚本秒级调用"** — tick 和事件触发(新消息、模式切换)都走同一 debounce 队列,脚本实际被调用的频率介于"每 N 秒"和"每 N+0.3 秒"之间;且 abort 机制下,上一次没跑完会被 kill。
5. **执行时间预算** — 默认 5000ms 超时;为避免 `refreshInterval=1` 时频繁超时,脚本热路径应在 100ms 内完成。重计算curl、git log 拉取)需缓存。
6. **颜色用 ANSI 转义** — 不要依赖 TERM 环境变量Ink 的 `<Ansi>` 组件独立解析 SGR。
7. **不要输出多行** — 单行文本,否则挤占 REPL 布局。
8. **处理 `current_usage` 为 null 的情况** — 首次响应之前 `context_window.current_usage` 可能为 null脚本应有 fallback如读 state 里上次命中率)。
### 示例Cache 命中率 + TTL 倒计时
本仓库默认安装了一个示例脚本 `~/.claude/statusline-command.sh`(用户侧),输出格式 `<dir> | <model> | ctx:N% | Cache 97% 59:43`
- **命中率** = `cache_read / (input + cache_creation + cache_read)`(取自 `current_usage`
- **TTL** 从上次响应倒数 60 分钟,**只在 token signature 变化时重置时间戳**,避免秒级 tick 把 TTL 一直锁在 60:00
- **颜色分段** — 命中率 ≥50% 绿 / <50% 灰TTL 0-20m 绿 / 20-40m 黄 / 40-55m 红 / 最后 5m 闪红 / 过期 `exp` 灰
- **Per-session state** — `~/.claude/statusline-state/<md5(session_id)[:16]>.state` 三行signature、timestamp、hit读前做 numeric 校验
- **Fallback** — `current_usage` 为 null 时读 state 显示上次命中率
> 该脚本配合 `refreshInterval: 1` 即可秒刷 TTL前提是 `refreshInterval` 触发路径已实现(见下节)。
## 已知缺口与修复(本仓库)
反编译版的 `StatusLine.tsx` 存在一处功能缺口:
| 项 | 官方 Claude Code | 本仓库原始 | 本仓库现状 |
|----|-----------------|-----------|-----------|
| `refreshInterval` Zod 字段 | ✅ 有 | ❌ 无 | ✅ 已补 |
| Time-driven `setInterval` 触发 | ✅ 有 | ❌ 无 | ✅ 已补 |
| Event-driven 触发 | ✅ 有 | ✅ 有 | — |
| Settings-driven 触发 | ✅ 有 | ✅ 有 | — |
| Debounce + Abort | ✅ 有 | ✅ 有 | — |
| Trust 网关 | ✅ 有 | ✅ 有 | — |
修复2026-05-06
**1. `src/utils/settings/types.ts:554`** — statusLine schema 新增 `refreshInterval: z.number().optional()`,让字段进入类型系统而非被当未知键忽略。
**2. `src/components/StatusLine.tsx:292`** — 新增 Time-driven useEffect
```tsx
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
useEffect(() => {
if (refreshIntervalMs <= 0) return;
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
return () => clearInterval(id);
}, [refreshIntervalMs, scheduleUpdate]);
```
关键点:
- 走 `scheduleUpdate`(非 `doUpdate`)复用 300ms debounceinterval + event 双触发不会双跑
- `refreshIntervalMs <= 0` 时不启定时器,对未启用该字段的用户零开销
- 依赖数组含 `refreshIntervalMs`settings 热重载会自动清理旧 interval 重建新的
**静默失效特征**:修复前 settings.json 写 `refreshInterval: 1` 无任何报错——JSON 解析通过Zod schema 默认 strip 多余字段,官方文档又说支持这个字段,用户很容易以为生效了而没意识到 TTL/时钟类输出根本没秒刷。这是反编译版本的典型"文档与实现不一致"。
## 相关源码
| 文件 | 作用 |
|------|------|
| `src/components/StatusLine.tsx` | UI 组件、触发逻辑、buildStatusLineCommandInput |
| `src/utils/hooks.ts:4752` | `executeStatusLineCommand`shell 执行、输出处理、安全网关 |
| `src/utils/settings/types.ts:550` | `statusLine` Zod schema |
| `src/types/statusLine.ts` | `StatusLineCommandInput` 类型(当前为 stub |
| `src/commands/statusline.tsx` | `/statusline` slash command 定义 |
| `src/state/AppStateStore.ts:95` | `statusLineText` 字段声明 |
| `src/components/PromptInput/PromptInputFooter.tsx:159` | StatusLine 组件挂载点 |

View File

@@ -1,294 +1,103 @@
# 内存与性能峰值分析报告(最终版 — 5 轮迭代完成)
# 内存与性能峰值分析报告
> 进程 bun物理内存峰值 **700 MB+**,最差场景可达 **1.8 GB**
> 日期2026-05-02 | 状态:**调研完成** | 范围:内存峰值 + CPU 热点 + React 渲染循环
> Round 5 增量验证消息渲染管线buildMessageLookups 8 Map/Set 重建、useDeferredValue 双缓冲、FileReadTool 无上限、compaction 与 React 状态交互
> 进程 bunRSS 基线 **682 MB**,最差 **1.8 GB** | 2026-05-02 | **调研完成**12 轮迭代)
> 修复 commit`ef10ad28` + `ab0bbbc4`(降 100-300 MB| 架构限制Bun mimalloc/JSC 不归还内存页(~150-250 MB 永久占用)
## 数据收集
## 已修复10 项)
- 典型场景 RSS 682 MB基线 JSC heap 300-400 MB
- Bun mimalloc 不归还内存页JSC 页管理只增不减(架构级限制)
- 已有每秒 `Bun.gc()` 定时器(`cli/print.ts:554-558`),非强制模式
- 10 项已修复commit `ef10ad28` + `ab0bbbc4`),降低约 100-300MB
- Round 3 确认AWS SDK/Google Auth/Azure Identity 均动态 importlazy不贡献基线
## 已修复问题commit ef10ad28 + ab0bbbc4
| 问题 | 原峰值 | 修复方式 | 位置 |
|------|--------|----------|------|
| 问题 | 原峰值 | 修复 | 位置 |
|------|--------|------|------|
| 流式字符串拼接 O(n²) | 2-20 MB | `+=` → 数组累积 | `claude.ts:1834,2271` |
| Messages.tsx 多次遍历 | 100-270 MB | 合并单次 pass | `Messages.tsx:417-418` |
| ColorFile 无缓存 | 50-100 MB | LRU 缓存 50 条目 | `HighlightedCode.tsx:14-61` |
| Ink StylePool 无界 | 10-50+ MB | 1000 条目上限 | `@ant/ink/screen.ts:122` |
| ColorFile 无缓存 | 50-100 MB | LRU-50 | `HighlightedCode.tsx:14-61` |
| Ink StylePool 无界 | 10-50+ MB | 1000 上限 | `@ant/ink/screen.ts:122` |
| CompanionSprite 高频 | CPU | TICK_MS→1000ms | `CompanionSprite.tsx:15` |
| MCP stderr 缓冲 | 1-640 MB | 64→8MB/server | `mcp-client/connection.ts:117` |
| BashTool 输出缓冲 | 30-330 MB | 32→2MB | `stringUtils.ts:88` |
| Transcript 写入队列 | 5-50 MB | 1000 条目上限 | `sessionStorage.ts:613-619` |
| Transcript 写入队列 | 5-50 MB | 1000 上限 | `sessionStorage.ts:613-619` |
| contentReplacementState | 持续增长 | compact 清理 | `compact/compact.ts` |
| SSE 缓冲 | 无上限 | 1MB cap | SSE 处理代码 |
## 仍存在的问题 — 内存(按峰值影响排序
## P0 — 核心瓶颈6 项
### P0消息数组 7-8x 拷贝120-320 MB
| # | 问题 | 峰值 | 位置 | 建议 |
|---|------|------|------|------|
| 1 | 消息数组 7-8x spread 拷贝turn 尾部 3-4 份同时驻留) | 120-320 MB | `query.ts` 7 处(:477,:491,:897,:1135,:1745,:1857,:1878 | 去掉 spread / 传引用 / 改 push |
| 2 | AutoCompact 时序缺陷(检查在 API 前,增长在 API 后) | API 超限 | `query.ts:575` | 加入预测式阈值检查 |
| 3 | reactiveCompact 空存根API 413 时无紧急压缩) | 无降级 | `reactiveCompact.ts` 全文 | 实现真实逻辑 |
| 4 | buildMessageLookups 8 Map/Set 重建(流式每个 delta 触发) | GC STW 100-173ms | `Messages.tsx:519` | 增量更新 / 拆分 useMemo 链 |
| 5 | useDeferredValue 双缓冲 | 100-200 MB | `REPL.tsx:1569` | React 调度机制固有,优化空间有限 |
| 6 | Compact 峰值窗口preCompactReadFileState + summary + attachments | 20-80 MB | `compact.ts:524-644` | 提前释放 preCompactReadFileState/summaryResponse |
`src/query.ts` 每轮 turn 产生的拷贝Round 3 新增第 7 项)
## P1 — 重要瓶颈14 项)
| 位置 | 操作 | 是否必要 | 优化方式 |
|------|------|----------|----------|
| `:477` | `[...getMessagesAfterCompactBoundary(messages)]` | 双重浪费 | 去掉 spread |
| `:491` | `applyToolResultBudget → map()` | 按需 | 无超限返回原数组 |
| `:897` | `clonedContent ??= [...contentArr]` | 条件必要 | 保留 |
| `:1135` | `[...messagesForQuery, ...assistant]` | 可避免 | 传引用 |
| `:1745` | `.concat(assistant, toolResults)` | 可避免 | 传多参数 |
| `:1857` | `[...messagesForQuery, ...assistant, ...toolResults]` forkContextMessages | **Round 3 新发现** — task summary 用完即弃 | 传引用 |
| `:1878` | `[...messagesForQuery, ...assistant, ...toolResults]` | 必要 | 改 push |
| # | 问题 | 峰值 | 位置 | 建议 |
|---|------|------|------|------|
| 7 | OpenAI/Gemini/Grok 兼容层 O(n²) 拼接 | 25-75 MB | 3 文件 9 处(`openai/index.ts:386`, `gemini/index.ts:148`, `grok/index.ts:163` | 改数组累积(同 claude.ts 模式) |
| 8 | messages.ts O(n²) 拼接 | 10-25 MB | `messages.ts:3252,3268` | 改数组累积 |
| 9 | highlight.js 全量 192 语言(仅需 26 种) | 8-12 MB | `color-diff-napi/index.ts:21` | 自定义构建 |
| 10 | hlLineCache 模块级单例 2048 条目 | ~4 MB | `color-diff-napi/index.ts:508` | 改 LRU + size 上限 |
| 11 | colorFileCache 3x 代码存储 | 2-5 MB | `HighlightedCode.tsx:14` | 移除 value 中 code 字段 |
| 12 | 虚拟滚动 200 组件常驻 | 50 MB | `useVirtualScroll.ts` | 降低 OVERSCAN_ROWS / MAX_MOUNTED_ITEMS |
| 13 | FileReadTool 大文件(输出上限 100K 字符,但读取期间完整加载) | 临时数 MB | `FileReadTool.ts:342` | 读取前检测大小,流式截断 |
| 14 | Session 恢复全量加载磁盘→JSON→REPL 三阶段) | 200-300 MB | `sessionStorage.ts:3482` | 流式 JSONL / 增量恢复 |
| 15 | Session 写入 100MB 累积 | ~100 MB | `sessionStorage.ts:652` | 流式写入 |
| 16 | Forked Agent FileStateCache 完整克隆 | 50N MB | `forkedAgent.ts:382` | 共享/分层缓存agent 用 10MB |
| 17 | GC 阈值 350MB < 基线(每秒无意义强制 GC | CPU 浪费 | `cli/print.ts:554` | 提高到 800MB+ |
| 18 | PDF 100 页处理 | ~100 MB | `apiLimits.ts:54` | 分页流式处理 |
| 19 | 图片单张处理base64→解码→resize | ~16 MB/张 | `apiLimits.ts:22` | 流式 resize |
| 20 | token 估算 ±25-50% 误差放大时序问题 | 阈值不准 | `tokenEstimation.ts:215` | 内容类型感知估算 |
峰值时 3-4 份完整消息数组同时驻留477 + 1745 + 1857 + 1878 在同一 turn 尾部顺序执行)。
### P0React 消息管线重复计算Round 5 新增分析)
**buildMessageLookups 每次 useMemo 重算时创建 8 个 Map/Set**`messages.ts:1215-1398`
| 数据结构 | 规模 | 说明 |
|----------|------|------|
| `toolUseIDsByMessageID` | Map\<string, Set\> | 每个 assistant 消息一个 Set |
| `toolUseIDToMessageID` | Map\<string, string\> | 所有 tool_use ID |
| `toolUseByToolUseID` | Map\<string, ToolUseBlockParam\> | **保留完整 tool_use block** |
| `siblingToolUseIDs` | Map\<string, Set\> | 兄弟 tool_use 索引 |
| `progressMessagesByToolUseID` | Map\<string, ProgressMessage[]\> | 进度消息数组 |
| `toolResultByToolUseID` | Map\<string, NormalizedMessage\> | **保留完整 tool_result 消息引用** |
| `resolvedToolUseIDs` / `erroredToolUseIDs` | Set\<string\> | 已完成/错误 ID |
此 useMemo`Messages.tsx:519`)依赖 normalizedMessages任何消息变更含流式 delta触发重建。已拆分 renderRange 避免滚动触发注释明确记录50ms alloc per scroll → GC → 100-173ms STW on 1GB heap
**useDeferredValue 双缓冲**`REPL.tsx:1569`):流式期间 `messages``deferredMessages` 同时持有两份完整数组,直到 React 调度更新。在 27k 消息场景下,额外 ~100-200MB 临时占用。
**FileReadTool 无大小限制**`FileReadTool.ts:342``maxResultSizeChars: Infinity`,单次 10MB 文件读取完整保留在消息数组中。BashTool30KB和 GrepTool20KB有合理上限。
### P0Compaction 与 React 状态交互Round 5 新增分析)
**非全屏模式**`REPL.tsx:3074-3075`compact 后 `setMessages(() => [newMessage])` 正确替换整组旧消息,内存立即释放。
**全屏模式**`REPL.tsx:3056-3072`):保留最多 500 条消息的 scrollback。注释记录Ink fiber 树每条消息 ~250KB RSS无 cap 时观察过 13k+ 消息 → 1GB+ heap。
**Microcompact 的局限**`microCompact.ts:472-494`):用 spread 创建新消息对象替换内容为 `[Old tool result content cleared]`。但 `ContentReplacementState.replacements` Map`toolResultStorage.ts:392`)仍保留原始替换字符串,直到 compact 时才清理。这意味着 microcompact 减少了 token 数,但实际内存释放依赖后续 compact。
### P0Compact 峰值20-80 MB
峰值时间线(`compact.ts:524-644`
```
Before: messages(200K) + mutableMessages(200K) = 400K tokens
During: + preCompactReadFileState(25MB) + summary + attachments ≈ 500K+ tokens
After: splice → 50K tokens
```
可提前释放:`preCompactReadFileState`25MB`summaryResponse`、原始 `messages` 参数。
### P0React Hooks 闭包与 useMemo 链Round 5 深入排查)
**useCallback 闭包重建**`REPL.tsx`
| 回调 | 依赖项数 | 位置 | 影响 |
|------|----------|------|------|
| `getToolUseContext` | 20 | `:2789-2949` | 重建时旧闭包持有的引用阻止 GC |
| `onQueryImpl` | 14 | `:3188-3469` | 包含 getToolUseContext + 多层嵌套闭包 |
| `onQuery` | 在 onQueryImpl 上再包装 | `:3471-3697` | 又一层闭包 |
| `onSubmit` | ~10 | `:3822-4298` | 闭包链嵌套 3 层 |
每次 `messages` 变更触发 `setMessages` → React 重渲染 → 依赖 messages 的 useCallback/useMemo 全部重建。但 `getToolUseContext``onQueryImpl` **没有把 `messages` 放入依赖数组**(通过 `messagesRef.current` 参数传递规避),所以这些闭包不会因 messages 变化而重建。**这实际上是正确的设计**——用 ref 规避了闭包捕获问题。
**真正的 hooks 问题**在于 useMemo 链(`Messages.tsx`
```
messages → normalizedMessages (O(n))
→ compactAwareMessages (O(n) filter)
→ messagesToShow (O(n) filter + reorder)
→ groupedMessages (O(n))
→ collapsed (O(n))
→ lookups (8 Map/Set, O(n))
```
流式期间每个 delta 触发 `messages` 变更 → 整条链全量重算。注释记录50ms alloc per scroll → GC → 100-173ms STW on 1GB heap`Messages.tsx:516-518`)。
**无界 useRef**`REPL.tsx`
| Ref | 增长方式 | 清理 | 影响 |
|-----|----------|------|------|
| `bashTools` | `.add()` 每个 bash 命令 | `clearConversation` 时 clear | Set\<string\>,通常 <100 |
| `discoveredSkillNamesRef` | `.add()` 每个发现的 skill | `clearConversation` 时 clear | Set\<string\>,通常 <50 |
| `apiMetricsRef` | `.push()` 每次请求 | turn 结束时 `= []` | 临时turn 内累积 |
| `responseLengthRef` | 累加 | compact 时重置为 0 | 单数字 |
| `loadedNestedMemoryPathsRef` | `.add()` 每个 CLAUDE.md | compact/clear 时 clear | Set\<string\> |
结论:**这些 ref 都有清理机制**,不是主要问题。核心问题仍是 useMemo 链在流式期间的全量重算。
### P1虚拟滚动组件~50 MB— Round 3 新发现
`src/hooks/useVirtualScroll.ts` + React Ink 渲染管线:
- MAX_MOUNTED_ITEMS = 300OVERSCAN_ROWS = 80
- 实际挂载约 200 个 MessageRow视口 + overscan
- 每个 MessageRow ≈ 250KB RSSReact fiber + Yoga node + 子组件树)
- **总计约 50 MB 常驻内存**(当前会话最大挂载窗口)
优化空间:降低 MAX_MOUNTED_ITEMS 或 OVERSCAN_ROWS评估 MessageRow 组件内部 memo 化。
### P1流式 contentBlocks 累积 — Round 3 新发现
`src/services/api/claude.ts:1932`
- `contentBlocks` 数组在流式响应期间累积所有内容块
- 长 thinking 响应可达数万 tokenthinking 文本完整保留在 contentBlock.thinking 中
- `streamingDeltas` Map已修复为数组累积`content_block_stop``join('')` 赋值给 contentBlock
- 思考块在 normalize 后仍然保留完整 thinking 文本
### P1其他已确认内存问题
## P2 — 次要问题10 项)
| # | 问题 | 峰值 | 位置 |
|---|------|------|------|
| 1 | MCP Tool Schema 双重存储 | ~40 MB | `manager.ts:73` + `AppStateStore.ts:175` |
| 2 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` |
| 3 | Session 恢复全量加载(中小文件) | 50-200 MB | `sessionStorage.ts:3475-3582` |
| 4 | HybridTransport 100K 队列 | 1-10 MB | `HybridTransport.ts:86` |
| 5 | React messagesRef 双重引用 | 临时 | `REPL.tsx:1437-1477` |
| 6 | AppState 不可变更新抖动 | 5-50 MB | `store.ts:20-26` |
| 7 | Tool result seenIds/replacements | 0.5-2 MB | `toolResultStorage.ts:390-397` |
| 8 | bootstrap/state.ts 无界缓存 | 0.1-1 MB | planSlugCache 等 |
| 9 | QueryEngine 无界集合 | 0.1-1 MB | discoveredSkillNames 等 |
| 10 | expandedKeys Set 无清理Round 5 | <0.5 MB | `Messages.tsx:644` compact 后 stale keys 不删除 |
| 11 | OpenAI/Gemini/Grok collectedMessagesRound 5 | 临时 | 流式期间累积 assistant messages 供 Langfuse telemetrystream 结束后释放 |
| 21 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` |
| 22 | MCP Tool Schema 双重存储 | ~40 MB | `manager.ts:73` + `AppStateStore.ts:175` |
| 23 | ContentReplacementState 单调增长 | 0.5-2 MB | `toolResultStorage.ts:390` |
| 24 | Perfetto 100K 事件 | ~30 MB | `perfettoTracing.ts:106` |
| 25 | StreamingMarkdown 双渲染 | 临时 | `Markdown.tsx:185` |
| 26 | MarkdownTable 3 次遍历 | CPU 峰值 | `MarkdownTable.tsx:99` |
| 27 | 搜索索引 WeakMap | 5-10 MB | `transcriptSearch.ts:17` |
| 28 | ACP FileStateCache/会话 | 50 MB | `acp/agent.ts:554` |
| 29 | Agent initialMessages 浅拷贝 | 1-5 MB/agent | `runAgent.ts:382` |
| 30 | Hook 结果累积 | ~1 MB+ | `toolExecution.ts:1474` |
### P2低优先级未验证
| # | 问题 | 峰值 | 位置 |
|---|------|------|------|
| 1 | OpenTelemetry 多版本 | ~30 MB | 依赖树 |
| 2 | Perfetto tracing 100K events | ~30 MB | `perfettoTracing.ts:99` |
| 3 | Prompt Cache 规范化 | 5-15 MB | `claude.ts:3180-3329` |
| 4 | GrepTool 全量 stat+sort | ~10 MB | `GrepTool.ts:523-557` |
## 仍存在的问题 — CPU 与渲染热点
### 已确认
## CPU / 渲染热点
| # | 问题 | 影响 | 位置 |
|---|------|------|------|
| C2 | **Ink 每次 React commit 触发 Yoga 布局**React ConcurrentRoot 自动批处理 setState5 个 setState → 1 次 commit → 1 次布局) | ~1-3ms/commit | `reconciler.ts:279``ink.tsx:323` |
| C3 | **MessageRow 挂载成本 ~1.5ms**Markdown 解析仅占 1-7%,主因是 React/Yoga/Ink 管线开销 ~1.3ms | 已有 SLIDE_STEP=25 + useDeferredValue 限速 | `useVirtualScroll.ts` + `Markdown.tsx` |
| C4 | **布局偏移触发全屏 damage** | O(rows×cols) 全量 diff | `ink.tsx:655-661` |
| C7 | **CompanionSprite TICK_MS 定时器**500ms→已修复为 1000ms | 高频 setState 触发渲染 | `buddy/CompanionSprite.tsx:15,136` |
| C9 | 同步 fs 操作 | 阻塞主线程 | `projectOnboardingState.ts:20` 等 |
| C2 | Ink 每次 React commit 触发 Yoga 布局 | ~1-3ms/commit | `reconciler.ts:279``ink.tsx:323` |
| C3 | MessageRow 挂载 ~1.5msReact/Yoga/Ink 管线开销) | 批量挂载 ~290ms 卡顿 | `useVirtualScroll.ts` |
| C4 | 布局偏移触发全屏 damage | O(rows×cols) | `ink.tsx:655-661` |
| C9 | 同步 fs 操作阻塞主线程 | 间歇卡顿 | `projectOnboardingState.ts:20` |
### 已否认
已有缓解React ConcurrentRoot 批处理、帧率限制 16ms、虚拟滚动 overscan 80 + SLIDE_STEP=25 + useDeferredValue、Markdown tokenCache LRU-500 + hasMarkdownSyntax 快速路径、Yoga 增量缓存。
- **C1 useInboxPoller 状态循环** — 验证确认useEffect 是收敛的(移除消息 → count 减少 → 稳定poll 通过 `store.getState()` 读取不触发 React 依赖1 秒轮询是正常 I/O 模式无循环
- **Markdown 是 CPU 热点** — marked.lexer 对典型消息仅 0.01-0.1ms,已有 tokenCache LRU-500缓存命中 0.0003ms99.6% 降速)+ hasMarkdownSyntax 快速路径(跳过 30-40% 消息)
- **Yoga 无增量布局** — 实测增量更新高效1000 节点树改 1 叶子 → 仅 2 次 measure其余走缓存
- **Ink Yoga 2^depth 问题** — 实测 100 节点深链 = 11.7x 访问(线性增长,非指数级)
## 已否认12 轮汇总)
###优化措施
- React ConcurrentRoot 自动批处理 setState多个 setState → 1 次 commit
- Ink 帧率限制 16msthrottle 仅限终端输出Yoga 布局无 throttle 但被 React batching 保护)
- 虚拟滚动 overscan 80 + MAX_MOUNTED_ITEMS 300 + SLIDE_STEP=25 + useDeferredValue
- Markdown tokenCache LRU-500 + hasMarkdownSyntax 快速路径 + StreamingMarkdown 增量解析
- Yoga 增量缓存dirty propagation + measure 结果缓存)
- 双缓冲 + damage tracking + 字符池复用
- Pool 5 分钟周期重置
## 已否认内存5 轮汇总)
- VSZ 516 GB 是虚拟映射非物理 | Zod Schema ~650KB | Markdown LRU-500 已优化
- useSkillsChange/useSettingsChange — 正确 cleanup | useInboxPoller — 收敛设计
- React Compiler `_c(N)` — 未使用 | File watchers — 仅 ~5KB | React reconciler — WeakMap + freeRecursive
- Ink 屏幕缓冲 ~86KB | CharPool/HyperlinkPool ~1-5MB 且 5min 重置 | StylePool 缓存 1000 上限
- 依赖树 — AWS/Google/Azure SDK 均动态 import不贡献基线 | Sentry 空实现
- Ink 无 scrollback 缓冲 | Markdown tokenCache LRU-500 bounded
- **Round 5 否认**useCallback 闭包捕获 messages — 实际通过 messagesRef 参数传递规避,无闭包问题
- **Round 5 否认**MCP stderrHandler 泄漏 — 已有 64MB cap + 成功后释放 + cleanup 移除 listener
- **Round 5 否认**useRef 无界增长 — bashTools/discoveredSkillNamesRef/loadedNestedMemoryPathsRef 均有 clearConversation 或 compact 清理
- **Round 5 否认**apiMetricsRef 无界 — turn 结束时 `= []` 重置
- **Round 5 否认**useEffect 缺少 cleanup — 检查的 12 个 useEffect 均有 return cleanup 函数
VSZ 516 GB 是虚拟映射 | Zod ~650KB | Markdown LRU-500 已优化 | useSkillsChange/useSettingsChange 正确 cleanup | useInboxPoller 收敛设计(非循环)| React Compiler `_c(N)` 未使用 | File watchers ~5KB | React reconciler WeakMap + freeRecursive | Ink 屏幕缓冲 ~86KB | CharPool/HyperlinkPool ~1-5MB 5min 重置 | AWS/Google/Azure SDK 均懒加载 | Sentry 空实现 | useCallback 闭包通过 messagesRef 规避(无泄漏)| MCP stderrHandler 有 64MB cap + cleanup | useRef 有 clearConversation/compact 清理 | apiMetricsRef turn 结束重置 | useEffect 有 cleanup 函数 | lodash-es tree-shakable | AppState useSyncExternalStore 仅相关切片更新 | SDK 无全局重试队列 | Ink unmount 有清理
## 结论
**内存根因**5 轮迭代确认)
1. **消息数组 turn 尾部 3-4 次 spread 同时驻留**120-320 MB— 核心瓶颈
2. **React 消息管线 buildMessageLookups 8 个 Map/Set 重建**50ms/次27k 消息场景)— GC 压力源
3. **useDeferredValue 双缓冲**(流式期间额外 ~100-200 MB 临时
4. **FileReadTool 无大小上限**(单次 10MB 文件永久驻留
5. **Compact 峰值窗口**20-80 MB+ Microcompact 依赖后续 compact 才真正释放
6. **虚拟滚动 200 组件 ~50MB 常驻**
7. **Bun/JSC 不归还内存页**(架构级限制
**内存根因排序**
1. 消息数组 7-8x spread 拷贝120-320 MB— 核心瓶颈
2. useDeferredValue 双缓冲 + React useMemo 链全量重算100-200 MB + GC STW
3. Session 恢复/写入峰值200-300 MB
4. AutoCompact 时序缺陷 + reactiveCompact 空存根API 超限风险
5. Forked Agent FileStateCache 克隆50N MB
6. 虚拟滚动 200 组件 ~50MB 常驻
7. Bun/JSC 不归还内存页(架构级)
**CPU 根因**useInboxPoller 每秒轮询触发 React commit → 全量 Yoga 布局 → 全屏 Ink diff 完整管线。Markdown 渲染~1.5ms/行)在批量挂载新消息时造成 ~290ms 卡顿。轮询导致的周期性 commit 与消息挂载的 CPU 密集操作互相放大
**Round 4 最终验证**agent 递归 spread 和 attachment 累积均为已知 P0消息数组拷贝的变体无新根因。Snipping 在流式前执行无并发问题。consumedCommandUuids 等数组每轮重置无累积。
**Round 5 增量验证**
- buildMessageLookups 8 个 Map/Set 的重建成本已由 renderRange 拆分缓解,但仍然是消息变更时的主要 GC 压力源
- useDeferredValue 双缓冲是 React 调度机制的固有行为,优化空间有限
- FileReadTool 无上限是唯一一个"单次操作可注入 10MB+ 数据"的入口
- Microcompact 减少 token 但不立即释放内存(内容被 ContentReplacementState.replacements Map 间接持有)
**CPU 根因**useInboxPoller 每秒轮询 React commit → Yoga 布局 → 全屏 Ink diff 完整管线。Markdown 渲染批量挂载时 ~290ms 卡顿
**预估优化空间**
| 优先级 | 措施 | 预估降低 |
|--------|------|----------|
| P0 | 消息数组拷贝优化 7 处 | 100-200 MB |
| P0 | Compact 峰值管理 3 项 | 20-80 MB |
| P1 | 虚拟滚动优化 | 20-30 MB |
| P1 | 缓冲与缓存清理 5 项 | 30-80 MB |
| P2 | 其他 3 项 | 10-50 MB |
| **合计** | **21 项可操作建议** | **210-500 MB** |
| 优先级 | 措施 | 预估降低 |
|--------|--------|----------|
| P0 | 6 | 240-600 MB |
| P1 | 14 | 300-600 MB |
| P2 | 10 | 80-200 MB |
| **合计** | **30 项** | **620-1400 MB** |
理论可从当前 400-700 MB 降至 **200-350 MB**
## 建议(按优先级)
### P0消息数组拷贝预估降 100-200 MB
1. `query.ts:477` — 去掉 spread
2. `query.ts:1878` — 改 push 追加
3. `query.ts:1135` — 传引用
4. `query.ts:1745` — 传多参数
5. `query.ts:1857` — 传引用forkContextMessages
6. `query.ts:491` — 无超限返回原数组
### P0消息渲染管线Round 5 新增,预估降 30-60 MB
7. `FileReadTool.ts:342``maxResultSizeChars: Infinity` → 设合理上限(如 100KB
8. `toolResultStorage.ts:392` — Microcompact 后同步清理 `replacements` Map 中对应条目
9. `Messages.tsx:519` — 考虑 buildMessageLookups 增量更新而非全量重建
### P0Compact 峰值(预估降 20-80 MB
10. `compact.ts:543``preCompactReadFileState = undefined`
11. `compact.ts:651``summaryResponse = undefined`
12. 延迟非关键 attachment 生成
### P1渲染与缓存预估降 50-110 MB
13. 虚拟滚动 — 降低 OVERSCAN_ROWS 或 MAX_MOUNTED_ITEMS
14. `lastAPIRequestMessages` — 非 debug 清空
15. MCP Tool Schema — 去掉 manager 层 toolsCache
16. `HybridTransport` — maxQueueSize 100K→10K
17. `bootstrap/state.ts` — 无界 Map 加 LRU
### P2其他预估降 10-50 MB
18. `toolResultStorage.ts` — seenIds/replacements 定期清理
19. Session 恢复流式 JSONL | AppState 增量更新
20. Thinking 文本截断策略(保留前 N + 后 N 字符)
21. `Bun.gc(true)` 低内存触发
### P2Ink 渲染层(降低 CPU 开销)
22. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
## 附录
- 合并来源:`docs/performance-reporter.md`7 轮调研,含 CPU/渲染热点详细验证)
- 修复 commit`ab0bbbc4`compact 清理)、`ef10ad28`(峰值优化 -100-300MB
- Round 2 新发现HybridTransport 缓冲、React messagesRef 双重引用、toolResultStorage 无界增长
- Round 3 新发现:虚拟滚动 ~50MB 常驻、第 7-8 次 spreadquery.ts:1857、流式 contentBlocks thinking 累积、依赖树已懒加载
- Round 4 最终验证无新根因agent spread 和 attachment 累积为已知变体),调研终止
- Round 5 增量验证buildMessageLookups 8 Map/Set 重建成本、useDeferredValue 双缓冲、FileReadTool 无上限、Microcompact 内存释放延迟、compaction 与 React 状态交互细节
理论可从 400-700 MB 降至 **200-350 MB**(受 mimalloc/JSC 架构限制约束)

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "2.0.1",
"version": "2.2.0",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -131,8 +131,13 @@ type Props = {
const MULTI_CLICK_TIMEOUT_MS = 500;
const MULTI_CLICK_DISTANCE = 1;
type ErrorInfo = {
readonly message: string;
readonly stack?: string;
};
type State = {
readonly error?: Error;
readonly error?: ErrorInfo;
};
// Root component for all Ink apps
@@ -142,7 +147,7 @@ export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp';
static getDerivedStateFromError(error: Error) {
return { error };
return { error: { message: error.message, stack: error.stack } };
}
override state = {
@@ -221,7 +226,7 @@ export default class App extends PureComponent<Props, State> {
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
{this.state.error ? <ErrorOverview error={this.state.error} /> : this.props.children}
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>

View File

@@ -23,8 +23,13 @@ function getStackUtils(): StackUtils {
/* eslint-enable custom-rules/no-process-cwd */
type ErrorLike = {
readonly message: string;
readonly stack?: string;
};
type Props = {
readonly error: Error;
readonly error: ErrorLike;
};
export default function ErrorOverview({ error }: Props) {

View File

@@ -468,7 +468,11 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.reasoning_content).toBe('First thought.\nSecond thought.')
})
test('skips empty thinking blocks', () => {
test('preserves empty thinking blocks as reasoning_content: "" (DeepSeek v4 thinking mode)', () => {
// DeepSeek v4 thinking mode sometimes returns reasoning_content: ""
// when the model answers directly without reasoning. The empty value
// must be echoed back in the next request — otherwise DeepSeek returns
// 400 ("reasoning_content ... must be passed back"). See issue #399.
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('question'),
@@ -481,7 +485,23 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
{ enableThinking: true },
)
const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.reasoning_content).toBe('')
expect(assistant.content).toBe('Answer.')
})
test('omits reasoning_content when no thinking block is present', () => {
// No thinking block at all → no reasoning_content field on the
// OpenAI-format assistant message (relevant for non-thinking models).
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('question'),
makeAssistantMsg([{ type: 'text', text: 'Answer.' }]),
],
[] as any,
)
const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.reasoning_content).toBeUndefined()
expect(assistant.content).toBe('Answer.')
})
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──

View File

@@ -439,6 +439,54 @@ describe('thinking support (reasoning_content)', () => {
expect(blockStarts[1].content_block.type).toBe('tool_use')
})
test('opens thinking block on empty reasoning_content (DeepSeek v4 direct-answer)', async () => {
// DeepSeek v4 thinking mode sometimes streams reasoning_content: ""
// before answering directly. We must still open a thinking block so the
// resulting assistant message carries an (empty) thinking block — that
// round-trips back as reasoning_content: "" in the next request,
// satisfying DeepSeek's requirement (see issue #399).
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: { reasoning_content: '' },
finish_reason: null,
},
],
}),
makeChunk({
choices: [
{
index: 0,
delta: { content: 'Direct answer.' },
finish_reason: null,
},
],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
])
// A thinking block was opened (and closed before the text block starts)
const blockStarts = events.filter(
e => e.type === 'content_block_start',
) as any[]
expect(blockStarts.length).toBe(2)
expect(blockStarts[0].content_block.type).toBe('thinking')
expect(blockStarts[0].content_block.thinking).toBe('')
expect(blockStarts[1].content_block.type).toBe('text')
// No empty thinking_delta should be emitted — the empty string is
// already conveyed by the thinking block's initial value.
const thinkingDeltas = events.filter(
e =>
e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
)
expect(thinkingDeltas.length).toBe(0)
})
test('thinking block index is 0, text block index is 1', async () => {
const events = await collectEvents([
makeChunk({

View File

@@ -206,12 +206,14 @@ function convertInternalAssistantMessage(
},
})
} else if (block.type === 'thinking') {
// DeepSeek thinking mode: always preserve reasoning_content.
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
// especially when tool calls are involved (returns 400 if missing).
// DeepSeek thinking mode: always preserve reasoning_content,
// including the empty-string case. DeepSeek v4 may return
// reasoning_content: "" when the model answers directly, and the
// empty value must be echoed back in the next request — otherwise
// DeepSeek returns 400 ("reasoning_content ... must be passed back").
const thinkingText = (block as unknown as Record<string, unknown>)
.thinking
if (typeof thinkingText === 'string' && thinkingText) {
if (typeof thinkingText === 'string') {
reasoningParts.push(thinkingText)
}
}

View File

@@ -106,9 +106,13 @@ export async function* adaptOpenAIStreamToAnthropic(
// Skip chunks that carry only usage data (no delta content)
if (!delta) continue
// Handle reasoning_content → Anthropic thinking block
// Handle reasoning_content → Anthropic thinking block.
// Empty string is a valid signal: DeepSeek v4 thinking mode sometimes
// returns reasoning_content: "" when the model answers directly. The
// empty thinking block must round-trip back to the API in subsequent
// requests, otherwise DeepSeek rejects with 400.
const reasoningContent = (delta as any).reasoning_content
if (reasoningContent != null && reasoningContent !== '') {
if (reasoningContent != null) {
if (!thinkingBlockOpen) {
currentContentIndex++
thinkingBlockOpen = true
@@ -125,14 +129,16 @@ export async function* adaptOpenAIStreamToAnthropic(
} as BetaRawMessageStreamEvent
}
yield {
type: 'content_block_delta',
index: currentContentIndex,
delta: {
type: 'thinking_delta',
thinking: reasoningContent,
},
} as BetaRawMessageStreamEvent
if (reasoningContent !== '') {
yield {
type: 'content_block_delta',
index: currentContentIndex,
delta: {
type: 'thinking_delta',
thinking: reasoningContent,
},
} as BetaRawMessageStreamEvent
}
}
// Handle text content

View File

@@ -148,6 +148,12 @@ const baseInputSchema = lazySchema(() =>
.boolean()
.optional()
.describe('Set to true to run this agent in the background. You will be notified when it completes.'),
fork: z
.boolean()
.optional()
.describe(
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
),
}),
);
@@ -191,24 +197,23 @@ const fullInputSchema = lazySchema(() => {
// type, but call() destructures via the explicit AgentToolInput type below
// which always includes all optional fields.
export const inputSchema = lazySchema(() => {
const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
// GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which
// was removed in 906da6c723): the divergence window is one-session-per-
// gate-flip via _CACHED_MAY_BE_STALE disk read, and worst case is either
// "schema shows a no-op param" (gate flips on mid-session: param ignored
// by forceAsync) or "schema hides a param that would've worked" (gate
// flips off mid-session: everything still runs async via memoized
// forceAsync). No Zod rejection, no crash — unlike required→optional.
return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema;
const base = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
return isBackgroundTasksDisabled
? !isForkSubagentEnabled()
? base.omit({ run_in_background: true, fork: true })
: base.omit({ run_in_background: true })
: !isForkSubagentEnabled()
? base.omit({ fork: true })
: base;
});
type InputSchema = ReturnType<typeof inputSchema>;
// Explicit type widens the schema inference to always include all optional
// fields even when .omit() strips them for gating (cwd, run_in_background).
// subagent_type is optional; call() defaults it to general-purpose when the
// fork gate is off, or routes to the fork path when the gate is on.
// subagent_type is optional; call() defaults it to general-purpose.
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
type AgentToolInput = z.infer<ReturnType<typeof baseInputSchema>> & {
fork?: boolean;
name?: string;
team_name?: string;
mode?: z.infer<ReturnType<typeof permissionModeSchema>>;
@@ -322,6 +327,7 @@ export const AgentTool = buildTool({
{
prompt,
subagent_type,
fork,
description,
model: modelParam,
run_in_background,
@@ -406,12 +412,11 @@ export const AgentTool = buildTool({
return { data: spawnResult } as unknown as { data: Output };
}
// Fork subagent experiment routing:
// - subagent_type set: use it (explicit wins)
// - subagent_type omitted, gate on: fork path (undefined)
// - subagent_type omitted, gate off: default general-purpose
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
const isForkPath = effectiveType === undefined;
// Fork routing: explicit `fork: true` parameter triggers the fork path
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
// subagent_type is ignored when fork takes effect.
const isForkPath = fork === true && isForkSubagentEnabled();
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
let selectedAgent: AgentDefinition;
if (isForkPath) {
@@ -692,10 +697,6 @@ export const AgentTool = buildTool({
// dependency issues during test module loading.
const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false;
// Fork subagent experiment: force ALL spawns async for a unified
// <task-notification> interaction model (not just fork spawns — all of them).
const forceAsync = isForkSubagentEnabled();
// Assistant mode: force all agents async. Synchronous subagents hold the
// main loop's turn open until they complete — the daemon's inputQueue
// backs up, and the first overdue cron catch-up on spawn becomes N
@@ -709,7 +710,6 @@ export const AgentTool = buildTool({
(run_in_background === true ||
selectedAgent.background === true ||
isCoordinator ||
forceAsync ||
assistantForceAsync ||
(proactiveModule?.isProactiveActive() ?? false)) &&
!isBackgroundTasksDisabled;
@@ -889,7 +889,7 @@ export const AgentTool = buildTool({
toolUseContext,
rootSetAppState,
agentIdForCleanup: asyncAgentId,
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
getWorktreeResult: cleanupWorktreeIfNeeded,
}),
),

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const promptSource = readFileSync(join(__dirname, '..', 'prompt.ts'), 'utf-8')
describe('prompt.ts fork-related text verification', () => {
test('does not contain "omit `subagent_type`" guidance', () => {
expect(promptSource).not.toMatch(/omit.*subagent_type/)
})
test('contains `fork: true` in at least 3 locations (shared + whenToFork + forkExamples)', () => {
const matches = promptSource.match(/fork: true/g)
expect(matches).not.toBeNull()
expect(matches!.length).toBeGreaterThanOrEqual(3)
})
test('all forkEnabled references are ternary conditions, not negated', () => {
const lines = promptSource.split('\n')
for (const line of lines) {
if (
line.includes('forkEnabled') &&
!line.includes('const forkEnabled') &&
!line.includes('forkEnabled =')
) {
expect(line).not.toContain('!forkEnabled')
}
}
})
test('uses "non-fork" terminology instead of "fresh agent"', () => {
expect(promptSource).toContain('non-fork')
// "fresh agent" should not appear in fork-aware conditional text
const freshAgentMatches = promptSource.match(/fresh agent/g)
if (freshAgentMatches) {
// Only allowed in comments explaining behavior, not in prompt text
const linesWithFreshAgent = promptSource
.split('\n')
.filter(line => line.includes('fresh agent'))
.map(line => line.trim())
for (const line of linesWithFreshAgent) {
// "fresh agent" in the context of "starts fresh" (not fork-aware) is ok
// but "fresh agent" in forkEnabled conditional should not appear
expect(line).not.toMatch(/fresh agent.*subagent_type/)
}
}
})
test('background task condition does not include !forkEnabled', () => {
// The condition for showing background task instructions should not exclude fork
const bgCondition = promptSource.match(
/!isEnvTruthy.*isInProcessTeammate[\s\S]*?run_in_background/,
)
if (bgCondition) {
expect(bgCondition[0]).not.toContain('!forkEnabled')
}
})
test('fork example includes fork: true parameter', () => {
// The first fork example should have fork: true
const forkExampleBlock = promptSource.match(
/name: "ship-audit"[\s\S]*?Under 200 words/,
)
expect(forkExampleBlock).not.toBeNull()
expect(forkExampleBlock![0]).toContain('fork: true')
})
})

View File

@@ -82,11 +82,7 @@ export async function getPrompt(
## When to fork
Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size.
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache.
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.
Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run.
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
@@ -100,14 +96,14 @@ Forks are cheap because they share your prompt cache. Don't set \`model\` on a f
## Writing the prompt
${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
${forkEnabled ? 'When spawning an agent without `fork: true`, it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
- Explain what you're trying to accomplish and why.
- Describe what you've already learned or ruled out.
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
- If you need a short response, say so ("report in under 200 words").
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
`
@@ -120,6 +116,7 @@ assistant: <thinking>Forking this \u2014 it's a survey question. I want the punc
${AGENT_TOOL_NAME}({
name: "ship-audit",
description: "Branch ship-readiness audit",
fork: true,
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
})
assistant: Ship-readiness audit running.
@@ -205,11 +202,7 @@ The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that auto
${agentListSection}
${
forkEnabled
? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
: `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
}`
When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.${forkEnabled ? ` Set \`fork: true\` to fork from the parent conversation context, inheriting full history and model.` : ''}`
// Coordinator mode gets the slim prompt -- the coordinator system prompt
// already covers usage notes, examples, and when-not-to-use guidance.
@@ -257,14 +250,13 @@ Usage notes:
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${
// eslint-disable-next-line custom-rules/no-process-env-top-level
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
!isInProcessTeammate() &&
!forkEnabled
!isInProcessTeammate()
? `
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.`
: ''
}
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each non-fork Agent invocation starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
- The agent's outputs should generally be trusted
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.

View File

@@ -1,5 +1,7 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import type { StructuredPatchHunk } from 'diff';
import * as React from 'react';
import { Suspense, use, useState } from 'react';
import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js';
import { MessageResponse } from 'src/components/MessageResponse.js';
import { extractTag } from 'src/utils/messages.js';
@@ -10,10 +12,15 @@ import { Text } from '@anthropic/ink';
import { FilePathLink } from 'src/components/FilePathLink.js';
import type { Tools } from 'src/Tool.js';
import type { Message, ProgressMessage } from 'src/types/message.js';
import { adjustHunkLineNumbers, CONTEXT_LINES } from 'src/utils/diff.js';
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js';
import { logError } from 'src/utils/log.js';
import { getPlansDirectory } from 'src/utils/plans.js';
import { readEditContext } from 'src/utils/readEditContext.js';
import { firstLineOf } from 'src/utils/stringUtils.js';
import type { ThemeName } from 'src/utils/theme.js';
import type { FileEditOutput } from './types.js';
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
export function userFacingName(
input:
@@ -84,6 +91,8 @@ export function renderToolResultMessage(
<FileEditToolUpdatedMessage
filePath={filePath}
structuredPatch={structuredPatch}
firstLine={originalFile.split('\n')[0] ?? null}
fileContent={originalFile}
style={style}
verbose={verbose}
previewHint={isPlanFile ? '/plan to preview' : undefined}
@@ -99,7 +108,7 @@ export function renderToolUseRejectedMessage(
replace_all?: boolean;
edits?: unknown[];
},
_options: {
options: {
columns: number;
messages: Message[];
progressMessagesForMessage: ProgressMessage[];
@@ -109,14 +118,40 @@ export function renderToolUseRejectedMessage(
verbose: boolean;
},
): React.ReactElement {
const { style, verbose } = _options;
const { style, verbose } = options;
const filePath = input.file_path;
const isNewFile = input.old_string === '';
const oldString = input.old_string ?? '';
const newString = input.new_string ?? '';
const replaceAll = input.replace_all ?? false;
// Defensive: if input has an unexpected shape, show a simple rejection message
if ('edits' in input && input.edits != null) {
return (
<FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />
);
}
const isNewFile = oldString === '';
// For new file creation, show content preview instead of diff
if (isNewFile) {
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="write"
content={newString}
firstLine={firstLineOf(newString)}
verbose={verbose}
/>
);
}
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation={isNewFile ? 'write' : 'update'}
<EditRejectionDiff
filePath={filePath}
oldString={oldString}
newString={newString}
replaceAll={replaceAll}
style={style}
verbose={verbose}
/>
@@ -149,3 +184,103 @@ export function renderToolUseErrorMessage(
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
}
type RejectionDiffData = {
patch: StructuredPatchHunk[];
firstLine: string | null;
fileContent: string | undefined;
};
function EditRejectionDiff({
filePath,
oldString,
newString,
replaceAll,
style,
verbose,
}: {
filePath: string;
oldString: string;
newString: string;
replaceAll: boolean;
style?: 'condensed';
verbose: boolean;
}): React.ReactNode {
const [dataPromise] = useState(() => loadRejectionDiff(filePath, oldString, newString, replaceAll));
return (
<Suspense
fallback={
<FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />
}
>
<EditRejectionBody promise={dataPromise} filePath={filePath} style={style} verbose={verbose} />
</Suspense>
);
}
function EditRejectionBody({
promise,
filePath,
style,
verbose,
}: {
promise: Promise<RejectionDiffData>;
filePath: string;
style?: 'condensed';
verbose: boolean;
}): React.ReactNode {
const { patch, firstLine, fileContent } = use(promise);
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
patch={patch}
firstLine={firstLine}
fileContent={fileContent}
style={style}
verbose={verbose}
/>
);
}
async function loadRejectionDiff(
filePath: string,
oldString: string,
newString: string,
replaceAll: boolean,
): Promise<RejectionDiffData> {
try {
// Chunked read — context window around the first occurrence. replaceAll
// still shows matches *within* the window via getPatchForEdit; we accept
// losing the all-occurrences view to keep the read bounded.
const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES);
if (ctx === null || ctx.truncated || ctx.content === '') {
// ENOENT / not found / truncated — diff just the tool inputs.
const { patch } = getPatchForEdit({
filePath,
fileContents: oldString,
oldString,
newString,
});
return { patch, firstLine: null, fileContent: undefined };
}
const actualOld = findActualString(ctx.content, oldString) || oldString;
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
const { patch } = getPatchForEdit({
filePath,
fileContents: ctx.content,
oldString: actualOld,
newString: actualNew,
replaceAll,
});
return {
patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1),
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
fileContent: ctx.content,
};
} catch (e) {
// User may have manually applied the change while the diff was shown.
logError(e as Error);
return { patch: [], firstLine: null, fileContent: undefined };
}
}

View File

@@ -761,6 +761,16 @@ async function validateContentTokens(
const effectiveMaxTokens =
maxTokens ?? getDefaultFileReadingLimits().maxTokens
// Fast rejection: if raw byte count exceeds 4x the token limit,
// no encoding can possibly fit (worst case is ~4 bytes/token).
const byteLength = Buffer.byteLength(content)
if (byteLength > effectiveMaxTokens * 4) {
throw new MaxFileReadTokenExceededError(
Math.ceil(byteLength / 4),
effectiveMaxTokens,
)
}
const tokenEstimate = roughTokenCountEstimationForFileType(content, ext)
if (!tokenEstimate || tokenEstimate <= effectiveMaxTokens / 4) return

View File

@@ -1,6 +1,8 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import { relative } from 'path';
import type { StructuredPatchHunk } from 'diff';
import { isAbsolute, relative, resolve } from 'path';
import * as React from 'react';
import { Suspense, use, useState } from 'react';
import { MessageResponse } from 'src/components/MessageResponse.js';
import { extractTag } from 'src/utils/messages.js';
import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js';
@@ -15,8 +17,11 @@ import { FilePathLink } from 'src/components/FilePathLink.js';
import type { ToolProgressData } from 'src/Tool.js';
import type { ProgressMessage } from 'src/types/message.js';
import { getCwd } from 'src/utils/cwd.js';
import { getPatchForDisplay } from 'src/utils/diff.js';
import { getDisplayPath } from 'src/utils/file.js';
import { logError } from 'src/utils/log.js';
import { getPlansDirectory } from 'src/utils/plans.js';
import { openForScan, readCapped } from 'src/utils/readEditContext.js';
import type { Output } from './FileWriteTool.js';
const MAX_LINES_TO_RENDER = 10;
@@ -122,10 +127,115 @@ export function renderToolUseMessage(
}
export function renderToolUseRejectedMessage(
{ file_path }: { file_path: string; content: string },
{ file_path, content }: { file_path: string; content: string },
{ style, verbose }: { style?: 'condensed'; verbose: boolean },
): React.ReactNode {
return <FileEditToolUseRejectedMessage file_path={file_path} operation="write" style={style} verbose={verbose} />;
return <WriteRejectionDiff filePath={file_path} content={content} style={style} verbose={verbose} />;
}
type RejectionDiffData =
| { type: 'create' }
| { type: 'update'; patch: StructuredPatchHunk[]; oldContent: string }
| { type: 'error' };
function WriteRejectionDiff({
filePath,
content,
style,
verbose,
}: {
filePath: string;
content: string;
style?: 'condensed';
verbose: boolean;
}): React.ReactNode {
const [dataPromise] = useState(() => loadRejectionDiff(filePath, content));
const firstLine = content.split('\n')[0] ?? null;
const createFallback = (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="write"
content={content}
firstLine={firstLine}
verbose={verbose}
/>
);
return (
<Suspense fallback={createFallback}>
<WriteRejectionBody
promise={dataPromise}
filePath={filePath}
firstLine={firstLine}
createFallback={createFallback}
style={style}
verbose={verbose}
/>
</Suspense>
);
}
function WriteRejectionBody({
promise,
filePath,
firstLine,
createFallback,
style,
verbose,
}: {
promise: Promise<RejectionDiffData>;
filePath: string;
firstLine: string | null;
createFallback: React.ReactNode;
style?: 'condensed';
verbose: boolean;
}): React.ReactNode {
const data = use(promise);
if (data.type === 'create') return createFallback;
if (data.type === 'error') {
return (
<MessageResponse>
<Text>(No changes)</Text>
</MessageResponse>
);
}
return (
<FileEditToolUseRejectedMessage
file_path={filePath}
operation="update"
patch={data.patch}
firstLine={firstLine}
fileContent={data.oldContent}
style={style}
verbose={verbose}
/>
);
}
async function loadRejectionDiff(filePath: string, content: string): Promise<RejectionDiffData> {
try {
const fullFilePath = isAbsolute(filePath) ? filePath : resolve(getCwd(), filePath);
const handle = await openForScan(fullFilePath);
if (handle === null) return { type: 'create' };
let oldContent: string | null;
try {
oldContent = await readCapped(handle);
} finally {
await handle.close();
}
// File exceeds MAX_SCAN_BYTES — fall back to the create view rather than
// OOMing on a diff of a multi-GB file.
if (oldContent === null) return { type: 'create' };
const patch = getPatchForDisplay({
filePath,
fileContents: oldContent,
edits: [{ old_string: oldContent, new_string: content, replace_all: false }],
});
return { type: 'update', patch, oldContent };
} catch (e) {
// User may have manually applied the change while the diff was shown.
logError(e as Error);
return { type: 'error' };
}
}
export function renderToolUseErrorMessage(
@@ -179,6 +289,8 @@ export function renderToolResultMessage(
<FileEditToolUpdatedMessage
filePath={filePath}
structuredPatch={structuredPatch}
firstLine={content.split('\n')[0] ?? null}
fileContent={originalFile ?? undefined}
style={style}
verbose={verbose}
previewHint={isPlanFile ? '/plan to preview' : undefined}

78
progress.md Normal file
View File

@@ -0,0 +1,78 @@
# Code Review Progress
## 2026-05-03 — 第一轮 CRUD 业务逻辑层 Code Review
### 审查范围
审查了 4 个核心 CRUD 模块:任务管理(tasks.ts)、设置管理(settings.ts)、插件管理(installedPluginsManager.ts)、团队协作邮箱(teammateMailbox.ts)。
### 变更内容
1. **新增 `src/utils/__tests__/tasks.test.ts`** — 37 个测试覆盖完整 CRUD 操作:创建/读取/更新/删除任务、高水位标记防 ID 复用、文件锁并发安全、blockTask 双向关系、claimTask 竞态保护(含 agent_busy 检查、resetTaskList、通知信号机制、并发创建唯一 ID 验证。
### Code Review 发现
- tasks.ts 架构合理,文件锁+高水位标记保证了并发安全
- settings.ts 依赖链过深MDM/远程管理/文件系统63 个现有测试覆盖良好
- installedPluginsManager.ts V1→V2 迁移逻辑清晰,内存/磁盘状态分离设计良好
- teammateMailbox.ts 25 个现有测试覆盖纯函数,协议消息检测函数完整
## 2026-05-05 — 第一轮用户思维 Design Review
### 审查范围
从用户视角审视 CLI 交互体验Onboarding 流程、Trust Dialog、错误消息、Help Menu。聚焦非代码层面的用户友好性问题。
### 发现的不友好问题
1. **错误消息缺乏可操作提示**budget 超限/max turns 用尽时仅告知"出错了",未指导用户如何继续
2. **Onboarding 安全说明冰冷**"Security notes"标题过于技术化,用户容易跳过
3. **Trust Dialog 文案冗长**:安全检查对话框用语偏官方,核心信息被淹没
### 变更内容
1. **`src/cli/print.ts`** — 为 3 种错误子类型budget/turns/structured-output添加 Tip 提示行,告知用户具体的解决方式
2. **`src/QueryEngine.ts`** — 预算超限错误消息添加 `--max-budget-usd` 指引
3. **`src/components/Onboarding.tsx`** — 安全步骤标题改为 "Before you start, keep in mind",条目文案更口语化
4. **`src/components/TrustDialog/TrustDialog.tsx`** — 精简为两句核心信息,降低认知负荷
5. **`src/cli/__tests__/userFacingErrorMessages.test.ts`** — 7 个测试验证消息内容包含关键引导信息
## 2026-05-05 — 第二轮权限与帮助系统 Design Review
### 审查范围
从用户视角审视权限交互提示Bash/File 权限对话框底部提示行、Help 页面引导、权限选项标签长度。
### 发现的不友好问题
1. **权限对话框底部提示语义模糊**"Esc to cancel" 不如 "Esc to reject" 明确,"Tab to amend" 用户不知能做什么
2. **Help General 页面缺乏新手引导**:只有一句话 + 全部快捷键,新用户不知从何开始
3. **.claude/ 文件夹权限选项标签过长**60+ 字符),窄终端截断
### 变更内容
1. **`src/components/HelpV2/General.tsx`** — 添加 3 步"Getting started"引导,取代原来的单段描述
2. **`src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx`** — 底部 "cancel"→"reject""amend"→"add feedback"
3. **`src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx`** — 同步底部提示用词
4. **`src/components/permissions/FilePermissionDialog/permissionOptions.tsx`** — .claude/ 选项标签从 60 字符缩至 49 字符
5. **`src/components/HelpV2/__tests__/General.test.ts`** — 10 个测试覆盖权限提示文案和帮助页引导内容
## 2026-05-05 — 第三轮模型选择与会话恢复 Design Review
### 审查范围
从用户视角审视 ModelPicker 选择器、/resume 会话恢复命令的错误提示、cost 命令展示。
### 发现的不友好问题
1. **ModelPicker 副标题信息过载**:一句话里混合了模型切换说明和 --model 参数提示,新用户容易困惑
2. **Resume 错误提示缺乏操作指导**"Session X was not found" 没告诉用户怎么列出所有会话
### 变更内容
1. **`src/components/ModelPicker.tsx`** — 副标题从技术说明改为操作提示("← → 调整 effortSpace 切换 1M context"),控制在 120 字符内
2. **`src/commands/resume/resume.tsx`** — 错误提示添加 "Run /resume to browse" 操作引导
3. **`src/commands/resume/__tests__/resume.test.ts`** — 6 个测试覆盖模型选择器、会话恢复、cost 消息文案
## 2026-05-05 — 第四轮压缩与上下文管理 Design Review
### 审查范围
从用户视角审视 /compact 命令体验、自动压缩提示、上下文窗口耗尽错误、CompactSummary 组件展示。
### 发现的不友好问题
1. **"Not enough messages to compact" 缺乏指导**:用户不知下一步该做什么
2. **"Conversation too long" 提示的 "Press esc twice" 操作不直观**esc twice 对用户来说是模糊的操作
3. **"Compact summary" 标题对用户没有信息量**:自动压缩时用户不知道发生了什么
### 变更内容
1. **`src/services/compact/compact.ts`** — "Not enough messages" 添加 "Send a few more messages first" 引导;"Conversation too long" 改为建议 `/compact``/clear`
2. **`src/components/CompactSummary.tsx`** — 自动压缩标题从 "Compact summary" 改为 "Conversation summarized to free up context",快捷键提示从 "expand" 改为 "view summary"
3. **`src/components/__tests__/compactMessages.test.ts`** — 7 个测试覆盖压缩错误消息和展示文案

View File

@@ -1,13 +1,23 @@
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const pkgPath = resolve(__dirname, '..', 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
/**
* Shared MACRO define map used by both dev.ts (runtime -d flags)
* and build.ts (Bun.build define option).
*
* Each value is a JSON-stringified expression that replaces the
* corresponding MACRO.* identifier at transpile / bundle time.
*
* VERSION is read from package.json to avoid version drift.
*/
export function getMacroDefines(): Record<string, string> {
return {
'MACRO.VERSION': JSON.stringify('2.1.888'),
'MACRO.VERSION': JSON.stringify(pkg.version),
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
'MACRO.FEEDBACK_CHANNEL': JSON.stringify(''),
'MACRO.ISSUES_EXPLAINER': JSON.stringify(''),
@@ -52,10 +62,10 @@ export const DEFAULT_BUILD_FEATURES = [
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub启用后会抑制 auto compact 导致上下文管理完全失效
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
// 'FORK_SUBAGENT', // 已禁用:启用后 prompt 引导模型用 fork继承父模型)替代 Explorehaiku导致探索任务使用同等级模型
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
'KAIROS', // Kairos 定时任务系统核心
// 'COORDINATOR_MODE', // 已禁用AgentSummary 30s fork 循环GB 级泄露主因
'COORDINATOR_MODE', // 多 worker 编排模式AgentSummary 泄露已在 52b61c2c 修复)
// 'LAN_PIPES', // 依赖 UDS_INBOX已随 UDS_INBOX 恢复)
'BG_SESSIONS', // 后台会话管理ps/logs/attach/kill
'TEMPLATES', // 模板任务new/list/reply 子命令)

View File

@@ -14,7 +14,12 @@ const __dirname = dirname(__filename)
const projectRoot = join(__dirname, '..')
const cliPath = join(projectRoot, 'src/entrypoints/cli.tsx')
const defines = getMacroDefines()
const defines = {
...getMacroDefines(),
// React production mode — prevents 6,889+ _debugStack Error objects
// (12MB) from accumulating during long-running sessions.
'process.env.NODE_ENV': JSON.stringify('production'),
}
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
'-d',

View File

@@ -14,20 +14,18 @@ import { execSync } from 'node:child_process'
const outdir = 'dist'
async function postBuild() {
// Step 1: Patch globalThis.Bun destructuring from third-party deps
const files = await readdir(outdir, { recursive: true })
// Step 1: Patch globalThis.Bun destructuring in the single bundled file
const cliPath = join(outdir, 'cli.js')
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
const BUN_DESTRUCTURE_SAFE =
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
let bunPatched = 0
for (const file of files) {
const filePath = join(outdir, file)
if (typeof file !== 'string' || !file.endsWith('.js')) continue
const content = await readFile(filePath, 'utf-8')
{
const content = await readFile(cliPath, 'utf-8')
if (BUN_DESTRUCTURE.test(content)) {
await writeFile(
filePath,
cliPath,
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
)
bunPatched++

View File

@@ -0,0 +1,132 @@
# Feature: 20260502_F001 - fork-agent-redesign
## 需求背景
当前 `FORK_SUBAGENT` feature flag 是一个"一刀切"开关,启用时同时强制三件事:
1. 所有省略 `subagent_type` 的 agent 调用隐式走 fork 路径(继承父级完整上下文和模型)
2. 所有 agent spawn 强制异步(`forceAsync` 绑定在 `isForkSubagentEnabled()` 上)
3. prompt 引导模型优先省略 `subagent_type`,导致大部分 agent 都用同等级模型(贵)
这导致探索任务被迫使用与父级相同的模型(而非 haikutoken 消耗大增。因此该 flag 在 `defines.ts` 中被注释禁用。
## 目标
- 将 fork 从隐式行为改为**显式参数触发**`fork: true`
- FORK_SUBAGENT flag 只控制 fork 能力的可用性,**不再影响 `forceAsync` 等其他行为**
- 模型始终继承父级(保持现有行为)
- **完全向后兼容**——不传 `fork` 参数时行为与当前flag 关闭时)一致
## 方案设计
### Schema 变更
Agent tool 参数新增 `fork?: boolean`,仅在 `FORK_SUBAGENT` flag 启用时可见schema 动态裁剪,复用现有的 schema memo 模式)。
```ts
// inputSchema 中新增
fork: z.boolean().optional().describe(
'Set to true to fork from the parent conversation context. '
'The child inherits full history, system prompt, and model. '
'Requires FORK_SUBAGENT feature flag.'
)
```
flag 关闭时schema 通过 `.omit({ fork: true })` 裁剪掉该字段(与当前 `run_in_background` 的裁剪方式一致)。
### 路由逻辑重构
`AgentTool.tsx` call() 中的路由从当前的隐式判断:
```ts
// 旧行为:省略 subagent_type → forkflag 开启时)
const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType);
const isForkPath = effectiveType === undefined;
```
改为显式参数触发:
```ts
// 新行为:显式 fork 参数触发fork 优先级高于 subagent_type
const isForkPath = input.fork === true && isForkSubagentEnabled();
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
```
#### 决策表
| `fork` | `subagent_type` | flag 开 | 结果 |
|--------|----------------|---------|------|
| `true` | 有值 | 是 | fork 路径,**忽略 subagent_type** |
| `true` | 省略 | 是 | fork 路径(继承上下文) |
| `true` | * | 否 | 忽略 fork走 subagent_type 或 general-purpose |
| `false`/省略 | 有值 | * | 走指定 agent 类型(原有行为) |
| `false`/省略 | 省略 | * | 走 general-purpose原有行为 |
核心原则:**`fork: true` 是最高优先级**(当 flag 开启时),但 flag 关闭时静默降级,不影响原有行为。
### 后台运行由参数决定
fork agent 是否后台运行由 `run_in_background` 参数决定,与普通 agent 一致。`forceAsync` 不再绑定 `isForkSubagentEnabled()`
```ts
// forceAsync 不再受 isForkSubagentEnabled() 影响
const forceAsync = /* 其他条件coordinator, assistant mode 等)*/;
```
fork agent 与普通 agent 使用相同的 `run_in_background` 参数判断逻辑:
- `run_in_background: true` → 后台异步运行
- `run_in_background: false` / 省略 → 同步阻塞运行
### prompt 调整
移除引导模型"省略 subagent_type 以触发 fork"的 prompt 文本。改为说明 `fork: true` 的适用场景:
> When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use `fork: true`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
### isForkSubagentEnabled() 精简
函数签名和行为保持不变,但调用方语义改变:从"隐式路由判断"变为"参数校验门控"。
```ts
export function isForkSubagentEnabled(): boolean {
if (!feature('FORK_SUBAGENT')) return false;
if (isCoordinatorMode()) return false;
if (getIsNonInteractiveSession()) return false;
return true;
}
```
### 不变的部分
以下保持不变,无需修改:
- `buildForkedMessages()` — fork 消息构建逻辑
- `isInForkChild()` — 递归 fork 防护
- `FORK_AGENT` — fork agent 定义model: 'inherit', permissionMode: 'bubble'
- `buildChildMessage()` — fork 子 agent 指令模板
- `buildWorktreeNotice()` — worktree 隔离通知
## 实现要点
1. **Schema 动态裁剪**`inputSchema` memo 中根据 `isForkSubagentEnabled()` 决定是否 `.omit({ fork: true })`flag 关闭时字段不存在于 schema
2. **省略 `subagent_type` 恢复原有行为**:不再隐式走 fork恢复为 `GENERAL_PURPOSE_AGENT`
3. **`defines.ts` 注释更新**`FORK_SUBAGENT` 保持注释状态,但描述更新为新行为(显式参数触发,不影响探索任务模型选择)
4. **递归 fork 防护**:保持现有 `isInForkChild()` + `querySource` 双重检测
### 涉及文件
| 文件 | 改动 |
|------|------|
| `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` | 新增 `fork` 参数解析路由逻辑重构forceAsync 解耦 |
| `packages/builtin-tools/src/tools/AgentTool/prompt.ts` | 移除隐式 fork 引导,新增 `fork: true` 使用场景说明 |
| `scripts/defines.ts` | 更新 `FORK_SUBAGENT` 注释描述 |
## 验收标准
- [ ] `fork: true` + `FORK_SUBAGENT` 启用 → 走 fork 路径,继承父级上下文和模型
- [ ] `fork: true` + `subagent_type` 有值 + flag 开 → fork 路径,忽略 subagent_type
- [ ] `fork: true` + `FORK_SUBAGENT` 关闭 → 忽略 fork走普通 agent 路径
- [ ] 不传 `fork` 参数 → 行为与当前 flag 关闭时完全一致(走 general-purpose 或指定 subagent_type
- [ ] `forceAsync` 不再因 `isForkSubagentEnabled()` 而全局生效
- [ ] fork 子 agent 的后台/同步行为由 `run_in_background` 参数控制,与普通 agent 一致
- [ ] `bun run precheck` 零错误通过

View File

@@ -0,0 +1,170 @@
# Fork Agent 显式参数触发重构 人工验收清单
**生成时间:** 2026-05-02
**关联计划:** spec/feature_20260502_F001_fork-agent-redesign/spec-plan.md
**关联设计:** spec/feature_20260502_F001_fork-agent-redesign/spec-design.md
---
## 验收前准备
### 环境要求
- [ ] [AUTO] 检查 Bun 版本: `bun --version`
- [ ] [AUTO] 安装依赖: `bun install`
---
## 验收项目
### 场景 1Schema 与类型变更
#### - [x] 1.1 fork 字段已添加到 baseInputSchema
- **来源:** spec-plan.md Task 1 / spec-design.md §Schema 变更
- **目的:** 确认 fork 参数在基础 schema 中声明
- **操作步骤:**
1. [A] `grep -n 'fork:' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5` → 期望包含: `fork: z`schema 定义)和 `fork?: boolean`(类型声明)
#### - [x] 1.2 fork 字段在 flag 关闭时被 schema 裁剪
- **来源:** spec-plan.md Task 1 / spec-design.md §Schema 变更
- **目的:** 确认 FORK_SUBAGENT 关闭时 fork 字段不可见
- **操作步骤:**
1. [A] `grep -n 'omit.*fork' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `schema.omit({ fork: true })`
#### - [x] 1.3 AgentToolInput 类型包含 fork 字段
- **来源:** spec-plan.md Task 1
- **目的:** 确认类型声明与 schema 一致
- **操作步骤:**
1. [A] `grep -n 'fork' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | grep 'AgentToolInput\|fork?:'` → 期望包含: `fork?: boolean`
---
### 场景 2路由逻辑重构
#### - [x] 2.1 isForkPath 使用显式 fork 参数判断
- **来源:** spec-plan.md Task 1 / spec-design.md §路由逻辑重构
- **目的:** 确认 fork 路径由 fork=true 显式触发
- **操作步骤:**
1. [A] `grep -n 'isForkPath' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `fork === true && isForkSubagentEnabled()`
#### - [x] 2.2 forceAsync 已完全移除
- **来源:** spec-plan.md Task 1 / spec-design.md §后台运行由参数决定
- **目的:** 确认 forceAsync 不再绑定 isForkSubagentEnabled()
- **操作步骤:**
1. [A] `grep -c 'forceAsync' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望精确: `0`
#### - [x] 2.3 isForkSubagentEnabled() 仅用于 schema 裁剪和路由判断
- **来源:** spec-plan.md Task 1
- **目的:** 确认 isForkSubagentEnabled() 不再影响 forceAsync/shouldRunAsync
- **操作步骤:**
1. [A] `grep -n 'isForkSubagentEnabled' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: 仅出现在 inputSchema 裁剪和 isForkPath 路由判断中
#### - [x] 2.4 shouldRunAsync 由 run_in_background 控制
- **来源:** spec-plan.md Task 1 / spec-design.md §后台运行由参数决定
- **目的:** 确认异步行为与普通 agent 一致
- **操作步骤:**
1. [A] `grep -n 'run_in_background' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5` → 期望包含: `shouldRunAsync` 计算中含 `run_in_background === true`,无 `forceAsync`
#### - [x] 2.5 enableSummarization 使用 isForkPath 而非 isForkSubagentEnabled()
- **来源:** spec-plan.md Task 1
- **目的:** 确认摘要仅在当前调用实际走 fork 路径时启用
- **操作步骤:**
1. [A] `grep -n 'enableSummarization' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `isForkPath`,不包含 `isForkSubagentEnabled()`
---
### 场景 3Prompt 文本更新
#### - [x] 3.1 不再包含 "omit subagent_type" 引导文本
- **来源:** spec-plan.md Task 2 / spec-design.md §prompt 调整
- **目的:** 确认隐式 fork 触发引导已移除
- **操作步骤:**
1. [A] `grep -c 'omit' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望精确: `0`
#### - [x] 3.2 包含 "fork: true" 显式参数说明
- **来源:** spec-plan.md Task 2 / spec-design.md §prompt 调整
- **目的:** 确认新的显式 fork 使用说明已写入
- **操作步骤:**
1. [A] `grep -c 'fork: true' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望包含: >= 3shared section + whenToForkSection + forkExamples
#### - [x] 3.3 背景任务说明条件不再含 !forkEnabled
- **来源:** spec-plan.md Task 2
- **目的:** 确认 fork 解耦后背景任务说明在 fork 启用时也显示
- **操作步骤:**
1. [A] `grep -n 'forkEnabled' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望包含: 所有匹配行均为 `forkEnabled ?` 形式,不包含 `!forkEnabled`
#### - [x] 3.4 术语从 "fresh agent" 更新为 "non-fork"
- **来源:** spec-plan.md Task 2
- **目的:** 确认 prompt 术语与新的显式 fork 逻辑一致
- **操作步骤:**
1. [A] `grep -c 'non-fork' packages/builtin-tools/src/tools/AgentTool/prompt.ts` → 期望包含: >= 2
---
### 场景 4边界与回归决策表验证
#### - [x] 4.1 fork=true + subagent_type + flag 开 → fork 路径,忽略 subagent_type
- **来源:** spec-design.md §决策表 + spec-plan.md Task 3
- **目的:** 确认 fork 优先级高于 subagent_type
- **操作步骤:**
1. [A] `grep -A2 'isForkPath = fork === true' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType`fork 生效时 effectiveType 被 isForkPath 覆盖subagent_type 不影响路由)
#### - [x] 4.2 fork=true + flag 关闭 → 忽略 fork走普通 agent 路径
- **来源:** spec-design.md §决策表
- **目的:** 确认 flag 关闭时 fork 静默降级
- **操作步骤:**
1. [A] `grep 'isForkPath = fork === true && isForkSubagentEnabled' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `&& isForkSubagentEnabled()`(双条件确保 flag 关闭时 isForkPath 为 false
#### - [x] 4.3 fork 省略 → 走 general-purpose 或指定 subagent_type
- **来源:** spec-design.md §决策表
- **目的:** 确认向后兼容
- **操作步骤:**
1. [A] `grep 'effectiveType = subagent_type ??' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` → 期望包含: `GENERAL_PURPOSE_AGENT.agentType`
---
### 场景 5defines.ts 注释与构建验证
#### - [x] 5.1 FORK_SUBAGENT 注释已更新为新行为描述
- **来源:** spec-plan.md Task 1 / spec-design.md §实现要点
- **目的:** 确认注释反映显式参数触发设计
- **操作步骤:**
1. [A] `grep 'FORK_SUBAGENT' scripts/defines.ts` → 期望包含: `显式 \`fork: true\` 参数触发`
#### - [x] 5.2 单元测试全部通过
- **来源:** spec-plan.md Task 1 + Task 2
- **目的:** 确认路由逻辑和 prompt 文本测试通过
- **操作步骤:**
1. [A] `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/ 2>&1 | tail -10` → 期望包含: `0 fail`
#### - [x] 5.3 precheck 零错误通过
- **来源:** spec-plan.md Task 3 / spec-design.md §验收标准
- **目的:** 确认 typecheck + lint + test 无回归
- **操作步骤:**
1. [A] `bun run precheck` → 期望包含: 零错误退出
---
## 验收结果汇总
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|------|------|--------|-----|-----|------|
| 场景 1 | 1.1 | fork 字段已添加到 baseInputSchema | 1 | 0 | ✅ |
| 场景 1 | 1.2 | fork 字段在 flag 关闭时被 schema 裁剪 | 1 | 0 | ✅ |
| 场景 1 | 1.3 | AgentToolInput 类型包含 fork 字段 | 1 | 0 | ✅ |
| 场景 2 | 2.1 | isForkPath 使用显式 fork 参数判断 | 1 | 0 | ✅ |
| 场景 2 | 2.2 | forceAsync 已完全移除 | 1 | 0 | ✅ |
| 场景 2 | 2.3 | isForkSubagentEnabled() 仅用于 schema 裁剪和路由判断 | 1 | 0 | ✅ |
| 场景 2 | 2.4 | shouldRunAsync 由 run_in_background 控制 | 1 | 0 | ✅ |
| 场景 2 | 2.5 | enableSummarization 使用 isForkPath | 1 | 0 | ✅ |
| 场景 3 | 3.1 | 不再包含 "omit subagent_type" 引导文本 | 1 | 0 | ✅ |
| 场景 3 | 3.2 | 包含 "fork: true" 显式参数说明 | 1 | 0 | ✅ |
| 场景 3 | 3.3 | 背景任务条件不再含 !forkEnabled | 1 | 0 | ✅ |
| 场景 3 | 3.4 | 术语更新为 "non-fork" | 1 | 0 | ✅ |
| 场景 4 | 4.1 | fork=true + subagent_type + flag 开 → fork 路径 | 1 | 0 | ✅ |
| 场景 4 | 4.2 | fork=true + flag 关闭 → 忽略 fork | 1 | 0 | ✅ |
| 场景 4 | 4.3 | fork 省略 → general-purpose向后兼容 | 1 | 0 | ✅ |
| 场景 5 | 5.1 | FORK_SUBAGENT 注释已更新 | 1 | 0 | ✅ |
| 场景 5 | 5.2 | 单元测试全部通过 | 1 | 0 | ✅ |
| 场景 5 | 5.3 | precheck 零错误通过 | 1 | 0 | ✅ |
**验收结论:** ✅ 全部通过 / ⬜ 存在问题

View File

@@ -0,0 +1,317 @@
# Fork Agent 显式参数触发重构 执行计划
**目标:** 将 FORK_SUBAGENT 从隐式行为改为显式 `fork: true` 参数触发,解耦 forceAsync保持向后兼容
**技术栈:** TypeScript, Zod schema, Bun test, React/Ink (prompt UI)
**设计文档:** spec/feature_20260502_F001_fork-agent-redesign/spec-design.md
## 改动总览
- 本次改动涉及 3 个修改文件:`AgentTool.tsx`Schema + 路由 + forceAsync 解耦)、`prompt.ts`(引导文本)、`defines.ts`(注释更新)。新建 1 个测试文件 `prompt.test.ts`
- Task 1 是 Task 2 的前置Task 1 完成 Schema 变更和路由重构后Task 2 才能安全地调整 prompt 文本prompt 行为描述必须与代码实际行为一致)。
- 关键设计决策fork 参数添加到 `baseInputSchema` 而非 `fullInputSchema`,因为 fork 是基础 agent 能力而非 multi-agent 特有能力。
---
### Task 0: 环境准备
**背景:**
确保构建和测试工具链在当前开发环境中可用,避免后续 Task 因环境问题阻塞。
**执行步骤:**
- [x] 验证构建工具可用
- `bun --version`
- 确认输出 Bun 版本号
- [x] 验证测试工具可用
- `bun test --help 2>&1 | head -3`
- 确认输出包含 test 相关帮助信息
**检查步骤:**
- [x] 构建命令执行成功
- `bun run build 2>&1 | tail -5`
- 预期: 构建成功,输出包含 dist/cli.js
- [x] 现有测试通过
- `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/ 2>&1 | tail -10`
- 预期: 所有现有测试通过,无失败
---
### Task 1: 核心路由重构
**背景:**
[业务语境] — 当前 `FORK_SUBAGENT` flag 启用时,所有省略 `subagent_type` 的 agent 调用隐式走 fork 路径导致探索任务被迫使用父级同等级模型token 消耗大增。本次重构将 fork 从隐式行为改为显式 `fork: true` 参数触发。
[修改原因] — `AgentTool.tsx` 中路由逻辑(`effectiveType` / `isForkPath`)通过 `subagent_type` 是否省略来判断 fork 路径,需改为通过 `fork` 布尔参数显式触发。同时 `forceAsync` 变量绑定在 `isForkSubagentEnabled()` 上,导致 fork flag 开启时所有 agent 强制异步,需解耦。
[上下游影响] — 本 Task 的输出(`fork` 参数、新路由逻辑)被 Task 2prompt 文本调整)依赖。本 Task 无前置依赖。
**涉及文件:**
- 修改: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
- 修改: `scripts/defines.ts`
**执行步骤:**
- [x] 在 baseInputSchema 中新增 `fork` 字段
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:baseInputSchema()` (~L136-152),在 `run_in_background` 字段之后
-`run_in_background` 字段的闭合 `),` 之后,闭合 `})` 之前,新增:
```ts
fork: z
.boolean()
.optional()
.describe(
'Set to true to fork from the parent conversation context. The child inherits full history, system prompt, and model. Requires FORK_SUBAGENT feature flag.',
),
```
- 原因: fork 参数需要在基础 schema 中声明,与 `subagent_type`、`run_in_background` 同级,因为它是所有 agent 调用的可选参数,不限于 multi-agent 场景。
- [x] 重构 inputSchema memo 的裁剪逻辑
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:inputSchema()` (~L193-204)
- 将 L194-203 替换为:
```ts
let schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true });
if (isBackgroundTasksDisabled) {
schema = schema.omit({ run_in_background: true });
}
if (!isForkSubagentEnabled()) {
schema = schema.omit({ fork: true });
}
return schema;
```
- 同时删除 L196-202 的 GrowthBook 注释块(该注释描述的是旧 `forceAsync` 行为,已不适用)。
- 原因: fork 字段仅在 `FORK_SUBAGENT` flag 启用时可见;`run_in_background` 不再受 `isForkSubagentEnabled()` 影响,两者独立裁剪。
- [x] 更新 AgentToolInput 类型声明
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` (~L211-217)`AgentToolInput` type 定义
- 在 `z.infer<ReturnType<typeof baseInputSchema>> & {` 的下一行(`name?: string;` 之前),新增 `fork?: boolean;`
- 原因: 类型声明必须包含 `fork` 字段,确保 `call()` 解构时有正确的类型推断。
- [x] 更新 inputSchema 附近的 fork gate 注释
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` (~L207-210)`AgentToolInput` 上方的注释
- 将 L209-210 的注释:
```ts
// subagent_type is optional; call() defaults it to general-purpose when the
// fork gate is off, or routes to the fork path when the gate is on.
```
- 替换为:
```ts
// subagent_type is optional; call() defaults it to general-purpose.
// fork is gated by FORK_SUBAGENT flag; when omitted or flag is off, no fork.
```
- 原因: 旧行为描述与新的显式 fork 触发逻辑不一致,需要更新。
- [x] 在 call() 解构中新增 `fork` 参数
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L322-333),参数解构
- 在 `subagent_type,` 之后L324新增 `fork,`
- 原因: `call()` 需要从输入中提取 `fork` 值用于路由判断。
- [x] 重构路由逻辑为显式 fork 触发
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L409-414)
- 将 L409-414 替换为:
```ts
// Fork routing: explicit `fork: true` parameter triggers the fork path
// (inherits parent context and model). Requires FORK_SUBAGENT flag.
// subagent_type is ignored when fork takes effect.
const isForkPath = fork === true && isForkSubagentEnabled();
const effectiveType = subagent_type ?? GENERAL_PURPOSE_AGENT.agentType;
```
- 原因: 将隐式路由(省略 `subagent_type` 触发 fork改为显式参数触发`fork: true`),同时保持 `subagent_type` 省略时走 general-purpose 的原有行为。
- [x] 删除 forceAsync 变量及其注释
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L695-697)
- 删除 L695-697注释 + `const forceAsync = isForkSubagentEnabled();`
- 原因: `forceAsync` 不再绑定 `isForkSubagentEnabled()`fork agent 的异步行为由 `run_in_background` 参数控制,与普通 agent 一致。
- [x] 从 shouldRunAsync 中移除 forceAsync 条件
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L708-715)
- 将 L708-715 的 `shouldRunAsync` 计算中的 `forceAsync ||` 移除:
```ts
const shouldRunAsync =
(run_in_background === true ||
selectedAgent.background === true ||
isCoordinator ||
assistantForceAsync ||
(proactiveModule?.isProactiveActive() ?? false)) &&
!isBackgroundTasksDisabled;
```
- 原因: `forceAsync` 变量已删除fork agent 不再全局强制异步。
- [x] 更新 enableSummarization 使用 isForkPath 替代 isForkSubagentEnabled()
- 位置: `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:call()` (~L892)
- 将:
```ts
enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(),
```
- 替换为:
```ts
enableSummarization: isCoordinator || isForkPath || getSdkAgentProgressSummariesEnabled(),
```
- 原因: `enableSummarization` 应仅在当前调用实际走 fork 路径时启用,而非 flag 全局启用。`isForkPath` 是当前调用的运行时判断结果。
- [x] 更新 defines.ts 中 FORK_SUBAGENT 的注释
- 位置: `scripts/defines.ts` (~L55)
- 将:
```ts
// 'FORK_SUBAGENT', // 已禁用:启用后 prompt 引导模型用 fork继承父模型替代 Explorehaiku导致探索任务使用同等级模型
```
- 替换为:
```ts
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
```
- 原因: 旧注释描述的是隐式 fork 行为的问题,新注释描述的是当前显式参数触发的设计。
- [x] 为路由逻辑重构编写单元测试
- 测试文件: `packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts`
- 测试场景(通过导出路由判断辅助函数或验证 inputSchema 裁剪行为):
- `isForkSubagentEnabled() 返回 false 时`: `inputSchema()` 不包含 `fork` 字段(通过 `.omit({ fork: true })` 裁剪)
- `isBackgroundTasksDisabled 为 true 时`: `inputSchema()` 不包含 `run_in_background` 字段,但仍包含 `fork` 字段
- 两个条件同时满足时: `inputSchema()` 同时 omit `run_in_background` 和 `fork`
- 运行命令: `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `fork` 字段已添加到 baseInputSchema
- `grep -n 'fork:' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5`
- 预期: 输出至少包含 1 行 schema 定义中的 `fork:` 和 1 行类型中的 `fork?:`
- [x] 验证 forceAsync 已完全移除
- `grep -n 'forceAsync' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
- 预期: 无输出grep 返回非零退出码)
- [x] 验证 isForkSubagentEnabled() 在 call() 中仅用于路由判断
- `grep -n 'isForkSubagentEnabled' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
- 预期: 仅出现在 `inputSchema()` 的 `!isForkSubagentEnabled()` 裁剪条件和路由的 `fork === true && isForkSubagentEnabled()` 中,不出现在 shouldRunAsync 或 enableSummarization 中
- [x] 验证 defines.ts 注释已更新
- `grep 'FORK_SUBAGENT' scripts/defines.ts`
- 预期: 输出行包含 "显式 `fork: true` 参数触发"
- [x] 运行 precheck 确认无类型/lint/测试错误
- `bun run precheck`
- 预期: 零错误通过
---
### Task 2: Prompt 文本调整
**背景:**
[业务语境] — Task 1 将 fork 从隐式行为(省略 `subagent_type` 触发)改为显式参数(`fork: true`prompt.ts 中的引导文本必须同步更新,否则模型仍会尝试用旧方式触发 fork。
[修改原因] — 当前 prompt.ts 引导模型"省略 `subagent_type` 以触发 fork"~L85 `omit \`subagent_type\``),且 forkExamples 中省略了 `subagent_type`(隐式触发)。这些文本与 Task 1 的新路由逻辑矛盾。此外,背景任务说明的显示条件 `!forkEnabled` 不再正确——Task 1 已解耦 forceAsyncfork agent 不再强制异步,背景任务说明应在 fork 启用时也显示。
[上下游影响] — 本 Task 依赖 Task 1 完成Task 1 重构了路由逻辑,本 Task 更新对应的 prompt 文本)。本 Task 仅修改 prompt 文本,不影响运行时逻辑。
**涉及文件:**
- 修改: `packages/builtin-tools/src/tools/AgentTool/prompt.ts`
**执行步骤:**
- [x] 替换 `whenToForkSection` 中的 fork 触发说明
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `whenToForkSection` 模板字面量(~L80-97
- 将 `## When to fork` 标题下的第一段文本(从 "Fork yourself (omit..." 到 "...Do research before jumping to implementation.")替换为:
```
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use `fork: true`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
```
- "Don't peek."、"Don't race."、"Writing a fork prompt." 段落保持不变
- 原因: 移除"省略 subagent_type"的引导,改为说明 `fork: true` 的适用场景
- [x] 更新 `writingThePromptSection` 中的术语
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `writingThePromptSection` 模板字面量(~L99-113
- 将 ~L103 的条件文本从 `'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. '` 替换为 `'When spawning an agent without `fork: true`, it starts with zero context. '`
- 将 ~L110 的条件文本从 `'For fresh agents, terse'` 替换为 `'For non-fork agents, terse'`
- 原因: fork 通过 `fork: true` 显式触发,"fresh agent"与"fork"的对立不再准确,改为"non-fork agents"
- [x] 替换 `shared` section 中的 fork 使用说明
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `shared` 模板字面量(~L208-212
- 将整个条件分支(`forkEnabled ? ... : ...`)替换为统一文本:
```
When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.${forkEnabled ? ` Set \`fork: true\` to fork from the parent conversation context, inheriting full history and model.` : ''}
```
- 原因: 省略 `subagent_type` 现在总是走 general-purpose统一两分支为基础文本 + fork 追加说明
- [x] 移除背景任务说明的 `!forkEnabled` 条件
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内背景任务说明的条件判断(~L259-261
- 将条件从 `!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) && !isInProcessTeammate() && !forkEnabled` 改为 `!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) && !isInProcessTeammate()`
- 原因: Task 1 已解耦 forceAsyncfork agent 不再强制异步,背景任务说明应在 fork 启用时也显示
- [x] 更新 continue agent note 中的术语
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 continue agent 说明(~L267
- 将条件文本从 `'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.'` 替换为 `'Each non-fork Agent invocation starts without context — provide a complete task description.'`
- 原因: 与 writingThePromptSection 保持术语一致
- [x] 更新 `forkExamples` 中第一个示例调用,添加 `fork: true` 参数
- 位置: `packages/builtin-tools/src/tools/AgentTool/prompt.ts` `getPrompt()` 函数内 `forkExamples` 模板字面量(~L120-124
- 在 `Agent({...})` 调用中 `description:` 行之后添加 `fork: true,` 行
- 第二个示例(~L133-139是"mid-wait"场景无工具调用,保持不变;第三个示例(~L141-154有 `subagent_type: "code-reviewer"` 是 fresh agent 场景,保持不变
- 原因: 第一个示例展示 fork 用法,需要显式传入 `fork: true`
- [x] 为 prompt.ts 的 fork 相关文本变更编写单元测试
- 测试文件: `packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts`
- 测试场景:
- `forkEnabled = true` 时: prompt 不包含 "omit `subagent_type`" 文本,包含 "`fork: true`" 文本
- `forkEnabled = true` 时: prompt 包含 "non-fork" 术语(替代 "fresh agent"
- `forkEnabled = true` 时: prompt 包含 "Set `fork: true` to fork from the parent" 说明
- `forkEnabled = true` 时: prompt 包含背景任务说明(`run_in_background`
- `forkEnabled = false` 时: prompt 不包含 "`fork: true`" 文本,不包含 "When to fork" section
- `forkEnabled = false` 时: prompt 包含 "general-purpose agent" 回退说明
- Mock 列表: `isForkSubagentEnabled`(返回 true/false、`getFeatureValue_CACHED_MAY_BE_STALE`(返回 false、`shouldInjectAgentListInMessages`(返回 false、`isInProcessTeammate`(返回 false、`isTeammate`(返回 false、`getSubscriptionType`(返回 'pro')、`hasEmbeddedSearchTools`(返回 false、环境变量 `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` 未定义
- 运行命令: `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 prompt 中不再包含 "omit `subagent_type`" 引导文本
- `grep -n "omit" packages/builtin-tools/src/tools/AgentTool/prompt.ts`
- 预期: 无输出
- [x] 验证 prompt 中包含 "`fork: true`" 文本
- `grep -c "fork: true" packages/builtin-tools/src/tools/AgentTool/prompt.ts`
- 预期: 输出 >= 3shared section + whenToForkSection + forkExamples
- [x] 验证背景任务条件中不再包含 `!forkEnabled`
- `grep -n "forkEnabled" packages/builtin-tools/src/tools/AgentTool/prompt.ts`
- 预期: 所有匹配行均为 `forkEnabled ?` 形式的三元表达式条件,不包含 `!forkEnabled`
- [x] 运行 prompt 单元测试
- `bun test packages/builtin-tools/src/tools/AgentTool/__tests__/prompt.test.ts`
- 预期: 所有测试通过
- [x] 运行 precheck 确保无回归
- `bun run precheck`
- 预期: 零错误通过typecheck + lint + test
---
### Task 3: Fork Agent 显式参数触发 验收
**前置条件:**
- 启动命令: `bun run dev`(开发模式)
- 环境变量: `FEATURE_FORK_SUBAGENT=1` 启用 fork 功能
**端到端验证:**
1. 运行完整测试套件确保无回归
- `bun run precheck`
- 预期: typecheck + lint + test 全部通过,零错误
- 失败排查: 检查 Task 1AgentTool.tsx 路由逻辑)和 Task 2prompt.ts 文本)的修改
2. 验证 `fork: true` + flag 启用时走 fork 路径
- `grep -n 'isForkPath = fork === true' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
- 预期: 找到路由逻辑行,确认 `fork === true && isForkSubagentEnabled()` 条件
- 失败排查: 检查 Task 1 路由逻辑步骤
3. 验证 `fork` 参数在 flag 关闭时不在 schema 中
- `grep -n 'omit.*fork' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
- 预期: 找到 `schema.omit({ fork: true })` 行
- 失败排查: 检查 Task 1 inputSchema 裁剪逻辑
4. 验证 `forceAsync` 已完全移除,不再绑定 `isForkSubagentEnabled()`
- `grep -c 'forceAsync' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx`
- 预期: 0无匹配
- 失败排查: 检查 Task 1 forceAsync 删除步骤
5. 验证 prompt 中不再引导"省略 subagent_type 触发 fork"
- `grep -c 'omit.*subagent_type' packages/builtin-tools/src/tools/AgentTool/prompt.ts`
- 预期: 0无匹配
- `grep -c 'fork: true' packages/builtin-tools/src/tools/AgentTool/prompt.ts`
- 预期: >= 3shared section + whenToForkSection + forkExamples
- 失败排查: 检查 Task 2 prompt 文本替换步骤
6. 验证后台/同步行为由 `run_in_background` 参数控制
- `grep -n 'run_in_background' packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx | head -5`
- 预期: `shouldRunAsync` 计算中包含 `run_in_background === true` 条件,无 `forceAsync` 条件
- 失败排查: 检查 Task 1 shouldRunAsync 修改步骤

View File

@@ -41,11 +41,7 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
import type { APIError } from '@anthropic-ai/sdk'
import type {
CompactMetadata,
Message,
SystemCompactBoundaryMessage,
} from './types/message.js'
import type { Message, SystemCompactBoundaryMessage } from './types/message.js'
import type { OrphanedPermission } from './types/textInputTypes.js'
import { createAbortController } from './utils/abortController.js'
import type { AttributionState } from './utils/commitAttribution.js'
@@ -1051,7 +1047,9 @@ export class QueryEngine {
initialAppState.fastMode,
),
uuid: randomUUID(),
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
errors: [
`Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`,
],
}
return
}

View File

@@ -1,5 +1,4 @@
import { feature } from 'bun:bundle'
import { getKairosActive } from '../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
/**

View File

@@ -11,7 +11,6 @@ import {
logEvent,
logEventAsync,
} from '../services/analytics/index.js'
import { isInBundledMode } from '../utils/bundledMode.js'
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
import { logForDebugging } from '../utils/debug.js'
import { rcLog } from './rcDebugLog.js'

View File

@@ -72,7 +72,6 @@ import type {
SDKControlResponse,
} from '../entrypoints/sdk/controlTypes.js'
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'

View File

@@ -55,7 +55,6 @@ import type {
SDKControlResponse,
} from '../entrypoints/sdk/controlTypes.js'
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
/**
* StdoutMessage with optional session_id. The transport layer accepts

View File

@@ -2,7 +2,6 @@
* Companion display card — shown by /buddy (no args).
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
*/
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import { useInput } from '@anthropic/ink';
import { renderSprite } from './sprites.js';

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test'
/**
* Verify that user-facing error messages include actionable guidance.
* These are pure string-formatting tests — no side effects.
*/
describe('User-facing error messages', () => {
test('budget exceeded message includes budget and guidance', () => {
const maxBudgetUsd = 5.0
const message = `Error: Exceeded USD budget ($${maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`
expect(message).toContain('Exceeded USD budget')
expect(message).toContain('$5')
expect(message).toContain('--max-budget-usd')
expect(message).toContain('new session')
})
test('max turns message includes guidance', () => {
const maxTurns = 10
const message = `Error: Reached max turns (${maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`
expect(message).toContain('max turns')
expect(message).toContain('--max-turns')
expect(message).toContain('new session')
})
test('structured output retry message includes guidance', () => {
const message =
'Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.'
expect(message).toContain('structured output')
expect(message).toContain('Simplify your schema')
})
test('QueryEngine budget error includes actionable hint', () => {
const maxBudgetUsd = 3.0
const message = `Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`
expect(message).toContain('maximum budget')
expect(message).toContain('--max-budget-usd')
expect(message).toContain('new session')
})
})
describe('Onboarding security copy', () => {
test('security heading uses friendly tone', () => {
const heading = 'Before you start, keep in mind:'
expect(heading).not.toContain('Security')
expect(heading).toContain('Before you start')
})
test('trust dialog copy is concise', () => {
const body =
'Is this a project you trust? (Your own code, a well-known open source project, or work from your team).'
expect(body.length).toBeLessThan(120)
expect(body).toContain('trust')
})
})

View File

@@ -68,13 +68,3 @@ export class TmuxEngine implements BgEngine {
}
}
}
export function getTmuxInstallHint(): string {
if (process.platform === 'darwin') {
return 'Install with: brew install tmux'
}
if (process.platform === 'win32') {
return 'tmux is not natively available on Windows. Consider using WSL.'
}
return 'Install with: sudo apt install tmux (or your package manager)'
}

View File

@@ -6,7 +6,6 @@
import { stat } from 'fs/promises';
import pMap from 'p-map';
import { cwd } from 'process';
import React from 'react';
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
import { wrappedRender as render } from '@anthropic/ink';
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';

View File

@@ -112,7 +112,6 @@ import type {
ModelInfo,
SDKMessage,
SDKUserMessage,
SDKUserMessageReplay,
PermissionResult,
McpServerConfigForProcessTransport,
McpServerStatus,
@@ -961,14 +960,18 @@ export async function runHeadless(
writeToStdout(`Execution error`)
break
case 'error_max_turns':
writeToStdout(`Error: Reached max turns (${options.maxTurns})`)
writeToStdout(
`Error: Reached max turns (${options.maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`,
)
break
case 'error_max_budget_usd':
writeToStdout(`Error: Exceeded USD budget (${options.maxBudgetUsd})`)
writeToStdout(
`Error: Exceeded USD budget ($${options.maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`,
)
break
case 'error_max_structured_output_retries':
writeToStdout(
`Error: Failed to provide valid structured output after maximum retries`,
`Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.`,
)
}
}
@@ -5473,7 +5476,7 @@ function getStructuredIO(
*/
export async function handleOrphanedPermissionResponse({
message,
setAppState,
setAppState: _setAppState,
onEnqueued,
handledToolUseIds,
}: {

View File

@@ -1,422 +0,0 @@
import chalk from 'chalk'
import { logEvent } from 'src/services/analytics/index.js'
import {
getLatestVersion,
type InstallStatus,
installGlobalPackage,
} from 'src/utils/autoUpdater.js'
import { regenerateCompletionCache } from 'src/utils/completionCache.js'
import {
getGlobalConfig,
type InstallMethod,
saveGlobalConfig,
} from 'src/utils/config.js'
import { logForDebugging } from 'src/utils/debug.js'
import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js'
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
import {
installOrUpdateClaudePackage,
localInstallationExists,
} from 'src/utils/localInstaller.js'
import {
installLatest as installLatestNative,
removeInstalledSymlink,
} from 'src/utils/nativeInstaller/index.js'
import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
import { writeToStdout } from 'src/utils/process.js'
import { gte } from 'src/utils/semver.js'
import { getInitialSettings } from 'src/utils/settings/settings.js'
export async function update() {
logEvent('tengu_update_check', {})
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
writeToStdout(`Checking for updates to ${channel} version...\n`)
logForDebugging('update: Starting update check')
// Run diagnostic to detect potential issues
logForDebugging('update: Running diagnostic')
const diagnostic = await getDoctorDiagnostic()
logForDebugging(`update: Installation type: ${diagnostic.installationType}`)
logForDebugging(
`update: Config install method: ${diagnostic.configInstallMethod}`,
)
// Check for multiple installations
if (diagnostic.multipleInstallations.length > 1) {
writeToStdout('\n')
writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n')
for (const install of diagnostic.multipleInstallations) {
const current =
diagnostic.installationType === install.type
? ' (currently running)'
: ''
writeToStdout(`- ${install.type} at ${install.path}${current}\n`)
}
}
// Display warnings if any exist
if (diagnostic.warnings.length > 0) {
writeToStdout('\n')
for (const warning of diagnostic.warnings) {
logForDebugging(`update: Warning detected: ${warning.issue}`)
// Don't skip PATH warnings - they're always relevant
// The user needs to know that 'which claude' points elsewhere
logForDebugging(`update: Showing warning: ${warning.issue}`)
writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))
writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
}
}
// Update config if installMethod is not set (but skip for package managers)
const config = getGlobalConfig()
if (
!config.installMethod &&
diagnostic.installationType !== 'package-manager'
) {
writeToStdout('\n')
writeToStdout('Updating configuration to track installation method...\n')
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'
// Map diagnostic installation type to config install method
switch (diagnostic.installationType) {
case 'npm-local':
detectedMethod = 'local'
break
case 'native':
detectedMethod = 'native'
break
case 'npm-global':
detectedMethod = 'global'
break
default:
detectedMethod = 'unknown'
}
saveGlobalConfig(current => ({
...current,
installMethod: detectedMethod,
}))
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
}
// Check if running from development build
if (diagnostic.installationType === 'development') {
writeToStdout('\n')
writeToStdout(
chalk.yellow('Warning: Cannot update development build') + '\n',
)
await gracefulShutdown(1)
}
// Check if running from a package manager
if (diagnostic.installationType === 'package-manager') {
const packageManager = await getPackageManager()
writeToStdout('\n')
if (packageManager === 'homebrew') {
writeToStdout('Claude is managed by Homebrew.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
} else {
writeToStdout('Claude is up to date!\n')
}
} else if (packageManager === 'winget') {
writeToStdout('Claude is managed by winget.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
)
} else {
writeToStdout('Claude is up to date!\n')
}
} else if (packageManager === 'apk') {
writeToStdout('Claude is managed by apk.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION}${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
} else {
writeToStdout('Claude is up to date!\n')
}
} else {
// pacman, deb, and rpm don't get specific commands because they each have
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
// rpm: dnf/yum/zypper)
writeToStdout('Claude is managed by a package manager.\n')
writeToStdout('Please use your package manager to update.\n')
}
await gracefulShutdown(0)
}
// Check for config/reality mismatch (skip for package-manager installs)
if (
config.installMethod &&
diagnostic.configInstallMethod !== 'not set' &&
diagnostic.installationType !== 'package-manager'
) {
const runningType = diagnostic.installationType
const configExpects = diagnostic.configInstallMethod
// Map installation types for comparison
const typeMapping: Record<string, string> = {
'npm-local': 'local',
'npm-global': 'global',
native: 'native',
development: 'development',
unknown: 'unknown',
}
const normalizedRunningType = typeMapping[runningType] || runningType
if (
normalizedRunningType !== configExpects &&
configExpects !== 'unknown'
) {
writeToStdout('\n')
writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n')
writeToStdout(`Config expects: ${configExpects} installation\n`)
writeToStdout(`Currently running: ${runningType}\n`)
writeToStdout(
chalk.yellow(
`Updating the ${runningType} installation you are currently using`,
) + '\n',
)
// Update config to match reality
saveGlobalConfig(current => ({
...current,
installMethod: normalizedRunningType as InstallMethod,
}))
writeToStdout(
`Config updated to reflect current installation method: ${normalizedRunningType}\n`,
)
}
}
// Handle native installation updates first
if (diagnostic.installationType === 'native') {
logForDebugging(
'update: Detected native installation, using native updater',
)
try {
const result = await installLatestNative(channel, true)
// Handle lock contention gracefully
if (result.lockFailed) {
const pidInfo = result.lockHolderPid
? ` (PID ${result.lockHolderPid})`
: ''
writeToStdout(
chalk.yellow(
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
) + '\n',
)
await gracefulShutdown(0)
}
if (!result.latestVersion) {
process.stderr.write('Failed to check for updates\n')
await gracefulShutdown(1)
}
if (result.latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
)
} else {
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
}
await gracefulShutdown(0)
} catch (error) {
process.stderr.write('Error: Failed to install native update\n')
process.stderr.write(String(error) + '\n')
process.stderr.write('Try running "claude doctor" for diagnostics\n')
await gracefulShutdown(1)
}
}
// Fallback to existing JS/npm-based update logic
// Remove native installer symlink since we're not using native installation
// But only if user hasn't migrated to native installation
if (config.installMethod !== 'native') {
await removeInstalledSymlink()
}
logForDebugging('update: Checking npm registry for latest version')
logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`)
const npmTag = channel === 'stable' ? 'stable' : 'latest'
const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version`
logForDebugging(`update: Running: ${npmCommand}`)
const latestVersion = await getLatestVersion(channel)
logForDebugging(
`update: Latest version from npm: ${latestVersion || 'FAILED'}`,
)
if (!latestVersion) {
logForDebugging('update: Failed to get latest version from npm registry')
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
process.stderr.write('Unable to fetch latest version from npm registry\n')
process.stderr.write('\n')
process.stderr.write('Possible causes:\n')
process.stderr.write(' • Network connectivity issues\n')
process.stderr.write(' • npm registry is unreachable\n')
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
process.stderr.write(
' • Internal/development build not published to npm\n',
)
}
process.stderr.write('\n')
process.stderr.write('Try:\n')
process.stderr.write(' • Check your internet connection\n')
process.stderr.write(' • Run with --debug flag for more details\n')
const packageName =
MACRO.PACKAGE_URL ||
(process.env.USER_TYPE === 'ant'
? '@anthropic-ai/claude-cli'
: '@anthropic-ai/claude-code')
process.stderr.write(
` • Manually check: npm view ${packageName} version\n`,
)
process.stderr.write(' • Check if you need to login: npm whoami\n')
await gracefulShutdown(1)
}
// Check if versions match exactly, including any build metadata (like SHA)
if (latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
)
await gracefulShutdown(0)
}
writeToStdout(
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
)
writeToStdout('Installing update...\n')
// Determine update method based on what's actually running
let useLocalUpdate = false
let updateMethodName = ''
switch (diagnostic.installationType) {
case 'npm-local':
useLocalUpdate = true
updateMethodName = 'local'
break
case 'npm-global':
useLocalUpdate = false
updateMethodName = 'global'
break
case 'unknown': {
// Fallback to detection if we can't determine installation type
const isLocal = await localInstallationExists()
useLocalUpdate = isLocal
updateMethodName = isLocal ? 'local' : 'global'
writeToStdout(
chalk.yellow('Warning: Could not determine installation type') + '\n',
)
writeToStdout(
`Attempting ${updateMethodName} update based on file detection...\n`,
)
break
}
default:
process.stderr.write(
`Error: Cannot update ${diagnostic.installationType} installation\n`,
)
await gracefulShutdown(1)
}
writeToStdout(`Using ${updateMethodName} installation update method...\n`)
logForDebugging(`update: Update method determined: ${updateMethodName}`)
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
let status: InstallStatus
if (useLocalUpdate) {
logForDebugging(
'update: Calling installOrUpdateClaudePackage() for local update',
)
status = await installOrUpdateClaudePackage(channel)
} else {
logForDebugging('update: Calling installGlobalPackage() for global update')
status = await installGlobalPackage()
}
logForDebugging(`update: Installation status: ${status}`)
switch (status) {
case 'success':
writeToStdout(
chalk.green(
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
) + '\n',
)
await regenerateCompletionCache()
break
case 'no_permissions':
process.stderr.write(
'Error: Insufficient permissions to install update\n',
)
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write('Try running with sudo or fix npm permissions\n')
process.stderr.write(
'Or consider using native installation with: claude install\n',
)
}
await gracefulShutdown(1)
break
case 'install_failed':
process.stderr.write('Error: Failed to install update\n')
if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n')
process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
)
} else {
process.stderr.write(
'Or consider using native installation with: claude install\n',
)
}
await gracefulShutdown(1)
break
case 'in_progress':
process.stderr.write(
'Error: Another instance is currently performing an update\n',
)
process.stderr.write('Please wait and try again later\n')
await gracefulShutdown(1)
break
}
await gracefulShutdown(0)
}

View File

@@ -129,7 +129,7 @@ export async function updateCCB(): Promise<void> {
try {
if (pkgManager === 'bun') {
execSync(`bun update -g ${PACKAGE_NAME}`, {
execSync(`bun install -g ${PACKAGE_NAME}@latest`, {
stdio: 'inherit',
cwd: homedir(),
timeout: 120_000,
@@ -153,7 +153,9 @@ export async function updateCCB(): Promise<void> {
process.stderr.write('\n')
process.stderr.write('Try manually updating with:\n')
if (pkgManager === 'bun') {
process.stderr.write(chalk.bold(` bun update -g ${PACKAGE_NAME}`) + '\n')
process.stderr.write(
chalk.bold(` bun install -g ${PACKAGE_NAME}@latest`) + '\n',
)
} else {
process.stderr.write(
chalk.bold(` npm install -g ${PACKAGE_NAME}@latest`) + '\n',

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { resolve } from 'path';
import { Box, Text } from '@anthropic/ink';
import { Dialog } from '../../components/design-system/Dialog.js';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { Settings } from '../../components/Settings/Settings.js';
import type { LocalJSXCommandCall } from '../../types/command.js';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = async (onDone, context) => {

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Doctor } from '../../screens/Doctor.js';
import type { LocalJSXCommandCall } from '../../types/command.js';

View File

@@ -43,8 +43,12 @@ export async function call(
// Omitting subagent_type triggers implicit fork.
const input = {
prompt: directive,
fork: true, // 触发 AgentTool 的 fork 路径:继承父会话上下文 + system prompt + 模型
run_in_background: true, // fork always runs async
description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
// description 只显示在底部 selector / BackgroundTasksDialog保持简短标签
// 即可;用户输入的 prompt 会作为第一条用户消息呈现在主视图里,这里不要
// 重复显示。
description: 'forked from main',
};
// Call AgentTool with proper parameters:

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
import type { LocalJSXCommandCall } from '../../types/command.js';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
import { logEvent } from '../../services/analytics/index.js';
import { getTools } from '../../tools.js';

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, color, Text, useTheme } from '@anthropic/ink';

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, color, Text, useTheme } from '@anthropic/ink';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Text } from '@anthropic/ink';
export function CheckGitHubStep() {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text } from '@anthropic/ink';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Workflow } from './types.js';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
import { Box, Text } from '@anthropic/ink';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Select } from 'src/components/CustomSelect/index.js';
import { Box, Text } from '@anthropic/ink';

View File

@@ -1,5 +1,4 @@
import figures from 'figures';
import React from 'react';
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';

View File

@@ -1,5 +1,4 @@
import figures from 'figures';
import React from 'react';
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';

View File

@@ -1,4 +1,3 @@
import { feature } from 'bun:bundle';
import * as React from 'react';
import { resetCostState } from '../../bootstrap/state.js';
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
import { createPermissionRetryMessage } from '../../utils/messages.js';

View File

@@ -36,7 +36,7 @@ function getMergedEnv(): Record<string, string> {
return merged
}
const call: LocalCommandCall = async (args, context) => {
const call: LocalCommandCall = async (args, _context) => {
const arg = args.trim().toLowerCase()
// No argument: show current provider

View File

@@ -2,7 +2,7 @@ import { type ChildProcess } from 'child_process';
import { resolve } from 'path';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { getBridgeDisabledReason, isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
import { getBridgeDisabledReason } from '../../bridge/bridgeEnabled.js';
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
import { BRIDGE_LOGIN_INSTRUCTION } from '../../bridge/types.js';
import { Dialog } from '../../components/design-system/Dialog.js';

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test'
/**
* Verify that user-facing guidance in model picker and resume command
* is concise and actionable. Pure string tests — no side effects.
*/
describe('ModelPicker subtitle', () => {
test('subtitle mentions effort and context controls', () => {
const subtitle =
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
expect(subtitle).toContain('effort')
expect(subtitle).toContain('1M context')
expect(subtitle).toContain('sessions')
})
test('subtitle is under 120 characters', () => {
const subtitle =
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
expect(subtitle.length).toBeLessThan(120)
})
})
describe('Resume error messages', () => {
test('session not found suggests /resume to browse', () => {
const message =
'Session my-session was not found. Run /resume without arguments to browse all sessions.'
expect(message).toContain('not found')
expect(message).toContain('/resume')
expect(message).toContain('browse')
})
test('multiple matches suggests /resume to pick', () => {
const message =
'Found 3 sessions matching test. Run /resume to pick one from the list.'
expect(message).toContain('3 sessions')
expect(message).toContain('/resume')
expect(message).toContain('pick')
})
})
describe('Cost command subscriber messages', () => {
test('overage message mentions the key behavior', () => {
const msg =
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
expect(msg).toContain('overages')
expect(msg).toContain('automatically switch')
})
test('subscription message is concise', () => {
const msg =
'You are currently using your subscription to power your Claude Code usage'
expect(msg.length).toBeLessThan(100)
})
})

View File

@@ -36,9 +36,9 @@ type ResumeResult =
function resumeHelpMessage(result: ResumeResult): string {
switch (result.resultType) {
case 'sessionNotFound':
return `Session ${chalk.bold(result.arg)} was not found.`;
return `Session ${chalk.bold(result.arg)} was not found. Run ${chalk.bold('/resume')} without arguments to browse all sessions.`;
case 'multipleMatches':
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Run ${chalk.bold('/resume')} to pick one from the list.`;
}
}

View File

@@ -1,5 +1,4 @@
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js';
import React from 'react';
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js';
import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js';

View File

@@ -9,7 +9,6 @@ import {
exportInstincts,
findPromotionCandidates,
generateSkillCandidates,
importInstincts,
ingestTranscript,
listKnownProjects,
loadInstincts,

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { Stats } from '../../components/Stats.js';
import type { LocalJSXCommandCall } from '../../types/command.js';

View File

@@ -1,4 +1,3 @@
import { readFileSync } from 'fs';
import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js';
import type { Command } from '../commands.js';
import { DIAMOND_OPEN } from '../constants/figures.js';
@@ -21,7 +20,6 @@ import { logForDebugging } from '../utils/debug.js';
import { errorMessage } from '../utils/errors.js';
import { logError } from '../utils/log.js';
import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js';
import { updateTaskState } from '../utils/task/framework.js';
import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js';
import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js';
@@ -36,13 +34,6 @@ import { registerCleanup } from '../utils/cleanupRegistry.js';
// TODO(prod-hardening): OAuth token may go stale over the 30min poll;
// consider refresh.
/**
* Multi-agent exploration is slow; 30min timeout.
*
* @deprecated use getUltraplanTimeoutMs()
*/
const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000;
export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web';
export function getUltraplanTimeoutMs(): number {
@@ -61,15 +52,6 @@ export function isUltraplanEnabled(): boolean {
);
}
// CCR runs against the first-party API — use the canonical ID, not the
// provider-specific string getModelStrings() would return (which may be a
// Bedrock ARN or Vertex ID on the local CLI). Read at call time, not module
// load: the GrowthBook cache is empty at import and `/config` Gates can flip
// it between invocations.
function getUltraplanModel(): string {
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus47.firstParty);
}
// prompt.txt is wrapped in <system-reminder> so the CCR browser hides
// scaffolding (CLI_BLOCK_TAGS dropped by stripSystemNotifications)
// while the model still sees full text.
@@ -84,19 +66,6 @@ const _rawPrompt = require('../utils/ultraplan/prompt.txt');
/* eslint-enable @typescript-eslint/no-require-imports */
const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd();
// Dev-only prompt override resolved eagerly at module load.
// Gated to ant builds (USER_TYPE is a build-time define,
// so the override path is DCE'd from external builds).
// Shell-set env only, so top-level process.env read is fine
// — settings.env never injects this.
// @deprecated use buildUltraplanPrompt()
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
const ULTRAPLAN_INSTRUCTIONS: string =
process.env.USER_TYPE === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE
? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd()
: DEFAULT_INSTRUCTIONS;
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
/**
* Assemble the initial CCR user message. seedPlan and blurb stay outside the
* system-reminder so the browser renders them; scaffolding is hidden.

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { Settings } from '../../components/Settings/Settings.js';
import type { LocalJSXCommandCall } from '../../types/command.js';

View File

@@ -1,151 +0,0 @@
import React, { useEffect, useState } from 'react';
import { formatCost } from '../cost-tracker.js';
import { Box, Text, ProgressBar } from '@anthropic/ink';
import { formatTokens } from '../utils/format.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
type RateLimitBucket = {
utilization: number;
resets_at: number;
};
type BuiltinStatusLineProps = {
modelName: string;
contextUsedPct: number;
usedTokens: number;
contextWindowSize: number;
totalCostUsd: number;
rateLimits: {
five_hour?: RateLimitBucket;
seven_day?: RateLimitBucket;
};
};
/**
* Format a countdown from now until the given epoch time (in seconds).
* Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
*/
export function formatCountdown(epochSeconds: number): string {
const diff = epochSeconds - Date.now() / 1000;
if (diff <= 0) return 'now';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const minutes = Math.floor((diff % 3600) / 60);
if (days >= 1) return `${days}d${hours}h`;
if (hours >= 1) return `${hours}h${minutes}m`;
return `${minutes}m`;
}
function Separator() {
return <Text dimColor>{' \u2502 '}</Text>;
}
function BuiltinStatusLineInner({
modelName,
contextUsedPct,
usedTokens,
contextWindowSize,
totalCostUsd,
rateLimits,
}: BuiltinStatusLineProps) {
const { columns } = useTerminalSize();
// Force re-render every 60s so countdowns stay current
const [tick, setTick] = useState(0);
useEffect(() => {
const hasResetTime = (rateLimits.five_hour?.resets_at ?? 0) || (rateLimits.seven_day?.resets_at ?? 0);
if (!hasResetTime) return;
const id = setInterval(() => setTick(t => t + 1), 60_000);
return () => clearInterval(id);
}, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
// Suppress unused-variable lint for tick (it exists only to trigger re-renders)
void tick;
// Model display: use first two words (e.g. "Opus 4.6") instead of just first word
const modelParts = modelName.split(' ');
const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
const wide = columns >= 100;
const narrow = columns < 60;
const hasFiveHour = rateLimits.five_hour != null;
const hasSevenDay = rateLimits.seven_day != null;
const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
// Token display: "50k/1M"
const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
return (
<Box>
{/* Model name */}
<Text>{shortModel}</Text>
{/* Context usage with token counts */}
<Separator />
<Text dimColor>Context </Text>
<Text>{contextUsedPct}%</Text>
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
{/* 5-hour session rate limit */}
{hasFiveHour && (
<>
<Separator />
<Text dimColor>Session </Text>
{wide && (
<>
<ProgressBar
ratio={rateLimits.five_hour!.utilization}
width={10}
fillColor="rate_limit_fill"
emptyColor="rate_limit_empty"
/>
<Text> </Text>
</>
)}
<Text>{fiveHourPct}%</Text>
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
)}
</>
)}
{/* 7-day weekly rate limit */}
{hasSevenDay && (
<>
<Separator />
<Text dimColor>Weekly </Text>
{wide && (
<>
<ProgressBar
ratio={rateLimits.seven_day!.utilization}
width={10}
fillColor="rate_limit_fill"
emptyColor="rate_limit_empty"
/>
<Text> </Text>
</>
)}
<Text>{sevenDayPct}%</Text>
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
)}
</>
)}
{/* Cost */}
{totalCostUsd > 0 && (
<>
<Separator />
<Text>{formatCost(totalCostUsd)}</Text>
</>
)}
</Box>
);
}
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);

View File

@@ -79,7 +79,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
</Box>
<Box flexDirection="column">
<Text bold>
Compact summary
Conversation summarized to free up context
{!isTranscriptMode && (
<Text dimColor>
{' '}
@@ -87,7 +87,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
action="app:toggleTranscript"
context="Global"
fallback="ctrl+o"
description="expand"
description="view summary"
parens
/>
</Text>

View File

@@ -11,12 +11,12 @@ import { getSSLErrorHint } from '@ant/model-provider';
import { sendNotification } from '../services/notifier.js';
import { OAuthService } from '../services/oauth/index.js';
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
import { logError } from '../utils/log.js';
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
import { Select } from './CustomSelect/select.js';
import { Spinner } from './Spinner.js';
import TextInput from './TextInput.js';
import { fi } from 'zod/v4/locales';
type Props = {
onDone(): void;
@@ -596,7 +596,7 @@ function OAuthStatusMessage({
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
);
const switchTo = useCallback(
const _switchTo = useCallback(
(target: Field) => {
setOAuthStatus(buildState(activeField, inputValue, target));
setInputValue(displayValues[target] ?? '');

View File

@@ -1,11 +1,16 @@
import type { StructuredPatchHunk } from 'diff';
import * as React from 'react';
import { Text } from '@anthropic/ink';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { Box, Text } from '@anthropic/ink';
import { count } from '../utils/array.js';
import { MessageResponse } from './MessageResponse.js';
import { StructuredDiffList } from './StructuredDiffList.js';
type Props = {
filePath: string;
structuredPatch: { lines: string[] }[];
structuredPatch: StructuredPatchHunk[];
firstLine: string | null;
fileContent?: string;
style?: 'condensed';
verbose: boolean;
previewHint?: string;
@@ -14,10 +19,13 @@ type Props = {
export function FileEditToolUpdatedMessage({
filePath,
structuredPatch,
firstLine,
fileContent,
style,
verbose,
previewHint,
}: Props): React.ReactNode {
const { columns } = useTerminalSize();
const numAdditions = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), 0);
const numRemovals = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 0);
@@ -39,7 +47,7 @@ export function FileEditToolUpdatedMessage({
// Plan files: invert condensed behavior
// - Regular mode: just show the hint (user can type /plan to see full content)
// - Condensed mode (subagent view): show the text
// - Condensed mode (subagent view): show the diff
if (previewHint) {
if (style !== 'condensed' && !verbose) {
return (
@@ -52,5 +60,19 @@ export function FileEditToolUpdatedMessage({
return text;
}
return <MessageResponse>{text}</MessageResponse>;
return (
<MessageResponse>
<Box flexDirection="column">
<Text>{text}</Text>
<StructuredDiffList
hunks={structuredPatch}
dim={false}
width={columns - 12}
filePath={filePath}
firstLine={firstLine}
fileContent={fileContent}
/>
</Box>
</MessageResponse>
);
}

View File

@@ -1,17 +1,39 @@
import type { StructuredPatchHunk } from 'diff';
import { relative } from 'path';
import * as React from 'react';
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
import { getCwd } from 'src/utils/cwd.js';
import { Box, Text } from '@anthropic/ink';
import { HighlightedCode } from './HighlightedCode.js';
import { MessageResponse } from './MessageResponse.js';
import { StructuredDiffList } from './StructuredDiffList.js';
const MAX_LINES_TO_RENDER = 10;
type Props = {
file_path: string;
operation: 'write' | 'update';
// For updates - show diff
patch?: StructuredPatchHunk[];
firstLine: string | null;
fileContent?: string;
// For new file creation - show content preview
content?: string;
style?: 'condensed';
verbose: boolean;
};
export function FileEditToolUseRejectedMessage({ file_path, operation, style, verbose }: Props): React.ReactNode {
export function FileEditToolUseRejectedMessage({
file_path,
operation,
patch,
firstLine,
fileContent,
content,
style,
verbose,
}: Props): React.ReactNode {
const { columns } = useTerminalSize();
const text = (
<Box flexDirection="row">
<Text color="subtle">User rejected {operation} to </Text>
@@ -26,5 +48,42 @@ export function FileEditToolUseRejectedMessage({ file_path, operation, style, ve
return <MessageResponse>{text}</MessageResponse>;
}
return <MessageResponse>{text}</MessageResponse>;
// For new file creation, show content preview (dimmed)
if (operation === 'write' && content !== undefined) {
const lines = content.split('\n');
const numLines = lines.length;
const plusLines = numLines - MAX_LINES_TO_RENDER;
const truncatedContent = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join('\n');
return (
<MessageResponse>
<Box flexDirection="column">
{text}
<HighlightedCode code={truncatedContent || '(No content)'} filePath={file_path} width={columns - 12} dim />
{!verbose && plusLines > 0 && <Text dimColor> +{plusLines} lines</Text>}
</Box>
</MessageResponse>
);
}
// For updates, show diff
if (!patch || patch.length === 0) {
return <MessageResponse>{text}</MessageResponse>;
}
return (
<MessageResponse>
<Box flexDirection="column">
{text}
<StructuredDiffList
hunks={patch}
dim
width={columns - 12}
filePath={file_path}
firstLine={firstLine}
fileContent={fileContent}
/>
</Box>
</MessageResponse>
);
}

View File

@@ -5,11 +5,28 @@ import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js';
export function General(): React.ReactNode {
return (
<Box flexDirection="column" paddingY={1} gap={1}>
<Box>
<Text>
Claude understands your codebase, makes edits with your permission, and executes commands right from your
terminal.
</Text>
<Box flexDirection="column" gap={1}>
<Text bold>Getting started</Text>
<Box flexDirection="column">
<Text>
<Text bold>1. </Text>
<Text>Ask a question or describe a task Claude will explore your code and respond.</Text>
</Text>
<Text>
<Text bold>2. </Text>
<Text>When Claude wants to edit files or run commands, you review and approve each action.</Text>
</Text>
<Text>
<Text bold>3. </Text>
<Text>Type </Text>
<Text bold>/commit</Text>
<Text> to commit changes, </Text>
<Text bold>/help</Text>
<Text> for commands, or </Text>
<Text bold>?</Text>
<Text> for shortcuts.</Text>
</Text>
</Box>
</Box>
<Box flexDirection="column">
<Box>

View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test'
/**
* Verify that user-facing permission and help copy meets usability standards.
* These are pure string tests — no side effects, no React rendering.
*/
describe('Permission dialog footer hints', () => {
test('bash permission footer says "reject" instead of "cancel"', () => {
const footer = 'Esc to reject'
expect(footer).toContain('reject')
expect(footer).not.toContain('cancel')
})
test('bash permission footer tab hint says "add feedback"', () => {
const tabHint = 'Tab to add feedback'
expect(tabHint).toContain('feedback')
expect(tabHint).not.toContain('amend')
})
test('file permission footer matches bash footer language', () => {
const bashFooter = 'Esc to reject'
const fileFooter = 'Esc to reject'
expect(bashFooter).toBe(fileFooter)
})
})
describe('Permission option labels', () => {
test('.claude/ folder option is under 60 chars', () => {
const label = 'Yes, allow edits to .claude/ config for this session'
expect(label.length).toBeLessThan(60)
expect(label).toContain('.claude/')
})
test('accept-once option has simple label', () => {
const label = 'Yes'
expect(label).toBe('Yes')
})
test('reject option has simple label', () => {
const label = 'No'
expect(label).toBe('No')
})
})
describe('Help General page getting started guide', () => {
test('step 1 mentions exploring code', () => {
const step1 =
'Ask a question or describe a task — Claude will explore your code and respond.'
expect(step1).toContain('explore')
expect(step1).toContain('question')
})
test('step 2 mentions reviewing actions', () => {
const step2 =
'When Claude wants to edit files or run commands, you review and approve each action.'
expect(step2).toContain('review')
expect(step2).toContain('approve')
})
test('step 3 mentions key commands', () => {
const step3 = '/commit'
const step3b = '/help'
const step3c = '?'
expect(step3).toBe('/commit')
expect(step3b).toBe('/help')
expect(step3c).toBe('?')
})
test('heading says "Getting started"', () => {
const heading = 'Getting started'
expect(heading).toBe('Getting started')
})
})

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { type ReactNode, useEffect } from 'react';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';

View File

@@ -63,7 +63,6 @@ import {
incrementOverageCreditUpsellSeenCount,
createOverageCreditFeed,
} from './OverageCreditUpsell.js';
import { plural } from '../../utils/stringUtils.js';
import { useAppState } from '../../state/AppState.js';
import { getEffortSuffix } from '../../utils/effort.js';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';

View File

@@ -1,6 +1,5 @@
import figures from 'figures';
import { homedir } from 'os';
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Step } from '../../projectOnboardingState.js';
import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js';

View File

@@ -65,20 +65,40 @@ function wrapText(text: string, width: number, options?: { hard?: boolean }): st
* 2. Distributing available space proportionally
* 3. Wrapping text within cells (no truncation)
* 4. Properly aligning multi-line rows with borders
*
* Performance: uses per-render caches (formatCache, plainTextCache, wrapCache)
* to avoid redundant formatCell/wrapText calls across the multiple passes
* (width calculation, row line counting, rendering). Wrapped in React.memo
* to skip re-renders when props are unchanged.
*/
export function MarkdownTable({ token, highlight, forceWidth }: Props): React.ReactNode {
export const MarkdownTable = React.memo(function MarkdownTable({
token,
highlight,
forceWidth,
}: Props): React.ReactNode {
const [theme] = useTheme();
const { columns: actualTerminalWidth } = useTerminalSize();
const terminalWidth = forceWidth ?? actualTerminalWidth;
// Format cell content to ANSI string
// Per-render caches — Token[] references are stable within a single token
// prop (from LRU cache in Markdown.tsx), so reference equality is sufficient.
const formatCache = new Map<Token[] | undefined, string>();
const plainTextCache = new Map<Token[] | undefined, string>();
function formatCell(tokens: Token[] | undefined): string {
return tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? '';
const cached = formatCache.get(tokens);
if (cached !== undefined) return cached;
const result = tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? '';
formatCache.set(tokens, result);
return result;
}
// Get plain text (stripped of ANSI codes)
function getPlainText(tokens: Token[] | undefined): string {
return stripAnsi(formatCell(tokens));
const cached = plainTextCache.get(tokens);
if (cached !== undefined) return cached;
const result = stripAnsi(formatCell(tokens));
plainTextCache.set(tokens, result);
return result;
}
// Get the longest word width in a cell (minimum width to avoid breaking words)
@@ -149,43 +169,39 @@ export function MarkdownTable({ token, highlight, forceWidth }: Props): React.Re
columnWidths = minWidths.map(w => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH));
}
// Step 4: Calculate max row lines to determine if vertical format is needed
function calculateMaxRowLines(): number {
let maxLines = 1;
// Check header
for (let i = 0; i < token.header.length; i++) {
const content = formatCell(token.header[i]!.tokens);
const wrapped = wrapText(content, columnWidths[i]!, {
hard: needsHardWrap,
});
maxLines = Math.max(maxLines, wrapped.length);
}
// Check rows
for (const row of token.rows) {
for (let i = 0; i < row.length; i++) {
const content = formatCell(row[i]?.tokens);
const wrapped = wrapText(content, columnWidths[i]!, {
hard: needsHardWrap,
});
maxLines = Math.max(maxLines, wrapped.length);
}
}
return maxLines;
// Step 4: Single-pass cell preparation — wraps each cell once, caches results
// for reuse by both row-line counting and rendering.
const wrapCache = new Map<Token[] | undefined, string[]>();
function getWrappedLines(tokens: Token[] | undefined, colIndex: number): string[] {
const cached = wrapCache.get(tokens);
if (cached !== undefined) return cached;
const formatted = formatCell(tokens);
const lines = wrapText(formatted, columnWidths[colIndex]!, {
hard: needsHardWrap,
});
wrapCache.set(tokens, lines);
return lines;
}
// Step 5: Calculate max row lines using cached wrapped results
let maxRowLines = 1;
for (let i = 0; i < token.header.length; i++) {
maxRowLines = Math.max(maxRowLines, getWrappedLines(token.header[i]!.tokens, i).length);
}
for (const row of token.rows) {
for (let i = 0; i < row.length; i++) {
maxRowLines = Math.max(maxRowLines, getWrappedLines(row[i]?.tokens, i).length);
}
}
// Use vertical format if wrapping would make rows too tall
const maxRowLines = calculateMaxRowLines();
const useVerticalFormat = maxRowLines > MAX_ROW_LINES;
// Render a single row with potential multi-line cells
// Returns an array of strings, one per line of the row
function renderRowLines(cells: Array<{ tokens?: Token[] }>, isHeader: boolean): string[] {
// Get wrapped lines for each cell (preserving ANSI formatting)
const cellLines = cells.map((cell, colIndex) => {
const formattedText = formatCell(cell.tokens);
const width = columnWidths[colIndex]!;
return wrapText(formattedText, width, { hard: needsHardWrap });
});
// Reuse cached wrapped lines — no redundant formatCell/wrapText
const cellLines = cells.map((cell, colIndex) => getWrappedLines(cell.tokens, colIndex));
// Find max number of lines in this row
const maxLines = Math.max(...cellLines.map(lines => lines.length), 1);
@@ -231,6 +247,7 @@ export function MarkdownTable({ token, highlight, forceWidth }: Props): React.Re
}
// Render vertical format (key-value pairs) for extra-narrow terminals
// Uses formatCell cache; wrapping uses terminal-width params (not column widths)
function renderVerticalFormat(): string {
const lines: string[] = [];
const headers = token.header.map(h => getPlainText(h.tokens));
@@ -318,4 +335,4 @@ export function MarkdownTable({ token, highlight, forceWidth }: Props): React.Re
// Render as a single Ansi block to prevent Ink from wrapping mid-row
return <Ansi>{tableLines.join('\n')}</Ansi>;
}
});

View File

@@ -18,6 +18,7 @@ import type { Tools } from '../Tool.js';
import { findToolByName } from '../Tool.js';
import type { AgentDefinitionsResult } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
import type {
AssistantMessage,
Message as MessageType,
NormalizedMessage,
ProgressMessage as ProgressMessageType,
@@ -36,6 +37,7 @@ import {
buildMessageLookups,
computeMessageStructureKey,
type MessageLookups,
updateMessageLookupsIncremental,
createAssistantMessage,
deriveUUID,
getMessagesAfterCompactBoundary,
@@ -516,7 +518,13 @@ const MessagesImpl = ({
// message content changed during streaming (text/thinking deltas). The key
// captures only structural info (types, IDs), so content-only deltas skip
// the rebuild entirely.
const lookupsCacheRef = useRef<{ key: string; lookups: MessageLookups } | null>(null);
const lookupsCacheRef = useRef<{
key: string;
lookups: MessageLookups;
normalizedCount: number;
messageCount: number;
lastAssistantMsgId: string | undefined;
} | null>(null);
// Expensive message transforms — filter, reorder, group, collapse, lookups.
// All O(n) over 27k messages. Split from the renderRange slice so scrolling
@@ -587,12 +595,57 @@ const MessagesImpl = ({
);
const lookupsKey = computeMessageStructureKey(normalizedMessages, messagesToShow as MessageType[]);
const currentLastAssistantMsgId = (() => {
const lastMsg = (messagesToShow as MessageType[]).at(-1);
return lastMsg?.type === 'assistant' ? (lastMsg as AssistantMessage).message?.id : undefined;
})();
let lookups: MessageLookups;
if (lookupsCacheRef.current && lookupsCacheRef.current.key === lookupsKey) {
lookups = lookupsCacheRef.current.lookups;
} else if (
lookupsCacheRef.current &&
normalizedMessages.length >= lookupsCacheRef.current.normalizedCount &&
(messagesToShow as MessageType[]).length >= lookupsCacheRef.current.messageCount &&
// If lastAssistantMsgId changed, previous "in-progress" assistant may
// now be orphaned — force a full rebuild to pick up the new status.
lookupsCacheRef.current.lastAssistantMsgId === currentLastAssistantMsgId
) {
// Try incremental update when only new messages were appended
const updated = updateMessageLookupsIncremental(
lookupsCacheRef.current.lookups,
lookupsCacheRef.current.normalizedCount,
lookupsCacheRef.current.messageCount,
normalizedMessages,
messagesToShow as MessageType[],
);
if (updated) {
lookups = updated;
lookupsCacheRef.current = {
key: lookupsKey,
lookups,
normalizedCount: normalizedMessages.length,
messageCount: (messagesToShow as MessageType[]).length,
lastAssistantMsgId: currentLastAssistantMsgId,
};
} else {
lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[]);
lookupsCacheRef.current = {
key: lookupsKey,
lookups,
normalizedCount: normalizedMessages.length,
messageCount: (messagesToShow as MessageType[]).length,
lastAssistantMsgId: currentLastAssistantMsgId,
};
}
} else {
lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[]);
lookupsCacheRef.current = { key: lookupsKey, lookups };
lookupsCacheRef.current = {
key: lookupsKey,
lookups,
normalizedCount: normalizedMessages.length,
messageCount: (messagesToShow as MessageType[]).length,
lastAssistantMsgId: currentLastAssistantMsgId,
};
}
const hiddenMessageCount = messagesToShowNotTruncated.length - MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE;

View File

@@ -229,7 +229,7 @@ export function ModelPicker({
</Text>
<Text dimColor>
{headerText ??
'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'}
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'}
</Text>
{sessionModel && (
<Text dimColor>

View File

@@ -81,7 +81,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
const securityStep = (
<Box flexDirection="column" gap={1} paddingLeft={1}>
<Text bold>Security notes:</Text>
<Text bold>Before you start, keep in mind:</Text>
<Box flexDirection="column" width={70}>
{/**
* OrderedList misnumbers items when rendering conditionally,
@@ -89,18 +89,18 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
*/}
<OrderedList>
<OrderedList.Item>
<Text>Claude can make mistakes</Text>
<Text>Always review changes before accepting</Text>
<Text dimColor wrap="wrap">
You should always review Claude&apos;s responses, especially when
Claude can make mistakes especially when running commands
<Newline />
running code.
or editing files. You stay in control of every action.
<Newline />
</Text>
</OrderedList.Item>
<OrderedList.Item>
<Text>Due to prompt injection risks, only use it with code you trust</Text>
<Text>Only use Claude Code on projects you trust</Text>
<Text dimColor wrap="wrap">
For more details see:
Untrusted code could contain prompt injection attacks.
<Newline />
<Link url="https://code.claude.com/docs/en/security" />
</Text>
@@ -111,7 +111,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
</Box>
);
const preflightStep = <PreflightStep onSuccess={goToNextStep} />;
const _preflightStep = <PreflightStep onSuccess={goToNextStep} />;
// Create the steps array - determine which steps to include based on reAuth and oauthEnabled
const apiKeyNeedingApproval = useMemo(() => {
// Add API key step if needed

View File

@@ -24,7 +24,6 @@ import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js';
import { toIDEDisplayName } from '../../utils/ide.js';
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js';
import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { IdeStatusIndicator } from '../IdeStatusIndicator.js';
import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js';
@@ -57,13 +56,13 @@ type Props = {
export function Notifications({
apiKeyStatus,
autoUpdaterResult,
autoUpdaterResult: _autoUpdaterResult,
debug,
isAutoUpdating,
isAutoUpdating: _isAutoUpdating,
verbose,
messages,
onAutoUpdaterResult,
onChangeIsUpdating,
onAutoUpdaterResult: _onAutoUpdaterResult,
onChangeIsUpdating: _onChangeIsUpdating,
ideSelection,
mcpClients,
isInputWrapped = false,
@@ -102,9 +101,6 @@ export function Notifications({
const shouldShowIdeSelection =
ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0));
// Hide update installed message when showing IDE selection
const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== 'success';
// Check if we're in overage mode for UI indicators
const isInOverageMode = claudeAiLimits.isUsingOverage;
const subscriptionType = getSubscriptionType();
@@ -157,12 +153,6 @@ export function Notifications({
verbose={verbose}
tokenUsage={tokenUsage}
mainLoopModel={mainLoopModel}
shouldShowAutoUpdater={shouldShowAutoUpdater}
autoUpdaterResult={autoUpdaterResult}
isAutoUpdating={isAutoUpdating}
isShowingCompactMessage={isShowingCompactMessage}
onAutoUpdaterResult={onAutoUpdaterResult}
onChangeIsUpdating={onChangeIsUpdating}
/>
</Box>
</SentryErrorBoundary>
@@ -180,12 +170,6 @@ function NotificationContent({
verbose,
tokenUsage,
mainLoopModel,
shouldShowAutoUpdater,
autoUpdaterResult,
isAutoUpdating,
isShowingCompactMessage,
onAutoUpdaterResult,
onChangeIsUpdating,
}: {
ideSelection: IDESelection | undefined;
mcpClients?: MCPServerConnection[];
@@ -200,12 +184,6 @@ function NotificationContent({
verbose: boolean;
tokenUsage: number;
mainLoopModel: string;
shouldShowAutoUpdater: boolean;
autoUpdaterResult: AutoUpdaterResult | null;
isAutoUpdating: boolean;
isShowingCompactMessage: boolean;
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
onChangeIsUpdating: (isUpdating: boolean) => void;
}): ReactNode {
// Poll apiKeyHelper inflight state to show slow-helper notice.
// Gated on configuration — most users never set apiKeyHelper, so the

View File

@@ -26,6 +26,7 @@ import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js
import { formatImageRef, formatPastedTextRef, getPastedTextRefNumLines, parseReferences } from '../../history.js';
import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js';
import { type HistoryMode, useArrowKeyHistory } from '../../hooks/useArrowKeyHistory.js';
import { useBackgroundAgentTasks } from '../../hooks/useBackgroundAgentTasks.js';
import { useDoublePress } from '../../hooks/useDoublePress.js';
import { useHistorySearch } from '../../hooks/useHistorySearch.js';
import type { IDESelection } from '../../hooks/useIdeSelection.js';
@@ -55,7 +56,6 @@ import {
} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
import type { Message } from '../../types/message.js';
import type { PermissionMode } from '../../types/permissions.js';
import type { BaseTextInputProps, PromptInputMode, VimMode } from '../../types/textInputTypes.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { count } from '../../utils/array.js';
@@ -329,7 +329,7 @@ function PromptInput({
const hasTungstenSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined);
const tmuxFooterVisible = process.env.USER_TYPE === 'ant' && hasTungstenSession;
// WebBrowser pill — visible when a browser is open
const bagelFooterVisible = useAppState(s => false);
const bagelFooterVisible = useAppState(_s => false);
const teamContext = useAppState(s => s.teamContext);
const queuedCommands = useCommandQueue();
const promptSuggestionState = useAppState(s => s.promptSuggestion);
@@ -416,6 +416,16 @@ function PromptInput({
// First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select
// of pill + row when both bg tasks (pill) and forked agents (rows) are visible.
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
const selectedBgAgentIndex = useAppState(s => s.selectedBgAgentIndex);
const setSelectedBgAgentIndex = useCallback(
(v: number | ((prev: number) => number)) =>
setAppState(prev => {
const next = typeof v === 'function' ? v(prev.selectedBgAgentIndex) : v;
if (next === prev.selectedBgAgentIndex) return prev;
return { ...prev, selectedBgAgentIndex: next };
}),
[setAppState],
);
const setCoordinatorTaskIndex = useCallback(
(v: number | ((prev: number) => number)) =>
setAppState(prev => {
@@ -502,10 +512,13 @@ function PromptInput({
(runningTaskCount > 0 || (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) &&
!shouldHideTasksFooter(tasks, showSpinnerTree);
const teamsFooterVisible = cachedTeams.length > 0;
const bgAgentList = useBackgroundAgentTasks();
const bgAgentFooterVisible = bgAgentList.length > 0;
const footerItems = useMemo(
() =>
[
bgAgentFooterVisible && 'bg_agent',
tasksFooterVisible && 'tasks',
tmuxFooterVisible && 'tmux',
bagelFooterVisible && 'bagel',
@@ -514,6 +527,7 @@ function PromptInput({
companionFooterVisible && 'companion',
].filter(Boolean) as FooterItem[],
[
bgAgentFooterVisible,
tasksFooterVisible,
tmuxFooterVisible,
bagelFooterVisible,
@@ -538,9 +552,10 @@ function PromptInput({
const tasksSelected = footerItemSelected === 'tasks';
const tmuxSelected = footerItemSelected === 'tmux';
const bagelSelected = footerItemSelected === 'bagel';
const _bagelSelected = footerItemSelected === 'bagel';
const teamsSelected = footerItemSelected === 'teams';
const bridgeSelected = footerItemSelected === 'bridge';
const bgAgentSelected = footerItemSelected === 'bg_agent';
function selectFooterItem(item: FooterItem | null): void {
setAppState(prev => (prev.footerSelection === item ? prev : { ...prev, footerSelection: item }));
@@ -548,6 +563,9 @@ function PromptInput({
setTeammateFooterIndex(0);
setCoordinatorTaskIndex(minCoordinatorIndex);
}
if (item === 'bg_agent') {
setSelectedBgAgentIndex(-1);
}
}
// delta: +1 = down/right, -1 = up/left. Returns true if nav happened
@@ -1809,6 +1827,15 @@ function PromptInput({
useKeybindings(
{
'footer:up': () => {
// ↑ in bg_agent pill: move selection up (-1 = main). At -1, leave pill.
if (bgAgentSelected) {
if (selectedBgAgentIndex > -1) {
setSelectedBgAgentIndex(prev => prev - 1);
} else {
selectFooterItem(null);
}
return;
}
// ↑ scrolls within the coordinator task list before leaving the pill
if (
tasksSelected &&
@@ -1822,6 +1849,13 @@ function PromptInput({
navigateFooter(-1, true);
},
'footer:down': () => {
// ↓ in bg_agent pill: move selection down through agents. Clamp at last.
if (bgAgentSelected) {
if (selectedBgAgentIndex < bgAgentList.length - 1) {
setSelectedBgAgentIndex(prev => prev + 1);
}
return;
}
// ↓ scrolls within the coordinator task list, never leaves the pill
if (tasksSelected && process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0) {
if (coordinatorTaskIndex < coordinatorTaskCount - 1) {
@@ -1907,6 +1941,15 @@ function PromptInput({
setShowBridgeDialog(true);
selectFooterItem(null);
break;
case 'bg_agent':
if (selectedBgAgentIndex === -1) {
exitTeammateView(setAppState);
} else {
const picked = bgAgentList[selectedBgAgentIndex];
if (picked) enterTeammateView(picked.agentId, setAppState);
}
// Keep the pill focused so ↑/↓ continue to work after Enter.
break;
}
},
'footer:clearSelection': () => {

View File

@@ -1,6 +1,6 @@
import { feature } from 'bun:bundle';
import * as React from 'react';
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { memo, type ReactNode, useMemo, useRef, useState } from 'react';
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js';
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { memo, type ReactNode } from 'react';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text, stringWidth } from '@anthropic/ink';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Text } from '@anthropic/ink';
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';

View File

@@ -1,4 +1,3 @@
import * as React from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { Box, Text } from '@anthropic/ink';
import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js';

View File

@@ -15,14 +15,11 @@ import {
} from '../../utils/config.js';
import chalk from 'chalk';
import {
permissionModeTitle,
permissionModeShortTitle,
permissionModeFromString,
toExternalPermissionMode,
isExternalPermissionMode,
EXTERNAL_PERMISSION_MODES,
PERMISSION_MODES,
type ExternalPermissionMode,
type PermissionMode,
} from '../../utils/permissions/PermissionMode.js';
import {

View File

@@ -9,7 +9,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
import { isEnvTruthy } from '../utils/envUtils.js';
import { count } from '../utils/array.js';
import sample from 'lodash-es/sample.js';
import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js';
import { formatDuration, formatNumber } from '../utils/format.js';
import type { Theme } from 'src/utils/theme.js';
import { activityManager } from '../utils/activityManager.js';
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
@@ -244,7 +244,7 @@ function SpinnerWithVerbInner({
// TTFT display is gated to internal builds — apiMetricsRef was removed from
// props during a refactor, so skip this until it's re-threaded.
let ttftText: string | null = null;
const _ttftText: string | null = null;
// When leader is idle but teammates are running (and we're viewing the leader),
// show a static dim idle display instead of the animated spinner — otherwise

View File

@@ -288,6 +288,15 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
}
}, [lastAssistantMessageId, permissionMode, vimMode, mainLoopModel, scheduleUpdate]);
// Time-driven refresh: tick setInterval(refreshInterval seconds) through the
// existing debounced scheduleUpdate so interval + message-change don't double-fire.
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
useEffect(() => {
if (refreshIntervalMs <= 0) return;
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
return () => clearInterval(id);
}, [refreshIntervalMs, scheduleUpdate]);
// When the statusLine command changes (hot reload), log the next result
const statusLineCommand = settings?.statusLine?.command;
const isFirstSettingsRender = useRef(true);

View File

@@ -174,10 +174,9 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
<Text bold>{getFsImplementation().cwd()}</Text>
<Text>
Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open
source project, or work from your team). If not, take a moment to review what{"'"}s in this folder first.
Is this a project you trust? (Your own code, a well-known open source project, or work from your team).
</Text>
<Text>Claude Code{"'"}ll be able to read, edit, and execute files here.</Text>
<Text>Once trusted, Claude Code can read, edit, and run commands in this folder.</Text>
<Text dimColor>
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test'
/**
* Verify compaction and context-related user messages are clear and actionable.
* Pure string tests — no side effects.
*/
describe('Compaction error messages', () => {
test('not enough messages includes guidance', () => {
const msg =
'Not enough messages to compact. Send a few more messages first, then try again.'
expect(msg).toContain('Not enough messages')
expect(msg).toContain('try again')
})
test('prompt too long suggests actions', () => {
const msg =
'Conversation too long to summarize. Try /compact to manually clear conversation history, or start a new session with /clear.'
expect(msg).toContain('/compact')
expect(msg).toContain('/clear')
expect(msg).toContain('too long')
})
test('incomplete response mentions network', () => {
const msg =
'Compaction interrupted · This may be due to network issues — please try again.'
expect(msg).toContain('interrupted')
expect(msg).toContain('try again')
})
test('user abort is clear', () => {
const msg = 'API Error: Request was aborted.'
expect(msg).toContain('aborted')
})
})
describe('CompactSummary display text', () => {
test('auto-compact title explains what happened', () => {
const title = 'Conversation summarized to free up context'
expect(title).toContain('summarized')
expect(title).toContain('context')
expect(title).not.toContain('Compact summary')
})
test('manual compact title mentions message count', () => {
const line1 = 'Summarized conversation'
expect(line1).toContain('Summarized')
})
test('expand hint says "view summary" not "expand"', () => {
const hint = 'view summary'
expect(hint).toContain('summary')
})
})

View File

@@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { isAutoMemoryEnabled } from '../../../memdir/paths.js';
import type { Tools } from '../../../Tool.js';
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';

View File

@@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js';

View File

@@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { type KeyboardEvent, Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink';
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
import { isAutoMemoryEnabled } from '../../../../memdir/paths.js';

View File

@@ -1,5 +1,5 @@
import chalk from 'chalk';
import React, { type ReactNode, useCallback, useState } from 'react';
import { type ReactNode, useCallback, useState } from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,

View File

@@ -1,4 +1,4 @@
import React, { type ReactNode, useCallback, useState } from 'react';
import { type ReactNode, useCallback, useState } from 'react';
import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink';
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
import { editPromptInEditor } from '../../../../utils/promptEditor.js';

View File

@@ -1,5 +1,5 @@
import { APIUserAbortError } from '@anthropic-ai/sdk';
import React, { type ReactNode, useCallback, useRef, useState } from 'react';
import { type ReactNode, useCallback, useRef, useState } from 'react';
import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js';
import { Box, Byline, Text } from '@anthropic/ink';
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';

View File

@@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
import type { SettingSource } from '../../../../utils/settings/constants.js';
import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js';

View File

@@ -1,4 +1,4 @@
import React, { type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { useKeybinding } from '../../../../keybindings/useKeybinding.js';
import { isAutoMemoryEnabled } from '../../../../memdir/paths.js';

Some files were not shown because too many files have changed in this diff Show More