fix: 优化内存峰值与 CPU 性能,降低 100-300MB 内存占用

- claude.ts: 流式字符串拼接从 O(n²) += 改为数组累积 join,消除 4 处热点
- Messages.tsx: 合并 3 组独立遍历为单次 pass(thinking/bash 查找、3-filter 链、divider/selectedIdx)
- HighlightedCode.tsx: ColorFile 实例添加模块级 LRU 缓存(50 条),避免重复创建
- screen.ts: StylePool 衍生缓存添加 1000 条上限淘汰,防止无界增长
- CompanionSprite.tsx: TICK_MS 从 500ms 提升至 1000ms,减少 setState 频率
- connection.ts: MCP stderr 缓冲从 64MB 降至 8MB
- stringUtils.ts: MAX_STRING_LENGTH 从 32MB 降至 2MB
- sessionStorage.ts: Transcript 写入队列添加 1000 条上限
- query.ts: spread 改 concat 减少一次数组拷贝
- PromptInputFooterLeftSide.tsx: 显示进程 pid 便于调试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best 2026-05-02 00:45:03 +08:00 committed by James Feng
parent aff7b0e853
commit b7bbbeb039
7 changed files with 782 additions and 917 deletions

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 — **`bun run precheck` 必须零错误通过**(包含 typecheck + lint fix + test
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**.
## 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) — 日常开发用 precheck 代替单独调用
# Lint & Format (Biome)
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) — 任务完成后必须运行
# Full check (typecheck + lint fix + test) — run after completing any task
bun run precheck
# Remote Control Server
@ -311,7 +311,7 @@ mock.module("src/utils/debug.ts", debugMock);
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bun run precheck
bun run typecheck
```
**类型规范**
@ -324,14 +324,14 @@ bun run precheck
## Working with This Codebase
- **precheck must pass** — `bun run precheck`typecheck + lint fix + test必须零错误,任何修改都不能引入新的类型/lint/测试错误。
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
- **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 precheck` 确认无类型/lint/格式/测试问题pre-commit hook 会自动拦截不合格提交。
- **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 会自动拦截不合格提交。
- **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

@ -1,180 +1,154 @@
# 内存与性能峰值分析报告(最终版 — 5 轮迭代完成)
# 内存与性能峰值分析报告
> 进程 bun物理内存峰值 **700 MB+**,最差场景可达 **1.8 GB**
> 日期2026-05-02 | 状态:**调研完成** | 范围:内存峰值 + CPU 热点 + React 渲染循环
> Round 5 增量验证消息渲染管线buildMessageLookups 8 Map/Set 重建、useDeferredValue 双缓冲、FileReadTool 无上限、compaction 与 React 状态交互
> 进程bun物理内存峰值 **700 MB+**,最差场景可达 **1.8 GB**
> 日期2026-05-017 轮排查 + 验证,已压缩)
> 范围:内存峰值 + CPU 热点 + React 循环 effect
## 数据收集
- 典型场景 RSS 682 MB基线 JSC heap 300-400 MB
- Bun mimalloc 不归还内存页JSC 页管理只增不减(架构级限制
- 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 `ab0bbbc4`scrollback 限 500、contentReplacementState 清理等
## 已修复问题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` |
| 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` |
| contentReplacementState | 持续增长 | compact 清理 | `compact/compact.ts` |
| SSE 缓冲 | 无上限 | 1MB cap | SSE 处理代码 |
| # | 来源 | 峰值 | 位置 | 验证状态 |
| --- | --- | --- | --- | --- |
| 1 | 消息数组 **6-7x** 拷贝 | 120-320 MB | `query.ts:477,491,1135,1745,1878` | ✅ 已验证,比原估 4x 更严重 |
| 2 | Messages.tsx 转换管线 **24-25x** 遍历 | 100-270 MB | `Messages.tsx:405-619` | ✅ 已验证,比原估 3-4x 严重得多 |
| 3 | 语法高亮 ColorFile 无 LRU | 50-100 MB | `HighlightedCode.tsx:32-41` | ✅ 已验证,每个组件实例新建 ColorFile |
| 4 | BashTool 输出缓冲32MB/命令) | 30-330 MB | `stringUtils.ts:88` (`2**25` = 32MB) | ✅ 已验证 |
| 5 | Compact 峰值(老+新共存) | 20-80 MB | `compact/compact.ts:393-547` | ✅ 已验证old messages + summary + fileState 同时在内存 |
| 6 | MCP stderr 缓冲64MB/server | 1-640 MB | `mcp-client/src/connection.ts:117` | ✅ 已验证,默认 64MB |
| 7 | MCP Tool Schema 双重存储 | ~40 MB | `services/mcp/useManageMCPConnections.ts:258` + `AppStateStore.ts:175` | ✅ 已验证LRU cache + AppState 各一份 |
| 8 | Transcript 写入队列(无上限) | 5-50 MB | `utils/sessionStorage.ts:559-615` | ✅ 已验证,无 size check100ms drain |
| 9 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` | ✅ 已验证,仅 ant 用户、/clear 时清空 |
| 10 | 流式字符串拼接(`+=` O(n²) | 2-20 MB | `claude.ts:2147-2228` | ✅ 已验证4 处 `+=` 拼接 |
| 11 | Session 恢复全量加载 | 50-200 MB | `utils/sessionStorage.ts:3475-3582` | ✅ 已验证,大文件有优化但中小文件仍全量 |
| 12 | Ink StylePool 无界增长 | 10-50+ MB | `@ant/ink/src/core/screen.ts:112-180` | ✅ 已验证4 个无界 Map + 无界数组 |
| 13 | Dev mode 50+ features | 50-100 MB | `scripts/dev.ts:29-34` | 未验证dev only |
| 14 | AppState 不可变更新抖动 | 5-50 MB | `store.ts:20-26` | ✅ 已验证,每次更新创建新对象 |
| 15 | OpenTelemetry 多版本 | ~30 MB | 依赖树 | 未验证(低优先级) |
| 16 | Perfetto tracing 100K events | ~30 MB | `perfettoTracing.ts:99` | 未验证(低优先级) |
| 17 | Prompt Cache 规范化 | 5-15 MB | `claude.ts:3180-3329` | 未验证(低优先级) |
| 18 | GrepTool 全量 stat+sort | ~10 MB | `GrepTool.ts:523-557` | 未验证(低优先级) |
| 19 | mimalloc + JSC 不归还内存 | RSS 持续高位 | Bun 运行时 | ✅ 架构确认 |
## 仍存在的问题 — 内存(按峰值影响排序)
## 验证详情
### P0消息数组 7-8x 拷贝120-320 MB
### #1 消息数组 6-7x 拷贝P0
`src/query.ts` 每轮 turn 产生的拷贝Round 3 新增第 7 项):
原始估计 4x实际验证发现更多拷贝点
| 位置 | 操作 | 是否必要 | 优化方式 |
|------|------|----------|----------|
| `: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 |
| 位置 | 操作 | 拷贝类型 |
| --- | --- | --- |
| `query.ts:477` + `utils/messages.ts:4830` | `getMessagesAfterCompactBoundary``slice()` + `[...result]` | 浅拷贝 ×2 |
| `query.ts:491` | `applyToolResultBudget``messages.map()` | 浅拷贝 ×1 |
| `query.ts:1135` | `executePostSamplingHooks([...messages, ...assistant])` | spread 合并 ×1 |
| `query.ts:1745` | `getAttachmentMessages(null, ctx, null, cmds, [...msgs, ...asst, ...results])` | spread 合并 ×1 |
| `query.ts:1878` | State 更新 `{ messages: [...msgs, ...asst, ...results] }` | spread 合并 ×1 |
| `query.ts:897` | `clonedContent ??= [...contentArr]` | 条件性拷贝 ×1 |
峰值时 3-4 份完整消息数组同时驻留477 + 1745 + 1857 + 1878 在同一 turn 尾部顺序执行)
总计每轮查询循环 **6-7 次数组浅拷贝**。单次拷贝开销小(指针数组),但累积峰值叠加时占用大量临时内存
### P0React 消息管线重复计算Round 5 新增分析
### #2 Messages.tsx 24-25 次遍历P1
**buildMessageLookups 每次 useMemo 重算时创建 8 个 Map/Set**`messages.ts:1215-1398`
原始估计 3-4 次,实际有 10 个独立处理阶段
| 数据结构 | 规模 | 说明 |
|----------|------|------|
| `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 |
1. `normalizedMessages` (行 405) — `normalizeMessages` + `filter` = 2 次
2. `lastThinkingBlockId` (行 421-446) — 反向遍历 = 1 次
3. `latestBashOutputUUID` (行 450-468) — 反向遍历 = 1 次
4. `normalizedToolUseIDs` (行 472) — `getToolUseIDs` = 1 次
5. `streamingToolUsesWithoutInProgress` (行 474-480) — `filter` = 1 次
6. `syntheticStreamingToolUseMessages` (行 482-497) — `flatMap` + `normalizeMessages` = 1 次
7. **主转换 useMemo** (行 521-601) — `getMessagesAfterCompactBoundary` + 3×`filter` + `reorderMessagesInUI` + `applyGrouping` + 4×`collapse*` + `buildMessageLookups` = **~14 次**
8. `renderableMessages` (行 604-619) — `slice` = 1 次
9. `dividerBeforeIndex` (行 629-633) — `findIndex` = 1 次
10. `selectedIdx` (行 635-638) — `findIndex` = 1 次
此 useMemo`Messages.tsx:519`)依赖 normalizedMessages任何消息变更含流式 delta触发重建。已拆分 renderRange 避免滚动触发注释明确记录50ms alloc per scroll → GC → 100-173ms STW on 1GB heap
**总计 ~24 次遍历**,主转换 useMemo 单独贡献 14 次
**useDeferredValue 双缓冲**`REPL.tsx:1569`):流式期间 `messages``deferredMessages` 同时持有两份完整数组,直到 React 调度更新。在 27k 消息场景下,额外 ~100-200MB 临时占用。
### #3 ColorFile 无 LRUP1
**FileReadTool 无大小限制**`FileReadTool.ts:342``maxResultSizeChars: Infinity`,单次 10MB 文件读取完整保留在消息数组中。BashTool30KB和 GrepTool20KB有合理上限。
- `HighlightedCode.tsx:32-41`:每次 `useMemo` 创建新 `ColorFile(code, filePath)` 实例
- `color-diff-napi` 内部有全局 `hlLineCache`Map上限 2048 条目)缓存 AST但不缓存渲染结果
- 无跨实例复用,大量代码块场景下每个组件持有一份完整 code 字符串
### P0Compaction 与 React 状态交互Round 5 新增分析)
### #4 BashTool 输出缓冲P2
**非全屏模式**`REPL.tsx:3074-3075`compact 后 `setMessages(() => [newMessage])` 正确替换整组旧消息,内存立即释放。
- `stringUtils.ts:88``const MAX_STRING_LENGTH = 2 ** 25` = **32 MB**(非 33MB
- 单条 Bash 命令输出可占 32MB 后才触发截断
**全屏模式**`REPL.tsx:3056-3072`):保留最多 500 条消息的 scrollback。注释记录Ink fiber 树每条消息 ~250KB RSS无 cap 时观察过 13k+ 消息 → 1GB+ heap。
### #5 Compact 峰值
**Microcompact 的局限**`microCompact.ts:472-494`):用 spread 创建新消息对象替换内容为 `[Old tool result content cleared]`。但 `ContentReplacementState.replacements` Map`toolResultStorage.ts:392`)仍保留原始替换字符串,直到 compact 时才清理。这意味着 microcompact 减少了 token 数,但实际内存释放依赖后续 compact。
- `compact/compact.ts:407`:先 `tokenCountWithEstimation(messages)` 遍历全量
- 整个 compact 过程 `messages` 数组不释放
- 额外创建 `preCompactReadFileState`、`postCompactFileAttachments`、`asyncAgentAttachments`
- 峰值 = old messages + API summary response + file state + attachments
### P0Compact 峰值20-80 MB
### #6 MCP stderr 缓冲
峰值时间线(`compact.ts:524-644`
```
Before: messages(200K) + mutableMessages(200K) = 400K tokens
During: + preCompactReadFileState(25MB) + summary + attachments ≈ 500K+ tokens
After: splice → 50K tokens
```
- `mcp-client/src/connection.ts:117``maxSize = 64 * 1024 * 1024`64MB 默认值)
- 每个 MCP server 连接独立缓冲10 个 server = 640MB 理论上限
可提前释放:`preCompactReadFileState`25MB、`summaryResponse`、原始 `messages` 参数。
### #7 MCP Tool Schema 双重存储
### P0React Hooks 闭包与 useMemo 链Round 5 深入排查)
- `services/mcp/useManageMCPConnections.ts:258`:更新时 `[...reject(mcp.tools, ...), ...tools]` 创建新数组
- 存储位置:`fetchToolsForClient` LRU20 条目)+ `AppState.mcp.tools` 数组
- 20 servers × ~50 tools × ~2KB/tool ≈ 2MB 重复
**useCallback 闭包重建**`REPL.tsx`
### #8 Transcript 写入队列
| 回调 | 依赖项数 | 位置 | 影响 |
|------|----------|------|------|
| `getToolUseContext` | 20 | `:2789-2949` | 重建时旧闭包持有的引用阻止 GC |
| `onQueryImpl` | 14 | `:3188-3469` | 包含 getToolUseContext + 多层嵌套闭包 |
| `onQuery` | 在 onQueryImpl 上再包装 | `:3471-3697` | 又一层闭包 |
| `onSubmit` | ~10 | `:3822-4298` | 闭包链嵌套 3 层 |
- `utils/sessionStorage.ts:561-564``writeQueues = new Map<string, Array<{entry, resolve}>>()` 无大小限制
- 每 100ms drain`FLUSH_INTERVAL_MS = 100`),高频写入时条目堆积
每次 `messages` 变更触发 `setMessages` → React 重渲染 → 依赖 messages 的 useCallback/useMemo 全部重建。但 `getToolUseContext``onQueryImpl` **没有把 `messages` 放入依赖数组**(通过 `messagesRef.current` 参数传递规避),所以这些闭包不会因 messages 变化而重建。**这实际上是正确的设计**——用 ref 规避了闭包捕获问题。
### #9 lastAPIRequestMessages
**真正的 hooks 问题**在于 useMemo 链(`Messages.tsx`
- `bootstrap/state.ts:118`:声明为模块级变量
- 仅 `ant` 用户设置(`log.ts:350`),非 ant 用户直接 `null`
- `/clear` 时通过 `clear/conversation.ts:155` 清空
```
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))
```
### #10 流式字符串拼接
流式期间每个 delta 触发 `messages` 变更 → 整条链全量重算。注释记录50ms alloc per scroll → GC → 100-173ms STW on 1GB heap`Messages.tsx:516-518`)。
- `claude.ts` 中 4 处 `+=` 操作:
- 行 2147-2148`connector_text += delta.connector_text`
- 行 2178`contentBlock.input += delta.partial_json`
- 行 2192`contentBlock.text += delta.text`
- 行 2227-2228`contentBlock.thinking += delta.thinking`
- 长流式响应时产生 O(n²) 内存分配
**无界 useRef**`REPL.tsx`
### #11 Session 恢复
| 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\> |
- 大文件(> `SKIP_PRECOMPACT_THRESHOLD`):使用 `readTranscriptForLoad()` 只加载 post-boundary 内容,有优化
- 中小文件(< threshold`readFile(filePath)` 全量读入
- 优化已部分到位,但阈值以下的文件仍全量加载
结论:**这些 ref 都有清理机制**,不是主要问题。核心问题仍是 useMemo 链在流式期间的全量重算。
### #12 Ink StylePool
### P1虚拟滚动组件~50 MB— Round 3 新发现
- `screen.ts:112-180``StylePool` 类含 4 个无界 Map/Array
- `ids: Map<string, number>` — style key → id
- `styles: AnsiCode[][]` — 无界数组
- `transitionCache: Map<number, string>`
- `inverseCache: Map<number, number>`
- `currentMatchCache: Map<number, number>`
- `intern()` 只 push 不淘汰
`src/hooks/useVirtualScroll.ts` + React Ink 渲染管线:
- MAX_MOUNTED_ITEMS = 300OVERSCAN_ROWS = 80
- 实际挂载约 200 个 MessageRow视口 + overscan
- 每个 MessageRow ≈ 250KB RSSReact fiber + Yoga node + 子组件树)
- **总计约 50 MB 常驻内存**(当前会话最大挂载窗口)
### #14 AppState 不可变更新
优化空间:降低 MAX_MOUNTED_ITEMS 或 OVERSCAN_ROWS评估 MessageRow 组件内部 memo 化。
- `store.ts:20-26``setState` 要求返回新对象,`Object.is` 比较后通知
- `useManageMCPConnections.ts` 每次 MCP 更新 spread 整个 `prevState`
### 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其他已确认内存问题
| # | 问题 | 峰值 | 位置 |
|---|------|------|------|
| 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 结束后释放 |
### 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 与渲染热点(第 6 轮探索 + 第 7 轮验证)
### 已确认
| # | 问题 | 影响 | 位置 |
|---|------|------|------|
| 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` |
| --- | --- | --- | --- |
| 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` |
| C7 | **CompanionSprite TICK_MS 定时器**500ms每秒 2 次 setState | 高频 setState 触发渲染 | `buddy/CompanionSprite.tsx:15,136` |
| C9 | 同步 fs 操作 | 阻塞主线程 | `projectOnboardingState.ts:20` 等 |
### 已否认
@ -184,7 +158,7 @@ messages → normalizedMessages (O(n))
- **Yoga 无增量布局** — 实测增量更新高效1000 节点树改 1 叶子 → 仅 2 次 measure其余走缓存
- **Ink Yoga 2^depth 问题** — 实测 100 节点深链 = 11.7x 访问(线性增长,非指数级)
### 已有优化措施
### 已确认的优化措施(已有)
- React ConcurrentRoot 自动批处理 setState多个 setState → 1 次 commit
- Ink 帧率限制 16msthrottle 仅限终端输出Yoga 布局无 throttle 但被 React batching 保护)
@ -194,101 +168,50 @@ messages → normalizedMessages (O(n))
- 双缓冲 + 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 是虚拟地址映射非物理内存
- RSS 波动是正常 GC 行为
- useSkillsChange / useSettingsChange 订阅泄漏 — 验证为正确的 React cleanup 模式
- Zod Schema 开销 — 仅 ~200-650KB已有 lazySchema + WeakMap 缓存
- Ink ClockContext 16ms 定时器 — 影响 CPU 不影响内存
## 结论
**内存根因**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 不归还内存页**(架构级限制)
**内存根因**:消息数组多重拷贝 + JSC/mimalloc 不归还内存。典型 700 MB最差 1.8 GB。
**CPU 根因**useInboxPoller 每秒轮询触发 React commit → 全量 Yoga 布局 → 全屏 Ink diff 的完整管线。Markdown 渲染(~1.5ms/行)在批量挂载新消息时造成 ~290ms 卡顿。轮询导致的周期性 commit 与消息挂载的 CPU 密集操作互相放大。
**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 间接持有)
### P0消息数组拷贝降 100-200 MB 内存)
**预估优化空间**
1. `query.ts:491` — applyToolResultBudget 按需拷贝
2. `query.ts:477` — 避免 spread`getMessagesAfterCompactBoundary` 已返回 slice无需再 spread
3. `query.ts:1878` — 追加而非重建(用 `push` 替代 `[...prev, ...new]`
4. `query.ts:1135,1745` — read-only 场景传引用而非 spread
| 优先级 | 措施 | 预估降低 |
|--------|------|----------|
| 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** |
### P1渲染管线降 50-150 MB + 降低 CPU
理论可从当前 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)` 低内存触发
1. `Messages.tsx:521-601` — 合并主转换 useMemo 中的 14 次遍历为单次 pass
2. `HighlightedCode.tsx:32-41` — ColorFile 实例级 LRU50 条)或 WeakMap 跨实例复用
3. `buddy/CompanionSprite.tsx:15` — TICK_MS 从 500ms 评估能否提升至 1000ms+
### P2Ink 渲染层(降低 CPU 开销)
22. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
1. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
## 附录
### P3内存 + 低优先级
- 合并来源:`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 状态交互细节
1. `lastAPIRequestMessages` — 非 debug 清空
2. `claude.ts:2147-2228` — 4 处流式 `+=` 改数组累积后 `join('')`
3. `mcp-client/connection.ts:117` — stderr 缓冲从 64MB 降至 8MB
4. Session 恢复中小文件也使用流式解析
5. BashTool `MAX_STRING_LENGTH` 从 32MB 降至 2MB
6. MCP Tool Schema 消除双重存储(只保留 AppState 一份)
7. Ink StylePool 加 LRU 淘汰(如 1000 条目上限)
8. Transcript 写入队列加 maxQueueSize 限制
9. OpenTelemetry 统一版本
10. AppState 无界集合加淘汰策略
11. GrepTool 先 limit 再 stat+sort
12. 评估 `Bun.gc(true)` 强制 GC

View File

@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "2.6.5",
"version": "1.11.1",
"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>",
@ -22,20 +22,23 @@
"repl"
],
"engines": {
"bun": ">=1.2.0"
"bun": ">=1.3.0"
},
"bin": {
"ccb": "dist/cli.js",
"claude-code-best": "dist/cli.js"
"ccb": "dist/cli-node.js",
"ccb-bun": "dist/cli-bun.js",
"claude-code-best": "dist/cli-node.js"
},
"workspaces": [
"packages/*",
"packages/@ant/*"
"packages/@ant/*",
"packages/@anthropic-ai/*"
],
"files": [
"dist",
"scripts/download-ripgrep.ts",
"scripts/postinstall.cjs"
"scripts/postinstall.cjs",
"scripts/run-parallel.mjs",
"scripts/setup-chrome-mcp.mjs"
],
"scripts": {
"build": "bun run build.ts",
@ -44,20 +47,32 @@
"build:bun": "bun run build.ts",
"dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build",
"lint": "biome lint src/",
"lint:fix": "biome lint --fix src/",
"format": "biome format --write src/",
"prepublishOnly": "bun run build:vite",
"lint": "biome lint .",
"lint:fix": "biome lint --fix .",
"format": "biome format --write .",
"check": "biome check .",
"check:fix": "biome check --fix .",
"prepare": "husky",
"test": "bun test",
"test:production": "bun run scripts/production-test.ts",
"test:production:offline": "bun run scripts/production-test.ts --offline",
"test:production:verbose": "bun run scripts/production-test.ts --verbose",
"test:production:bun": "bun run scripts/production-test.ts --bun",
"check:bundle": "bun run scripts/check-bundle-integrity.ts",
"check:unused": "knip-bun",
"health": "bun run scripts/health-check.ts",
"postinstall": "node scripts/postinstall.cjs",
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"precheck": "bun run typecheck && bun run check:fix && bun test",
"rcs": "bun run scripts/rcs.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0"
"@agentclientprotocol/sdk": "^0.19.0",
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
"highlight.js": "^11.11.1",
"ws": "^8.20.0"
},
"devDependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
@ -65,59 +80,74 @@
"@ant/computer-use-input": "workspace:*",
"@ant/computer-use-mcp": "workspace:*",
"@ant/computer-use-swift": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.26.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@ant/model-provider": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.29.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
"@anthropic-ai/mcpb": "^2.1.2",
"@anthropic-ai/sandbox-runtime": "^0.0.44",
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic-ai/sdk": "^0.81.0",
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@anthropic/ink": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0",
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/credential-providers": "^3.1020.0",
"@aws-sdk/client-bedrock": "^3.1037.0",
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
"@aws-sdk/client-sts": "^3.1037.0",
"@aws-sdk/credential-provider-node": "^3.972.36",
"@aws-sdk/credential-providers": "^3.1037.0",
"@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.10",
"@biomejs/biome": "^2.4.12",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@claude-code-best/weixin": "workspace:*",
"@commander-js/extra-typings": "^14.0.0",
"@growthbook/growthbook": "^1.6.5",
"@langfuse/otel": "^5.1.0",
"@langfuse/tracing": "^5.1.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-prometheus": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/api-logs": "^0.215.0",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
"@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-logs": "^0.215.0",
"@opentelemetry/sdk-metrics": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1",
"@types/bun": "^1.3.11",
"@sentry/node": "^10.49.0",
"@smithy/core": "^3.23.15",
"@smithy/node-http-handler": "^4.5.3",
"@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3",
"@types/plist": "^3.0.5",
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-reconciler": "^0.33.0",
"@types/semver": "^7.7.1",
"@types/sharp": "^0.32.0",
"@types/shell-quote": "^1.7.5",
"@types/stack-utils": "^2.0.3",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1",
"ajv": "^8.18.0",
"asciichart": "^1.5.25",
"audio-capture-napi": "workspace:*",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"axios": "^1.15.2",
"bidi-js": "^1.0.3",
"cacache": "^20.0.4",
"chalk": "^5.6.2",
@ -132,32 +162,32 @@
"execa": "^9.6.1",
"fflate": "^0.8.2",
"figures": "^6.1.0",
"fuse.js": "^7.1.0",
"fuse.js": "^7.3.0",
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"https-proxy-agent": "^8.0.0",
"husky": "^9.1.7",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.1.1",
"lint-staged": "^17.0.7",
"lodash-es": "^4.17.23",
"lru-cache": "^11.2.7",
"marked": "^17.0.5",
"knip": "^6.4.1",
"lint-staged": "^16.4.0",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
"modifiers-napi": "workspace:*",
"openai": "^6.33.0",
"openai": "^6.34.0",
"p-map": "^7.0.4",
"picomatch": "^4.0.4",
"plist": "^3.1.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react": "^19.2.5",
"react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0",
"rollup": "^4.60.2",
"semver": "^7.7.4",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
@ -166,22 +196,32 @@
"strip-ansi": "^7.2.0",
"supports-hyperlinks": "^4.4.0",
"tree-kill": "^1.2.2",
"turndown": "^7.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"undici": "^7.24.6",
"turndown": "^7.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"undici": "^7.25.0",
"url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1",
"vite": "^6.0.0",
"vite": "^8.0.8",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-types": "^3.17.5",
"wrap-ansi": "^10.0.0",
"ws": "^8.20.0",
"xss": "^1.0.15",
"yaml": "^2.8.3",
"zod": "^4.3.6"
},
"optionalDependencies": {
"doubaoime-asr": "^0.1.0"
},
"overrides": {
"@inquirer/prompts": "8.4.2",
"@xmldom/xmldom": "0.8.13",
"follow-redirects": "1.16.0",
"hono": "4.12.15",
"postcss": "8.5.10",
"uuid": "14.0.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs,jsx}": [
"biome check --fix --no-errors-on-unmatched"

View File

@ -1,50 +1,50 @@
import { feature } from 'bun:bundle'
import figures from 'figures'
import React, { useEffect, useRef, useState } from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { Box, Text, stringWidth } from '@anthropic/ink'
import { useAppState, useSetAppState } from '../state/AppState.js'
import type { AppState } from '../state/AppStateStore.js'
import { getGlobalConfig } from '../utils/config.js'
import { isFullscreenActive } from '../utils/fullscreen.js'
import type { Theme } from '../utils/theme.js'
import { getCompanion } from './companion.js'
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
import { RARITY_COLORS } from './types.js'
import { feature } from 'bun:bundle';
import figures from 'figures';
import React, { useEffect, useRef, useState } from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { Box, Text, stringWidth } from '@anthropic/ink';
import { useAppState, useSetAppState } from '../state/AppState.js';
import type { AppState } from '../state/AppStateStore.js';
import { getGlobalConfig } from '../utils/config.js';
import { isFullscreenActive } from '../utils/fullscreen.js';
import type { Theme } from '../utils/theme.js';
import { getCompanion } from './companion.js';
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
import { RARITY_COLORS } from './types.js';
const TICK_MS = 500
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
const TICK_MS = 1000;
const BUBBLE_SHOW = 10; // ticks → ~10s at 1000ms
const FADE_WINDOW = 3; // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
const H = figures.heart
const H = figures.heart;
const PET_HEARTS = [
` ${H} ${H} `,
` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `,
`${H} ${H} ${H} `,
'· · · ',
]
];
function wrap(text: string, width: number): string[] {
const words = text.split(' ')
const lines: string[] = []
let cur = ''
const words = text.split(' ');
const lines: string[] = [];
let cur = '';
for (const w of words) {
if (cur.length + w.length + 1 > width && cur) {
lines.push(cur)
cur = w
lines.push(cur);
cur = w;
} else {
cur = cur ? `${cur} ${w}` : w
cur = cur ? `${cur} ${w}` : w;
}
}
if (cur) lines.push(cur)
return lines
if (cur) lines.push(cur);
return lines;
}
function SpeechBubble({
@ -53,40 +53,29 @@ function SpeechBubble({
fading,
tail,
}: {
text: string
color: keyof Theme
fading: boolean
tail: 'down' | 'right'
text: string;
color: keyof Theme;
fading: boolean;
tail: 'down' | 'right';
}): React.ReactNode {
const lines = wrap(text, 30)
const borderColor = fading ? 'inactive' : color
const lines = wrap(text, 30);
const borderColor = fading ? 'inactive' : color;
const bubble = (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
paddingX={1}
width={34}
>
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
{lines.map((l, i) => (
<Text
key={i}
italic
dimColor={!fading}
color={fading ? 'inactive' : undefined}
>
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
{l}
</Text>
))}
</Box>
)
);
if (tail === 'right') {
return (
<Box flexDirection="row" alignItems="center">
{bubble}
<Text color={borderColor}></Text>
</Box>
)
);
}
return (
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
@ -96,18 +85,18 @@ function SpeechBubble({
<Text color={borderColor}></Text>
</Box>
</Box>
)
);
}
export const MIN_COLS_FOR_FULL_SPRITE = 100
const SPRITE_BODY_WIDTH = 12
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
const SPRITE_PADDING_X = 2
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24
export const MIN_COLS_FOR_FULL_SPRITE = 100;
const SPRITE_BODY_WIDTH = 12;
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
const SPRITE_PADDING_X = 2;
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24;
function spriteColWidth(nameWidth: number): number {
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
}
// Width the sprite area consumes. PromptInput subtracts this so text wraps
@ -115,89 +104,73 @@ function spriteColWidth(nameWidth: number): number {
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
// (above input in fullscreen, below in scrollback), so no reservation.
export function companionReservedColumns(
terminalColumns: number,
speaking: boolean,
): number {
if (!feature('BUDDY')) return 0
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return 0
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
const nameWidth = stringWidth(companion.name)
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
if (!feature('BUDDY')) return 0;
const companion = getCompanion();
if (!companion || getGlobalConfig().companionMuted) return 0;
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
const nameWidth = stringWidth(companion.name);
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
}
export function CompanionSprite(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction)
const petAt = useAppState(s => s.companionPetAt)
const focused = useAppState(s => s.footerSelection === 'companion')
const setAppState = useSetAppState()
const { columns } = useTerminalSize()
const [tick, setTick] = useState(0)
const lastSpokeTick = useRef(0)
const reaction = useAppState(s => s.companionReaction);
const petAt = useAppState(s => s.companionPetAt);
const focused = useAppState(s => s.footerSelection === 'companion');
const setAppState = useSetAppState();
const { columns } = useTerminalSize();
const [tick, setTick] = useState(0);
const lastSpokeTick = useRef(0);
// Sync-during-render (not useEffect) so the first post-pet render already
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
const [{ petStartTick, forPetAt }, setPetStart] = useState({
petStartTick: 0,
forPetAt: petAt,
})
});
if (petAt !== forPetAt) {
setPetStart({ petStartTick: tick, forPetAt: petAt })
setPetStart({ petStartTick: tick, forPetAt: petAt });
}
useEffect(() => {
const timer = setInterval(
setT => setT((t: number) => t + 1),
TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [])
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!reaction) return
lastSpokeTick.current = tick
if (!reaction) return;
lastSpokeTick.current = tick;
const timer = setTimeout(
setA =>
setA((prev: AppState) =>
prev.companionReaction === undefined
? prev
: { ...prev, companionReaction: undefined },
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
),
BUBBLE_SHOW * TICK_MS,
setAppState,
)
return () => clearTimeout(timer)
);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
}, [reaction, setAppState])
}, [reaction, setAppState]);
if (!feature('BUDDY')) return null
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null
if (!feature('BUDDY')) return null;
const companion = getCompanion();
if (!companion || getGlobalConfig().companionMuted) return null;
const color = RARITY_COLORS[companion.rarity]
const colWidth = spriteColWidth(stringWidth(companion.name))
const color = RARITY_COLORS[companion.rarity];
const colWidth = spriteColWidth(stringWidth(companion.name));
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
const fading =
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
const petAge = petAt ? tick - petStartTick : Infinity
const petting = petAge * TICK_MS < PET_BURST_MS
const petAge = petAt ? tick - petStartTick : Infinity;
const petting = petAge * TICK_MS < PET_BURST_MS;
// Narrow terminals: collapse to one-line face. When speaking, the quip
// replaces the name beside the face (no room for a bubble).
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
const quip =
reaction && reaction.length > NARROW_QUIP_CAP
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
: reaction
const label = quip
? `"${quip}"`
: focused
? ` ${companion.name} `
: companion.name
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
return (
<Box paddingX={1} alignSelf="flex-end">
<Text>
@ -210,44 +183,34 @@ export function CompanionSprite(): React.ReactNode {
dimColor={!focused && !reaction}
bold={focused}
inverse={focused && !reaction}
color={
reaction
? fading
? 'inactive'
: color
: focused
? color
: undefined
}
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
>
{label}
</Text>
</Text>
</Box>
)
);
}
const frameCount = spriteFrameCount(companion.species)
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
const frameCount = spriteFrameCount(companion.species);
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
let spriteFrame: number
let blink = false
let spriteFrame: number;
let blink = false;
if (reaction || petting) {
// Excited: cycle all fidget frames fast
spriteFrame = tick % frameCount
spriteFrame = tick % frameCount;
} else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
if (step === -1) {
spriteFrame = 0
blink = true
spriteFrame = 0;
blink = true;
} else {
spriteFrame = step % frameCount
spriteFrame = step % frameCount;
}
}
const body = renderSprite(companion, spriteFrame).map(line =>
blink ? line.replaceAll(companion.eye, '-') : line,
)
const sprite = heartFrame ? [heartFrame, ...body] : body
const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line));
const sprite = heartFrame ? [heartFrame, ...body] : body;
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
// focused shows inverse name. The enter-to-open hint lives in
@ -255,31 +218,20 @@ export function CompanionSprite(): React.ReactNode {
// sprite doesn't jump up when selected. flexShrink=0 stops the
// inline-bubble row wrapper from squeezing the sprite to fit.
const spriteColumn = (
<Box
flexDirection="column"
flexShrink={0}
alignItems="center"
width={colWidth}
>
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
{sprite.map((line, i) => (
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
{line}
</Text>
))}
<Text
italic
bold={focused}
dimColor={!focused}
color={focused ? color : undefined}
inverse={focused}
>
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
{focused ? ` ${companion.name} ` : companion.name}
</Text>
</Box>
)
);
if (!reaction) {
return <Box paddingX={1}>{spriteColumn}</Box>
return <Box paddingX={1}>{spriteColumn}</Box>;
}
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
@ -288,19 +240,14 @@ export function CompanionSprite(): React.ReactNode {
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
// because floating into Static scrollback can't be cleared.
if (isFullscreenActive()) {
return <Box paddingX={1}>{spriteColumn}</Box>
return <Box paddingX={1}>{spriteColumn}</Box>;
}
return (
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
<SpeechBubble
text={reaction}
color={color}
fading={fading}
tail="right"
/>
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
{spriteColumn}
</Box>
)
);
}
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
@ -308,33 +255,29 @@ export function CompanionSprite(): React.ReactNode {
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
// just reads companionReaction and renders the fade.
export function CompanionFloatingBubble(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction)
const reaction = useAppState(s => s.companionReaction);
const [{ tick, forReaction }, setTick] = useState({
tick: 0,
forReaction: reaction,
})
});
// Reset tick synchronously when reaction changes (not in useEffect, which
// runs post-render and would show one stale-faded frame). Storing the
// reaction the tick is counting FOR alongside the tick itself means the
// fade computation never sees a tick from a previous reaction.
if (reaction !== forReaction) {
setTick({ tick: 0, forReaction: reaction })
setTick({ tick: 0, forReaction: reaction });
}
useEffect(() => {
if (!reaction) return
const timer = setInterval(
set => set(s => ({ ...s, tick: s.tick + 1 })),
TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [reaction])
if (!reaction) return;
const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick);
return () => clearInterval(timer);
}, [reaction]);
if (!feature('BUDDY') || !reaction) return null
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null
if (!feature('BUDDY') || !reaction) return null;
const companion = getCompanion();
if (!companion || getGlobalConfig().companionMuted) return null;
return (
<SpeechBubble
@ -343,5 +286,5 @@ export function CompanionFloatingBubble(): React.ReactNode {
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
tail="down"
/>
)
);
}

View File

@ -1,21 +1,27 @@
import * as React from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useSettings } from '../hooks/useSettings.js'
import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink'
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
import sliceAnsi from '../utils/sliceAnsi.js'
import { countCharInString } from '../utils/stringUtils.js'
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js'
import { expectColorFile } from './StructuredDiff/colorDiff.js'
import * as React from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useSettings } from '../hooks/useSettings.js';
import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink';
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
import sliceAnsi from '../utils/sliceAnsi.js';
import { countCharInString } from '../utils/stringUtils.js';
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js';
import { expectColorFile } from './StructuredDiff/colorDiff.js';
import type { ColorFile as ColorFileType } from 'color-diff-napi';
// Module-level LRU cache for ColorFile instances to avoid recreating
// them for the same (filePath, code) across component instances.
const colorFileCache = new Map<string, { colorFile: ColorFileType; code: string }>();
const COLOR_FILE_CACHE_MAX = 50;
type Props = {
code: string
filePath: string
width?: number
dim?: boolean
}
code: string;
filePath: string;
width?: number;
dim?: boolean;
};
const DEFAULT_WIDTH = 80
const DEFAULT_WIDTH = 80;
export const HighlightedCode = memo(function HighlightedCode({
code,
@ -23,39 +29,53 @@ export const HighlightedCode = memo(function HighlightedCode({
width,
dim = false,
}: Props): React.ReactElement {
const ref = useRef<DOMElement>(null)
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH)
const [theme] = useTheme()
const settings = useSettings()
const syntaxHighlightingDisabled =
settings.syntaxHighlightingDisabled ?? false
const ref = useRef<DOMElement>(null);
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH);
const [theme] = useTheme();
const settings = useSettings();
const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false;
const colorFile = useMemo(() => {
if (syntaxHighlightingDisabled) {
return null
return null;
}
const ColorFile = expectColorFile()
const ColorFile = expectColorFile();
if (!ColorFile) {
return null
return null;
}
return new ColorFile(code, filePath)
}, [code, filePath, syntaxHighlightingDisabled])
const cacheKey = `${filePath}\0${code.length}`;
const cached = colorFileCache.get(cacheKey);
if (cached && cached.code === code) {
// Move to end (most recently used)
colorFileCache.delete(cacheKey);
colorFileCache.set(cacheKey, cached);
return cached.colorFile;
}
const instance = new ColorFile(code, filePath);
// Evict oldest entry if cache is full
if (colorFileCache.size >= COLOR_FILE_CACHE_MAX) {
const oldest = colorFileCache.keys().next().value;
if (oldest !== undefined) colorFileCache.delete(oldest);
}
colorFileCache.set(cacheKey, { colorFile: instance, code });
return instance;
}, [code, filePath, syntaxHighlightingDisabled]);
useEffect(() => {
if (!width && ref.current) {
const { width: elementWidth } = measureElement(ref.current)
const { width: elementWidth } = measureElement(ref.current);
if (elementWidth > 0) {
setMeasuredWidth(elementWidth - 2)
setMeasuredWidth(elementWidth - 2);
}
}
}, [width])
}, [width]);
const lines = useMemo(() => {
if (colorFile === null) {
return null
return null;
}
return colorFile.render(theme, measuredWidth, dim)
}, [colorFile, theme, measuredWidth, dim])
return colorFile.render(theme, measuredWidth, dim);
}, [colorFile, theme, measuredWidth, dim]);
// Gutter width matches ColorFile's layout in lib.rs: space + right-aligned
// line number (max_digits = lineCount.toString().length) + space. No marker
@ -64,10 +84,10 @@ export const HighlightedCode = memo(function HighlightedCode({
// (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native
// selection where noSelect is meaningless.
const gutterWidth = useMemo(() => {
if (!isFullscreenEnvEnabled()) return 0
const lineCount = countCharInString(code, '\n') + 1
return lineCount.toString().length + 2
}, [code])
if (!isFullscreenEnvEnabled()) return 0;
const lineCount = countCharInString(code, '\n') + 1;
return lineCount.toString().length + 2;
}, [code]);
return (
<Box ref={ref}>
@ -84,26 +104,15 @@ export const HighlightedCode = memo(function HighlightedCode({
)}
</Box>
) : (
<HighlightedCodeFallback
code={code}
filePath={filePath}
dim={dim}
skipColoring={syntaxHighlightingDisabled}
/>
<HighlightedCodeFallback code={code} filePath={filePath} dim={dim} skipColoring={syntaxHighlightingDisabled} />
)}
</Box>
)
})
);
});
function CodeLine({
line,
gutterWidth,
}: {
line: string
gutterWidth: number
}): React.ReactNode {
const gutter = sliceAnsi(line, 0, gutterWidth)
const content = sliceAnsi(line, gutterWidth)
function CodeLine({ line, gutterWidth }: { line: string; gutterWidth: number }): React.ReactNode {
const gutter = sliceAnsi(line, 0, gutterWidth);
const content = sliceAnsi(line, gutterWidth);
return (
<Box flexDirection="row">
<NoSelect fromLeftEdge>
@ -115,5 +124,5 @@ function CodeLine({
<Ansi>{content}</Ansi>
</Text>
</Box>
)
);
}

View File

@ -1,125 +1,130 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { feature } from 'bun:bundle'
import { feature } from 'bun:bundle';
// Dead code elimination: conditional import for COORDINATOR_MODE
/* eslint-disable @typescript-eslint/no-require-imports */
const coordinatorModule = feature('COORDINATOR_MODE')
? (require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js'))
: undefined
: undefined;
/* eslint-enable @typescript-eslint/no-require-imports */
import { Box, Text, Link } from '@anthropic/ink'
import * as React from 'react'
import figures from 'figures'
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js'
import type { ToolPermissionContext } from '../../Tool.js'
import { isVimModeEnabled } from './utils.js'
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
import { Box, Text, Link } from '@anthropic/ink';
import * as React from 'react';
import figures from 'figures';
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js';
import type { ToolPermissionContext } from '../../Tool.js';
import { isVimModeEnabled } from './utils.js';
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
import {
isDefaultMode,
permissionModeSymbol,
permissionModeTitle,
getModeColor,
} from '../../utils/permissions/PermissionMode.js'
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js'
import { isBackgroundTask } from '../../tasks/types.js'
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js'
import { count } from '../../utils/array.js'
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import { TeamStatus } from '../teams/TeamStatus.js'
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'
import { useAppState, useAppStateStore } from 'src/state/AppState.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import HistorySearchInput from './HistorySearchInput.js'
import { usePrStatus } from '../../hooks/usePrStatus.js'
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { useTasksV2 } from '../../hooks/useTasksV2.js'
import { formatDuration } from '../../utils/format.js'
import { VoiceWarmupHint } from './VoiceIndicator.js'
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
import { useVoiceState } from '../../context/voice.js'
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { getPlatform } from '../../utils/platform.js'
import { PrBadge } from '../PrBadge.js'
import * as proactiveModuleValue from '../../proactive/index.js'
} from '../../utils/permissions/PermissionMode.js';
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js';
import { isBackgroundTask } from '../../tasks/types.js';
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js';
import { count } from '../../utils/array.js';
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
import { TeamStatus } from '../teams/TeamStatus.js';
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js';
import { useAppState, useAppStateStore } from 'src/state/AppState.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import HistorySearchInput from './HistorySearchInput.js';
import { usePrStatus } from '../../hooks/usePrStatus.js';
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useTasksV2 } from '../../hooks/useTasksV2.js';
import { formatDuration, formatFileSize } from '../../utils/format.js';
import { VoiceWarmupHint } from './VoiceIndicator.js';
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js';
import { useVoiceState } from '../../context/voice.js';
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { getPlatform } from '../../utils/platform.js';
import { PrBadge } from '../PrBadge.js';
const proactiveModule =
feature('PROACTIVE') || feature('KAIROS')
? proactiveModuleValue
: null
const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
const NULL = () => null
const MAX_VOICE_HINT_SHOWS = 3
// Dead code elimination: conditional import for proactive mode
/* eslint-disable @typescript-eslint/no-require-imports */
const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') : null;
/* eslint-enable @typescript-eslint/no-require-imports */
const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
const NULL = () => null;
const MAX_VOICE_HINT_SHOWS = 3;
const RSS_UPDATE_INTERVAL_MS = 5_000;
type RssState = { text: string; level: 'normal' | 'warning' | 'error' };
function useRssDisplay(): RssState | null {
const [state, setState] = useState<RssState | null>(null);
useEffect(() => {
function update(): void {
const mb = process.memoryUsage().rss / (1024 * 1024);
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal';
const text = formatFileSize(mb * 1024 * 1024);
setState(prev => (prev?.text === text ? prev : { text, level }));
}
update();
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS);
return () => clearInterval(timer);
}, []);
return state;
}
type Props = {
exitMessage: {
show: boolean
key?: string
}
vimMode: VimMode | undefined
mode: PromptInputMode
toolPermissionContext: ToolPermissionContext
suppressHint: boolean
isLoading: boolean
showMemoryTypeSelector?: boolean
tasksSelected: boolean
teamsSelected: boolean
tmuxSelected: boolean
teammateFooterIndex?: number
isPasting?: boolean
isSearching: boolean
historyQuery: string
setHistoryQuery: (query: string) => void
historyFailedMatch: boolean
onOpenTasksDialog?: (taskId?: string) => void
}
show: boolean;
key?: string;
};
vimMode: VimMode | undefined;
mode: PromptInputMode;
toolPermissionContext: ToolPermissionContext;
suppressHint: boolean;
isLoading: boolean;
showMemoryTypeSelector?: boolean;
tasksSelected: boolean;
teamsSelected: boolean;
tmuxSelected: boolean;
teammateFooterIndex?: number;
isPasting?: boolean;
isSearching: boolean;
historyQuery: string;
setHistoryQuery: (query: string) => void;
historyFailedMatch: boolean;
onOpenTasksDialog?: (taskId?: string) => void;
};
function ProactiveCountdown(): React.ReactNode {
const nextTickAt = useSyncExternalStore(
proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE,
proactiveModule?.getNextTickAt ?? NULL,
NULL,
)
);
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null)
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
useEffect(() => {
if (nextTickAt === null) {
setRemainingSeconds(null)
return
setRemainingSeconds(null);
return;
}
function update(): void {
const remaining = Math.max(
0,
Math.ceil((nextTickAt! - Date.now()) / 1000),
)
setRemainingSeconds(remaining)
const remaining = Math.max(0, Math.ceil((nextTickAt! - Date.now()) / 1000));
setRemainingSeconds(remaining);
}
update()
const interval = setInterval(update, 1000)
return () => clearInterval(interval)
}, [nextTickAt])
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [nextTickAt]);
if (remainingSeconds === null) return null
if (remainingSeconds === null) return null;
return (
<Text dimColor>
waiting{' '}
{formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}
</Text>
)
return <Text dimColor>waiting {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}</Text>;
}
export function PromptInputFooterLeftSide({
@ -145,26 +150,22 @@ export function PromptInputFooterLeftSide({
<Text dimColor key="exit-message">
Press {exitMessage.key} again to exit
</Text>
)
);
}
if (isPasting) {
return (
<Text dimColor key="pasting-message">
Pasting text
</Text>
)
);
}
const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching
const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching;
return (
<Box justifyContent="flex-start" gap={1}>
{isSearching && (
<HistorySearchInput
value={historyQuery}
onChange={setHistoryQuery}
historyFailedMatch={historyFailedMatch}
/>
<HistorySearchInput value={historyQuery} onChange={setHistoryQuery} historyFailedMatch={historyFailedMatch} />
)}
{showVim ? (
<Text dimColor key="vim-insert">
@ -183,20 +184,20 @@ export function PromptInputFooterLeftSide({
onOpenTasksDialog={onOpenTasksDialog}
/>
</Box>
)
);
}
type ModeIndicatorProps = {
mode: PromptInputMode
toolPermissionContext: ToolPermissionContext
showHint: boolean
isLoading: boolean
tasksSelected: boolean
teamsSelected: boolean
tmuxSelected: boolean
teammateFooterIndex?: number
onOpenTasksDialog?: (taskId?: string) => void
}
mode: PromptInputMode;
toolPermissionContext: ToolPermissionContext;
showHint: boolean;
isLoading: boolean;
tasksSelected: boolean;
teamsSelected: boolean;
tmuxSelected: boolean;
teammateFooterIndex?: number;
onOpenTasksDialog?: (taskId?: string) => void;
};
function ModeIndicator({
mode,
@ -209,110 +210,70 @@ function ModeIndicator({
teammateFooterIndex,
onOpenTasksDialog,
}: ModeIndicatorProps): React.ReactNode {
const { columns } = useTerminalSize()
const modeCycleShortcut = useShortcutDisplay(
'chat:cycleMode',
'Chat',
'shift+tab',
)
const tasks = useAppState(s => s.tasks)
const teamContext = useAppState(s => s.teamContext)
const { columns } = useTerminalSize();
const modeCycleShortcut = useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab');
const tasks = useAppState(s => s.tasks);
const teamContext = useAppState(s => s.teamContext);
// Set once in initialState (main.tsx --remote mode) and never mutated — lazy
// init captures the immutable value without a subscription.
const store = useAppStateStore()
const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl)
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
const expandedView = useAppState(s => s.expandedView)
const showSpinnerTree = expandedView === 'teammates'
const prStatus = usePrStatus(isLoading, isPrStatusEnabled())
const hasTmuxSession = useAppState(
s =>
process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined,
)
const store = useAppStateStore();
const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl);
const viewSelectionMode = useAppState(s => s.viewSelectionMode);
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
const expandedView = useAppState(s => s.expandedView);
const showSpinnerTree = expandedView === 'teammates';
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
const hasTmuxSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined);
const nextTickAt = useSyncExternalStore(
proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE,
proactiveModule?.getNextTickAt ?? NULL,
NULL,
)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceState = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useVoiceState(s => s.voiceState)
: ('idle' as const)
const voiceWarmingUp = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useVoiceState(s => s.voiceWarmingUp)
: false
const hasSelection = useHasSelection()
const selGetState = useSelection().getState
const hasNextTick = nextTickAt !== null
const isCoordinator = feature('COORDINATOR_MODE')
? coordinatorModule?.isCoordinatorMode() === true
: false
);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
const hasSelection = useHasSelection();
const selGetState = useSelection().getState;
const hasNextTick = nextTickAt !== null;
const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false;
const runningTaskCount = useMemo(
() =>
count(
Object.values(tasks),
t =>
isBackgroundTask(t) &&
!(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
),
[tasks],
)
const tasksV2 = useTasksV2()
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0
const escShortcut = useShortcutDisplay(
'chat:cancel',
'Chat',
'esc',
).toLowerCase()
const todosShortcut = useShortcutDisplay(
'app:toggleTodos',
'Global',
'ctrl+t',
)
const killAgentsShortcut = useShortcutDisplay(
'chat:killAgents',
'Chat',
'ctrl+x ctrl+k',
)
const voiceKeyShortcut = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
: ''
);
const tasksV2 = useTasksV2();
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0;
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
// Captured at mount so the hint doesn't flicker mid-session if another
// CC instance increments the counter. Incremented once via useEffect the
// first time voice is enabled in this session — approximates "hint was
// shown" without tracking the exact render-time condition (which depends
// on parts/hintParts computed after the early-return hooks boundary).
const [voiceHintUnderCap] = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useState(
() =>
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
MAX_VOICE_HINT_SHOWS,
)
: [false]
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
: [false];
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
useEffect(() => {
if (feature('VOICE_MODE')) {
if (!voiceEnabled || !voiceHintUnderCap) return
if (voiceHintIncrementedRef?.current) return
if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true
const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1
if (!voiceEnabled || !voiceHintUnderCap) return;
if (voiceHintIncrementedRef?.current) return;
if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true;
const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1;
saveGlobalConfig(prev => {
if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev
return { ...prev, voiceFooterHintSeenCount: newCount }
})
if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev;
return { ...prev, voiceFooterHintSeenCount: newCount };
});
}
}, [voiceEnabled, voiceHintUnderCap])
const isKillAgentsConfirmShowing = useAppState(
s => s.notifications.current?.key === 'kill-agents-confirm',
)
}, [voiceEnabled, voiceHintUnderCap]);
const isKillAgentsConfirmShowing = useAppState(s => s.notifications.current?.key === 'kill-agents-confirm');
const rssState = useRssDisplay();
// Derive team info from teamContext (no filesystem I/O needed)
// Match the same logic as TeamStatus to avoid trailing separator
@ -321,27 +282,21 @@ function ModeIndicator({
isAgentSwarmsEnabled() &&
!isInProcessEnabled() &&
teamContext !== undefined &&
count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0
count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0;
if (mode === 'bash') {
return <Text color="bashBorder">! for bash mode</Text>
return <Text color="bashBorder">! for bash mode</Text>;
}
const currentMode = toolPermissionContext?.mode
const hasActiveMode = !isDefaultMode(currentMode)
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
const isViewingTeammate =
viewSelectionMode === 'viewing-agent' &&
viewedTask?.type === 'in_process_teammate'
const isViewingCompletedTeammate =
isViewingTeammate && viewedTask != null && viewedTask.status !== 'running'
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate
const currentMode = toolPermissionContext?.mode;
const hasActiveMode = !isDefaultMode(currentMode);
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate';
const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running';
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate;
// Count primary items (permission mode or coordinator mode, background tasks, and teams)
const primaryItemCount =
(isCoordinator || hasActiveMode ? 1 : 0) +
(hasBackgroundTasks ? 1 : 0) +
(hasTeams ? 1 : 0)
const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0);
// PR indicator is short (~10 chars) — unlike the old diff indicator the
// >=100 threshold was tuned for. Now that auto mode is effectively the
@ -353,19 +308,16 @@ function ModeIndicator({
prStatus.reviewState !== null &&
prStatus.url !== null &&
primaryItemCount < 2 &&
(primaryItemCount === 0 || columns >= 80)
(primaryItemCount === 0 || columns >= 80);
// Hide the shift+tab hint when there are 2 primary items
const shouldShowModeHint = primaryItemCount < 2
const shouldShowModeHint = primaryItemCount < 2;
// Check if we have in-process teammates (showing pills)
// In spinner-tree mode, pills are disabled - teammates appear in the spinner tree instead
const hasInProcessTeammates =
!showSpinnerTree &&
hasBackgroundTasks &&
Object.values(tasks).some(t => t.type === 'in_process_teammate')
const hasTeammatePills =
hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate)
!showSpinnerTree && hasBackgroundTasks && Object.values(tasks).some(t => t.type === 'in_process_teammate');
const hasTeammatePills = hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate);
// In remote mode (`claude assistant`, --teleport) the agent runs elsewhere;
// the local permission mode shown here doesn't reflect the agent's state.
@ -374,20 +326,15 @@ function ModeIndicator({
const modePart =
currentMode && hasActiveMode && !getIsRemoteMode() ? (
<Text color={getModeColor(currentMode)} key="mode">
{permissionModeSymbol(currentMode)}{' '}
{permissionModeTitle(currentMode).toLowerCase()} on
{permissionModeSymbol(currentMode)} {permissionModeTitle(currentMode).toLowerCase()} on
{shouldShowModeHint && (
<Text dimColor>
{' '}
<KeyboardShortcutHint
shortcut={modeCycleShortcut}
action="cycle"
parens
/>
<KeyboardShortcutHint shortcut={modeCycleShortcut} action="cycle" parens />
</Text>
)}
</Text>
) : null
) : null;
// Build parts array - exclude BackgroundTaskStatus when we have teammate pills
// (teammate pills get their own row)
@ -404,37 +351,32 @@ function ModeIndicator({
// its click-target Box isn't nested inside the <Text wrap="truncate">
// wrapper (reconciler throws on Box-in-Text).
// Tmux pill (ant-only) — appears right after tasks in nav order
...(process.env.USER_TYPE === 'ant' && hasTmuxSession
? [<TungstenPill key="tmux" selected={tmuxSelected} />]
: []),
...(process.env.USER_TYPE === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []),
...(isAgentSwarmsEnabled() && hasTeams
? [
<TeamStatus
key="teams"
teamsSelected={teamsSelected}
showHint={showHint && !hasBackgroundTasks}
/>,
]
? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />]
: []),
...(shouldShowPrStatus
? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />]
: []),
// RSS memory indicator — always visible
...(rssState
? [
<PrBadge
key="pr-status"
number={prStatus.number!}
url={prStatus.url!}
reviewState={prStatus.reviewState!}
/>,
<Text
key="rss"
dimColor={rssState.level === 'normal'}
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
>
{rssState.text} · pid:{process.pid}
</Text>,
]
: []),
]
];
// Check if any in-process teammates exist (for hint text cycling)
const hasAnyInProcessTeammates = Object.values(tasks).some(
t => t.type === 'in_process_teammate' && t.status === 'running',
)
const hasRunningAgentTasks = Object.values(tasks).some(
t => t.type === 'local_agent' && t.status === 'running',
)
);
const hasRunningAgentTasks = Object.values(tasks).some(t => t.type === 'local_agent' && t.status === 'running');
// Get hint parts separately for potential second-line rendering
const hintParts = showHint
@ -449,32 +391,25 @@ function ModeIndicator({
hasRunningAgentTasks,
isKillAgentsConfirmShowing,
)
: []
: [];
if (isViewingCompletedTeammate) {
parts.push(
<Text dimColor key="esc-return">
<KeyboardShortcutHint
shortcut={escShortcut}
action="return to team lead"
/>
<KeyboardShortcutHint shortcut={escShortcut} action="return to team lead" />
</Text>,
)
);
} else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) {
parts.push(<ProactiveCountdown key="proactive" />)
parts.push(<ProactiveCountdown key="proactive" />);
} else if (!hasTeammatePills && showHint) {
parts.push(...hintParts)
parts.push(...hintParts);
}
// When we have teammate pills, always render them on their own line above other parts
if (hasTeammatePills) {
// Don't append spinner hints when viewing a completed teammate —
// the "esc to return to team lead" hint already replaces "esc to interrupt"
const otherParts = [
...(modePart ? [modePart] : []),
...parts,
...(isViewingCompletedTeammate ? [] : hintParts),
]
const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)];
return (
<Box flexDirection="column">
<Box>
@ -492,21 +427,18 @@ function ModeIndicator({
</Box>
)}
</Box>
)
);
}
// Add "↓ to manage tasks" hint when panel has visible rows
const hasCoordinatorTasks =
process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0
const hasCoordinatorTasks = process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0;
// Tasks pill renders as a Box sibling (not a parts entry) so its
// click-target Box isn't nested inside <Text wrap="truncate"> — the
// reconciler throws on Box-in-Text. Computed here so the empty-checks
// below still treat "pill present" as non-empty.
const tasksPart =
hasBackgroundTasks &&
!hasTeammatePills &&
!shouldHideTasksFooter(tasks, showSpinnerTree) ? (
hasBackgroundTasks && !hasTeammatePills && !shouldHideTasksFooter(tasks, showSpinnerTree) ? (
<BackgroundTaskStatus
tasksSelected={tasksSelected}
isViewingTeammate={isViewingTeammate}
@ -514,27 +446,27 @@ function ModeIndicator({
isLeaderIdle={!isLoading}
onOpenDialog={onOpenTasksDialog}
/>
) : null
) : null;
if (parts.length === 0 && !tasksPart && !modePart && showHint) {
parts.push(
<Text dimColor key="shortcuts-hint">
? for shortcuts
</Text>,
)
);
}
// Only replace the idle voice hint when there's something to say — otherwise
// fall through instead of showing an empty Byline. "esc to clear" was removed
// (looked like "esc to interrupt" when idle; esc-clears-selection is standard
// UX) leaving only ctrl+c (copyOnSelect off) and the xterm.js native-select hint.
const copyOnSelect = getGlobalConfig().copyOnSelect ?? true
const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs())
const copyOnSelect = getGlobalConfig().copyOnSelect ?? true;
const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs());
// Warmup hint takes priority — when the user is actively holding
// the activation key, show feedback regardless of other hints.
if (feature('VOICE_MODE') && voiceEnabled && voiceWarmingUp) {
parts.push(<VoiceWarmupHint key="voice-warmup" />)
parts.push(<VoiceWarmupHint key="voice-warmup" />);
} else if (isFullscreenEnvEnabled() && selectionHintHasContent) {
// xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is
// platform-specific and gated on macOS (SelectionService.shouldForceSelection):
@ -546,26 +478,21 @@ function ModeIndicator({
// option+click hint they just tried.
// Non-reactive getState() read is safe: lastPressHadAlt is immutable
// while hasSelection is true (set pre-drag, cleared with selection).
const isMac = getPlatform() === 'macos'
const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false)
const isMac = getPlatform() === 'macos';
const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false);
parts.push(
<Text dimColor key="selection-copy">
<Byline>
{!copyOnSelect && (
<KeyboardShortcutHint shortcut="ctrl+c" action="copy" />
)}
{!copyOnSelect && <KeyboardShortcutHint shortcut="ctrl+c" action="copy" />}
{isXtermJs() &&
(altClickFailed ? (
<Text>set macOptionClickForcesSelection in VS Code settings</Text>
) : (
<KeyboardShortcutHint
shortcut={isMac ? 'option+click' : 'shift+click'}
action="native select"
/>
<KeyboardShortcutHint shortcut={isMac ? 'option+click' : 'shift+click'} action="native select" />
))}
</Byline>
</Text>,
)
);
} else if (
feature('VOICE_MODE') &&
parts.length > 0 &&
@ -579,7 +506,7 @@ function ModeIndicator({
<Text dimColor key="voice-hint">
hold {voiceKeyShortcut} to speak
</Text>,
)
);
}
if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) {
@ -591,7 +518,7 @@ function ModeIndicator({
<KeyboardShortcutHint shortcut="↓" action="manage" />
)}
</Text>,
)
);
}
// In fullscreen the bottom section is flexShrink:0 — every row here
@ -603,7 +530,7 @@ function ModeIndicator({
// from 0→1 row. Always render 1 row in fullscreen; return a space when
// empty so Yoga reserves the row without painting anything visible.
if (parts.length === 0 && !tasksPart && !modePart) {
return isFullscreenEnvEnabled() ? <Text> </Text> : null
return isFullscreenEnvEnabled() ? <Text> </Text> : null;
}
// flexShrink=0 keeps mode + pill at natural width; the remaining parts
@ -628,7 +555,7 @@ function ModeIndicator({
</Text>
)}
</Box>
)
);
}
function getSpinnerHintParts(
@ -642,27 +569,27 @@ function getSpinnerHintParts(
hasRunningAgentTasks: boolean,
isKillAgentsConfirmShowing: boolean,
): React.ReactElement[] {
let toggleAction: string
let toggleAction: string;
if (hasTeammates) {
// Cycling: none → tasks → teammates → none
switch (expandedView) {
case 'none':
toggleAction = 'show tasks'
break
toggleAction = 'show tasks';
break;
case 'tasks':
toggleAction = 'show teammates'
break
toggleAction = 'show teammates';
break;
case 'teammates':
toggleAction = 'hide'
break
toggleAction = 'hide';
break;
}
} else {
toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks'
toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks';
}
// Show the toggle hint only when there are task items to display or
// teammates to cycle to
const showToggleHint = hasTaskItems || hasTeammates
const showToggleHint = hasTaskItems || hasTeammates;
return [
...(isLoading
@ -675,26 +602,20 @@ function getSpinnerHintParts(
...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing
? [
<Text dimColor key="kill-agents">
<KeyboardShortcutHint
shortcut={killAgentsShortcut}
action="stop agents"
/>
<KeyboardShortcutHint shortcut={killAgentsShortcut} action="stop agents" />
</Text>,
]
: []),
...(showToggleHint
? [
<Text dimColor key="toggle-tasks">
<KeyboardShortcutHint
shortcut={todosShortcut}
action={toggleAction}
/>
<KeyboardShortcutHint shortcut={todosShortcut} action={toggleAction} />
</Text>,
]
: []),
]
];
}
function isPrStatusEnabled(): boolean {
return getGlobalConfig().prStatusFooterEnabled ?? true
return getGlobalConfig().prStatusFooterEnabled ?? true;
}

View File

@ -7,9 +7,6 @@ import type { CanUseToolFn } from './hooks/useCanUseTool.js'
import { FallbackTriggeredError } from './services/api/withRetry.js'
import {
calculateTokenWarningState,
estimateMaxTurnGrowth,
getAutoCompactThreshold,
getEffectiveContextWindowSize,
isAutoCompactEnabled,
type AutoCompactTrackingState,
} from './services/compact/autoCompact.js'
@ -65,21 +62,25 @@ import {
getAttachmentMessages,
startRelevantMemoryPrefetch,
} from './utils/attachments.js'
import * as skillPrefetchValue from './services/skillSearch/prefetch.js'
import * as jobClassifierValue from './jobs/classifier.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const skillPrefetch = feature('EXPERIMENTAL_SKILL_SEARCH')
? skillPrefetchValue
? (require('./services/skillSearch/prefetch.js') as typeof import('./services/skillSearch/prefetch.js'))
: null
const jobClassifier = feature('TEMPLATES')
? jobClassifierValue
? (require('./jobs/classifier.js') as typeof import('./jobs/classifier.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import {
enqueue,
remove as removeFromQueue,
getCommandsByMaxPriority,
isSlashCommand,
} from './utils/messageQueueManager.js'
import {
type AutonomyTurnOutcome,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from './utils/autonomyQueueLifecycle.js'
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import {
@ -97,6 +98,7 @@ import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool
import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js'
import { executeStopFailureHooks } from './utils/hooks.js'
import type { QuerySource } from './constants/querySource.js'
import type { QueuedCommand } from './types/textInputTypes.js'
import { createDumpPromptsFetch } from './services/api/dumpPrompts.js'
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
import { queryCheckpoint } from './utils/queryProfiler.js'
@ -110,27 +112,25 @@ import type { Terminal, Continue } from './query/transitions.js'
import { feature } from 'bun:bundle'
import {
getCurrentTurnTokenBudget,
getSessionId,
getTurnOutputTokens,
incrementBudgetContinuationCount,
getSessionId,
} from './bootstrap/state.js'
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
import { count } from './utils/array.js'
import {
createTrace,
endTrace,
flushLangfuse,
isLangfuseEnabled,
} from './services/langfuse/index.js'
import { getAPIProvider } from './utils/model/providers.js'
import * as taskSummaryModuleValue from './utils/taskSummary.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const snipModule = feature('HISTORY_SNIP')
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
: null
const taskSummaryModule = feature('BG_SESSIONS')
? taskSummaryModuleValue
? (require('./utils/taskSummary.js') as typeof import('./utils/taskSummary.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
@ -140,7 +140,11 @@ function* yieldMissingToolResultBlocks(
) {
for (const assistantMessage of assistantMessages) {
// Extract all tool use blocks from this assistant message
const toolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
const toolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
@ -192,6 +196,33 @@ function isWithheldMaxOutputTokens(
return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens'
}
function getAutonomyTurnOutcome(params: {
terminal?: Terminal
thrownError?: unknown
}): AutonomyTurnOutcome {
if (params.thrownError !== undefined) {
return { type: 'failed', error: params.thrownError }
}
const terminal = params.terminal
const reason = terminal?.reason
switch (reason) {
case 'completed':
return { type: 'completed' }
case undefined:
case 'aborted_streaming':
case 'aborted_tools':
return { type: 'cancelled' }
case 'model_error':
return { type: 'failed', error: terminal.error }
default:
return {
type: 'failed',
message: `query ended without successful completion: ${reason}`,
}
}
}
export type QueryParams = {
messages: Message[]
systemPrompt: SystemPrompt
@ -241,6 +272,8 @@ export async function* query(
Terminal
> {
const consumedCommandUuids: string[] = []
const consumedAutonomyCommands: QueuedCommand[] = []
// Create Langfuse trace for this query turn (no-op if not configured).
// When called as a sub-agent, langfuseTrace is already set by runAgent()
// — reuse it instead of creating an independent trace.
@ -260,7 +293,7 @@ export async function* query(
})
: null)
// Attach trace to toolUseContext so tool execution can record observations.
// Attach trace to toolUseContext so tool execution can record observations
const paramsWithTrace: QueryParams = langfuseTrace
? {
...params,
@ -269,40 +302,40 @@ export async function* query(
: params
let terminal: Terminal | undefined
let didThrow = false
let thrownError: unknown
try {
terminal = yield* queryLoop(paramsWithTrace, consumedCommandUuids)
terminal = yield* queryLoop(
paramsWithTrace,
consumedCommandUuids,
consumedAutonomyCommands,
)
} catch (error) {
didThrow = true
thrownError = error
throw error
} finally {
// Only end the trace if we created it — sub-agents own their traces.
await finalizeAutonomyCommandsForTurn({
commands: consumedAutonomyCommands,
outcome: getAutonomyTurnOutcome({
terminal,
...(didThrow ? { thrownError } : {}),
}),
priority: 'later',
})
.then(nextCommands => {
for (const command of nextCommands) {
enqueue(command)
}
})
.catch(logError)
// Only end the trace if we created it — sub-agents own their traces
if (ownsTrace) {
const isAborted =
terminal?.reason === 'aborted_streaming' ||
terminal?.reason === 'aborted_tools'
endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined)
// Flush the processor to release span data (including serialized
// conversation history stored as langfuse.observation.input). Without
// this, SpanImpl objects retain hundreds of KB of JSON until the
// processor's batch timer fires (default 10s).
await flushLangfuse()
}
// Break the closure chain: toolUseContext captures langfuseTrace which
// holds SpanImpl -> performance buffers. Nulling these after endTrace
// allows GC to reclaim the span tree.
if (paramsWithTrace !== params) {
paramsWithTrace.toolUseContext.langfuseTrace = null
paramsWithTrace.toolUseContext.langfuseRootTrace = null
paramsWithTrace.toolUseContext.langfuseBatchSpan = null
}
const gPerf = globalThis.performance
if (gPerf && typeof gPerf.clearMarks === 'function') {
try {
gPerf.clearMarks()
gPerf.clearMeasures?.()
gPerf.clearResourceTimings?.()
} catch {
// Non-critical — some environments may not support all methods.
}
}
}
@ -319,6 +352,7 @@ export async function* query(
async function* queryLoop(
params: QueryParams,
consumedCommandUuids: string[],
consumedAutonomyCommands: QueuedCommand[],
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
@ -366,7 +400,7 @@ async function* queryLoop(
// multiple compacts: each subtracts the final context at that compact's
// trigger point. Loop-local (not on State) to avoid touching the 7 continue
// sites.
let taskBudgetRemaining: number | undefined = undefined
let taskBudgetRemaining: number | undefined
// Snapshot immutable env/statsig/session state once at entry. See QueryConfig
// for what's included and why feature() gates are intentionally excluded.
@ -440,7 +474,7 @@ async function* queryLoop(
queryTracking,
}
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
let tracking = autoCompactTracking
@ -495,16 +529,6 @@ async function* queryLoop(
querySource,
)
messagesForQuery = microcompactResult.messages
// Release original strings from contentReplacementState.replacements for
// tool results whose content was replaced with the cleared message.
if (microcompactResult.clearedToolUseIds?.length) {
const replacements = toolUseContext?.contentReplacementState?.replacements
if (replacements) {
for (const id of microcompactResult.clearedToolUseIds) {
replacements.delete(id)
}
}
}
// For cached microcompact (cache editing), defer boundary message until after
// the API response so we can use actual cache_deleted_input_tokens.
// Gated behind feature() so the string is eliminated from external builds.
@ -735,48 +759,6 @@ async function* queryLoop(
}
}
// Predictive autocompact: estimate if this turn's growth will push
// us past the context window. Uses effectiveContextWindow directly
// (without the autocompact buffer) to avoid double-reserving with
// getAutoCompactThreshold which already subtracts buffer.
if (!compactionResult && isAutoCompactEnabled()) {
const model = toolUseContext.options.mainLoopModel
const currentTokens =
tokenCountWithEstimation(messagesForQuery) - snipTokensFreed
const estimatedGrowth = estimateMaxTurnGrowth(model)
const predictiveThreshold =
getEffectiveContextWindowSize(model) - estimatedGrowth
if (currentTokens > predictiveThreshold) {
const predictiveResult = await deps.autocompact(
messagesForQuery,
toolUseContext,
{
systemPrompt,
userContext,
systemContext,
toolUseContext,
forkContextMessages: messagesForQuery,
},
querySource,
tracking,
snipTokensFreed,
)
if (predictiveResult.compactionResult) {
messagesForQuery = buildPostCompactMessages(
predictiveResult.compactionResult,
)
snipTokensFreed = 0
tracking = tracking
? {
...tracking,
compacted: true,
consecutiveFailures: predictiveResult.consecutiveFailures ?? 0,
}
: tracking
}
}
}
let attemptWithFallback = true
queryCheckpoint('query_api_loop_start')
@ -878,7 +860,14 @@ async function* queryLoop(
let yieldMessage: typeof message = message
if (message.type === 'assistant') {
const assistantMsg = message as AssistantMessage
const contentArr = Array.isArray(assistantMsg.message?.content) ? assistantMsg.message.content as unknown as Array<{ type: string; input?: unknown; name?: string; [key: string]: unknown }> : []
const contentArr = Array.isArray(assistantMsg.message?.content)
? (assistantMsg.message.content as unknown as Array<{
type: string
input?: unknown
name?: string
[key: string]: unknown
}>)
: []
let clonedContent: typeof contentArr | undefined
for (let i = 0; i < contentArr.length; i++) {
const block = contentArr[i]!
@ -914,7 +903,10 @@ async function* queryLoop(
if (clonedContent) {
yieldMessage = {
...message,
message: { ...(assistantMsg.message ?? {}), content: clonedContent },
message: {
...(assistantMsg.message ?? {}),
content: clonedContent,
},
} as typeof message
}
}
@ -960,7 +952,11 @@ async function* queryLoop(
const assistantMessage = message as AssistantMessage
assistantMessages.push(assistantMessage)
const msgToolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
const msgToolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
@ -1093,7 +1089,10 @@ async function* queryLoop(
logEvent('tengu_query_error', {
assistantMessages: assistantMessages.length,
toolUses: assistantMessages.flatMap(_ =>
(Array.isArray(_.message?.content) ? _.message.content as Array<{ type: string }> : []).filter(content => content.type === 'tool_use'),
(Array.isArray(_.message?.content)
? (_.message.content as Array<{ type: string }>)
: []
).filter(content => content.type === 'tool_use'),
).length,
queryChainId: queryChainIdForAnalytics,
@ -1133,7 +1132,7 @@ async function* queryLoop(
// Execute post-sampling hooks after model response is complete
if (assistantMessages.length > 0) {
void executePostSamplingHooks(
messagesForQuery.concat(assistantMessages),
[...messagesForQuery, ...assistantMessages],
systemPrompt,
userContext,
systemContext,
@ -1395,7 +1394,10 @@ async function* queryLoop(
// error → hook blocking → retry → error → …
if (lastMessage?.isApiErrorMessage) {
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: 'completed' }
return {
reason: 'model_error',
error: lastMessage.error ?? lastMessage.apiError ?? 'api_error',
}
}
const stopHookResult = yield* handleStopHooks(
@ -1496,7 +1498,6 @@ async function* queryLoop(
queryCheckpoint('query_tool_execution_start')
if (streamingToolExecutor) {
logEvent('tengu_streaming_tool_execution_used', {
tool_count: toolUseBlocks.length,
@ -1556,9 +1557,14 @@ async function* queryLoop(
const lastAssistantMessage = assistantMessages.at(-1)
let lastAssistantText: string | undefined
if (lastAssistantMessage) {
const textBlocks = (Array.isArray(lastAssistantMessage.message?.content) ? lastAssistantMessage.message.content as Array<{ type: string; text?: string }> : []).filter(
block => block.type === 'text',
)
const textBlocks = (
Array.isArray(lastAssistantMessage.message?.content)
? (lastAssistantMessage.message.content as Array<{
type: string
text?: string
}>)
: []
).filter(block => block.type === 'text')
if (textBlocks.length > 0) {
const lastTextBlock = textBlocks.at(-1)
if (lastTextBlock && 'text' in lastTextBlock) {
@ -1710,12 +1716,32 @@ async function* queryLoop(
// user prompts, even if someone stamps an agentId on one.
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
const queuedAutonomyClaim = await claimConsumableQueuedAutonomyCommands(
queuedCommandsSnapshot,
)
if (queuedAutonomyClaim.staleCommands.length > 0) {
removeFromQueue(queuedAutonomyClaim.staleCommands)
}
const claimedConsumedCommands = queuedAutonomyClaim.claimedCommands.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification',
)
if (claimedConsumedCommands.length > 0) {
consumedAutonomyCommands.push(...claimedConsumedCommands)
for (const cmd of claimedConsumedCommands) {
if (cmd.uuid) {
consumedCommandUuids.push(cmd.uuid)
notifyCommandLifecycle(cmd.uuid, 'started')
}
}
removeFromQueue(claimedConsumedCommands)
}
for await (const attachment of getAttachmentMessages(
null,
updatedToolUseContext,
null,
queuedCommandsSnapshot,
queuedAutonomyClaim.attachmentCommands,
messagesForQuery.concat(assistantMessages, toolResults),
querySource,
)) {
@ -1747,7 +1773,6 @@ async function* queryLoop(
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
// Inject prefetched skill discovery. collectSkillDiscoveryPrefetch emits
// hidden_by_main_turn — true when the prefetch resolved before this point
// (should be >98% at AKI@250ms / Haiku@573ms vs turn durations of 2-30s).
@ -1763,8 +1788,11 @@ async function* queryLoop(
// Remove only commands that were actually consumed as attachments.
// Prompt and task-notification commands are converted to attachments above.
const consumedCommands = queuedCommandsSnapshot.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification',
const claimedCommandSet = new Set(claimedConsumedCommands)
const consumedCommands = queuedAutonomyClaim.attachmentCommands.filter(
cmd =>
(cmd.mode === 'prompt' || cmd.mode === 'task-notification') &&
!claimedCommandSet.has(cmd),
)
if (consumedCommands.length > 0) {
for (const cmd of consumedCommands) {
@ -1826,10 +1854,11 @@ async function* queryLoop(
userContext,
systemContext,
toolUseContext,
forkContextMessages: messagesForQuery.concat(
assistantMessages,
toolResults,
),
forkContextMessages: [
...messagesForQuery,
...assistantMessages,
...toolResults,
],
})
}
}
@ -1846,7 +1875,7 @@ async function* queryLoop(
queryCheckpoint('query_recursive_call')
const next: State = {
messages: messagesForQuery.concat(assistantMessages, toolResults),
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
autoCompactTracking: tracking,
turnCount: nextTurnCount,