diff --git a/CLAUDE.md b/CLAUDE.md index 6df5af0f1..33e647684 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 变为 unused(TS2578),直接移除注释。MACRO 替换产生的永假比较(如 `'production' === 'development'`)仍需保留 `@ts-expect-error`。 - **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。 diff --git a/docs/memory-peak-analysis.md b/docs/memory-peak-analysis.md index 9e84a58e4..d074c458d 100644 --- a/docs/memory-peak-analysis.md +++ b/docs/memory-peak-analysis.md @@ -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-01(7 轮排查 + 验证,已压缩) +> 范围:内存峰值 + 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 均动态 import(lazy),不贡献基线 +- 前置修复(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 check,100ms 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 次数组浅拷贝**。单次拷贝开销小(指针数组),但累积峰值叠加时占用大量临时内存。 -### P0:React 消息管线重复计算(Round 5 新增分析) +### #2 Messages.tsx 24-25 次遍历(P1) -**buildMessageLookups 每次 useMemo 重算时创建 8 个 Map/Set**(`messages.ts:1215-1398`): +原始估计 3-4 次,实际有 10 个独立处理阶段: -| 数据结构 | 规模 | 说明 | -|----------|------|------| -| `toolUseIDsByMessageID` | Map\ | 每个 assistant 消息一个 Set | -| `toolUseIDToMessageID` | Map\ | 所有 tool_use ID | -| `toolUseByToolUseID` | Map\ | **保留完整 tool_use block** | -| `siblingToolUseIDs` | Map\ | 兄弟 tool_use 索引 | -| `progressMessagesByToolUseID` | Map\ | 进度消息数组 | -| `toolResultByToolUseID` | Map\ | **保留完整 tool_result 消息引用** | -| `resolvedToolUseIDs` / `erroredToolUseIDs` | Set\ | 已完成/错误 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 无 LRU(P1) -**FileReadTool 无大小限制**(`FileReadTool.ts:342`):`maxResultSizeChars: Infinity`,单次 10MB 文件读取完整保留在消息数组中。BashTool(30KB)和 GrepTool(20KB)有合理上限。 +- `HighlightedCode.tsx:32-41`:每次 `useMemo` 创建新 `ColorFile(code, filePath)` 实例 +- `color-diff-napi` 内部有全局 `hlLineCache`(Map,上限 2048 条目)缓存 AST,但不缓存渲染结果 +- 无跨实例复用,大量代码块场景下每个组件持有一份完整 code 字符串 -### P0:Compaction 与 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 -### P0:Compact 峰值(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 双重存储 -### P0:React Hooks 闭包与 useMemo 链(Round 5 深入排查) +- `services/mcp/useManageMCPConnections.ts:258`:更新时 `[...reject(mcp.tools, ...), ...tools]` 创建新数组 +- 存储位置:`fetchToolsForClient` LRU(20 条目)+ `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>()` 无大小限制 +- 每 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\,通常 <100 | -| `discoveredSkillNamesRef` | `.add()` 每个发现的 skill | `clearConversation` 时 clear | Set\,通常 <50 | -| `apiMetricsRef` | `.push()` 每次请求 | turn 结束时 `= []` | 临时,turn 内累积 | -| `responseLengthRef` | 累加 | compact 时重置为 0 | 单数字 | -| `loadedNestedMemoryPathsRef` | `.add()` 每个 CLAUDE.md | compact/clear 时 clear | Set\ | +- 大文件(> `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` — style key → id + - `styles: AnsiCode[][]` — 无界数组 + - `transitionCache: Map` + - `inverseCache: Map` + - `currentMatchCache: Map` +- `intern()` 只 push 不淘汰 -`src/hooks/useVirtualScroll.ts` + React Ink 渲染管线: -- MAX_MOUNTED_ITEMS = 300,OVERSCAN_ROWS = 80 -- 实际挂载约 200 个 MessageRow(视口 + overscan) -- 每个 MessageRow ≈ 250KB RSS(React 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 响应可达数万 token,thinking 文本完整保留在 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 collectedMessages(Round 5) | 临时 | 流式期间累积 assistant messages 供 Langfuse telemetry,stream 结束后释放 | - -### 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 自动批处理 setState,5 个 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 自动批处理 setState,5 个 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 帧率限制 16ms(throttle 仅限终端输出,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 增量更新而非全量重建 - -### P0:Compact 峰值(预估降 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 实例级 LRU(50 条)或 WeakMap 跨实例复用 +3. `buddy/CompanionSprite.tsx:15` — TICK_MS 从 500ms 评估能否提升至 1000ms+ ### P2:Ink 渲染层(降低 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 次 spread(query.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 diff --git a/package.json b/package.json index 5a1e7075f..8e9888de8 100644 --- a/package.json +++ b/package.json @@ -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 ", @@ -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" diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 22dbeb643..0bb580338 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -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 = ( - + {lines.map((l, i) => ( - + {l} ))} - ) + ); if (tail === 'right') { return ( {bubble} - ) + ); } return ( @@ -96,18 +85,18 @@ function SpeechBubble({ - ) + ); } -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 ( @@ -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} - ) + ); } - 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 = ( - + {sprite.map((line, i) => ( {line} ))} - + {focused ? ` ${companion.name} ` : companion.name} - ) + ); if (!reaction) { - return {spriteColumn} + return {spriteColumn}; } // 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 {spriteColumn} + return {spriteColumn}; } return ( - + {spriteColumn} - ) + ); } // 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 ( = BUBBLE_SHOW - FADE_WINDOW} tail="down" /> - ) + ); } diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index 3cfe9a736..8fb7a2236 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -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(); +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(null) - const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH) - const [theme] = useTheme() - const settings = useSettings() - const syntaxHighlightingDisabled = - settings.syntaxHighlightingDisabled ?? false + const ref = useRef(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 ( @@ -84,26 +104,15 @@ export const HighlightedCode = memo(function HighlightedCode({ )} ) : ( - + )} - ) -}) + ); +}); -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 ( @@ -115,5 +124,5 @@ function CodeLine({ {content} - ) + ); } diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 20a7dc54e..997fe3e7d 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -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(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(null) + const [remainingSeconds, setRemainingSeconds] = useState(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 ( - - waiting{' '} - {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })} - - ) + return waiting {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}; } export function PromptInputFooterLeftSide({ @@ -145,26 +150,22 @@ export function PromptInputFooterLeftSide({ Press {exitMessage.key} again to exit - ) + ); } if (isPasting) { return ( Pasting text… - ) + ); } - const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching + const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching; return ( {isSearching && ( - + )} {showVim ? ( @@ -183,20 +184,20 @@ export function PromptInputFooterLeftSide({ onOpenTasksDialog={onOpenTasksDialog} /> - ) + ); } 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 ! for bash mode + return ! for bash mode; } - 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() ? ( - {permissionModeSymbol(currentMode)}{' '} - {permissionModeTitle(currentMode).toLowerCase()} on + {permissionModeSymbol(currentMode)} {permissionModeTitle(currentMode).toLowerCase()} on {shouldShowModeHint && ( {' '} - + )} - ) : 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 // wrapper (reconciler throws on Box-in-Text). // Tmux pill (ant-only) — appears right after tasks in nav order - ...(process.env.USER_TYPE === 'ant' && hasTmuxSession - ? [] - : []), + ...(process.env.USER_TYPE === 'ant' && hasTmuxSession ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams - ? [ - , - ] + ? [] : []), ...(shouldShowPrStatus + ? [] + : []), + // RSS memory indicator — always visible + ...(rssState ? [ - , + + {rssState.text} · pid:{process.pid} + , ] : []), - ] + ]; // 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( - + , - ) + ); } else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) { - parts.push() + parts.push(); } 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 ( @@ -492,21 +427,18 @@ function ModeIndicator({ )} - ) + ); } // 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 — 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) ? ( - ) : null + ) : null; if (parts.length === 0 && !tasksPart && !modePart && showHint) { parts.push( ? for shortcuts , - ) + ); } // 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() + parts.push(); } 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( - {!copyOnSelect && ( - - )} + {!copyOnSelect && } {isXtermJs() && (altClickFailed ? ( set macOptionClickForcesSelection in VS Code settings ) : ( - + ))} , - ) + ); } else if ( feature('VOICE_MODE') && parts.length > 0 && @@ -579,7 +506,7 @@ function ModeIndicator({ hold {voiceKeyShortcut} to speak , - ) + ); } if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) { @@ -591,7 +518,7 @@ function ModeIndicator({ )} , - ) + ); } // 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() ? : null + return isFullscreenEnvEnabled() ? : null; } // flexShrink=0 keeps mode + pill at natural width; the remaining parts @@ -628,7 +555,7 @@ function ModeIndicator({ )} - ) + ); } 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 ? [ - + , ] : []), ...(showToggleHint ? [ - + , ] : []), - ] + ]; } function isPrStatusEnabled(): boolean { - return getGlobalConfig().prStatusFooterEnabled ?? true + return getGlobalConfig().prStatusFooterEnabled ?? true; } diff --git a/src/query.ts b/src/query.ts index 4b78e0115..0b175d00f 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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,