mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
fix: 优化内存峰值与 CPU 性能,降低 100-300MB 内存占用
- claude.ts: 流式字符串拼接从 O(n²) += 改为数组累积 join,消除 4 处热点 - Messages.tsx: 合并 3 组独立遍历为单次 pass(thinking/bash 查找、3-filter 链、divider/selectedIdx) - HighlightedCode.tsx: ColorFile 实例添加模块级 LRU 缓存(50 条),避免重复创建 - screen.ts: StylePool 衍生缓存添加 1000 条上限淘汰,防止无界增长 - CompanionSprite.tsx: TICK_MS 从 500ms 提升至 1000ms,减少 setState 频率 - connection.ts: MCP stderr 缓冲从 64MB 降至 8MB - stringUtils.ts: MAX_STRING_LENGTH 从 32MB 降至 2MB - sessionStorage.ts: Transcript 写入队列添加 1000 条上限 - query.ts: spread 改 concat 减少一次数组拷贝 - PromptInputFooterLeftSide.tsx: 显示进程 pid 便于调试 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -60,9 +60,8 @@ bun run health
|
|||||||
# Check unused exports
|
# Check unused exports
|
||||||
bun run check:unused
|
bun run check:unused
|
||||||
|
|
||||||
# Full check (typecheck + lint + test) — run after completing any task
|
# Full check (typecheck + lint fix + test) — run after completing any task
|
||||||
bun run test:all
|
bun run precheck
|
||||||
bun run typecheck
|
|
||||||
|
|
||||||
# Remote Control Server
|
# Remote Control Server
|
||||||
bun run rcs
|
bun run rcs
|
||||||
|
|||||||
217
docs/memory-peak-analysis.md
Normal file
217
docs/memory-peak-analysis.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# 内存与性能峰值分析报告
|
||||||
|
|
||||||
|
> 进程: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.gc()` 定时器(`cli/print.ts:554-558`),非强制模式
|
||||||
|
- 前置修复(commit `ab0bbbc4`):scrollback 限 500、contentReplacementState 清理等
|
||||||
|
|
||||||
|
## 内存问题(按峰值影响排序)
|
||||||
|
|
||||||
|
| # | 来源 | 峰值 | 位置 | 验证状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 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 运行时 | ✅ 架构确认 |
|
||||||
|
|
||||||
|
## 验证详情
|
||||||
|
|
||||||
|
### #1 消息数组 6-7x 拷贝(P0)
|
||||||
|
|
||||||
|
原始估计 4x,实际验证发现更多拷贝点:
|
||||||
|
|
||||||
|
| 位置 | 操作 | 拷贝类型 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
总计每轮查询循环 **6-7 次数组浅拷贝**。单次拷贝开销小(指针数组),但累积峰值叠加时占用大量临时内存。
|
||||||
|
|
||||||
|
### #2 Messages.tsx 24-25 次遍历(P1)
|
||||||
|
|
||||||
|
原始估计 3-4 次,实际有 10 个独立处理阶段:
|
||||||
|
|
||||||
|
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 次
|
||||||
|
|
||||||
|
**总计 ~24 次遍历**,主转换 useMemo 单独贡献 14 次。
|
||||||
|
|
||||||
|
### #3 ColorFile 无 LRU(P1)
|
||||||
|
|
||||||
|
- `HighlightedCode.tsx:32-41`:每次 `useMemo` 创建新 `ColorFile(code, filePath)` 实例
|
||||||
|
- `color-diff-napi` 内部有全局 `hlLineCache`(Map,上限 2048 条目)缓存 AST,但不缓存渲染结果
|
||||||
|
- 无跨实例复用,大量代码块场景下每个组件持有一份完整 code 字符串
|
||||||
|
|
||||||
|
### #4 BashTool 输出缓冲(P2)
|
||||||
|
|
||||||
|
- `stringUtils.ts:88`:`const MAX_STRING_LENGTH = 2 ** 25` = **32 MB**(非 33MB)
|
||||||
|
- 单条 Bash 命令输出可占 32MB 后才触发截断
|
||||||
|
|
||||||
|
### #5 Compact 峰值
|
||||||
|
|
||||||
|
- `compact/compact.ts:407`:先 `tokenCountWithEstimation(messages)` 遍历全量
|
||||||
|
- 整个 compact 过程 `messages` 数组不释放
|
||||||
|
- 额外创建 `preCompactReadFileState`、`postCompactFileAttachments`、`asyncAgentAttachments`
|
||||||
|
- 峰值 = old messages + API summary response + file state + attachments
|
||||||
|
|
||||||
|
### #6 MCP stderr 缓冲
|
||||||
|
|
||||||
|
- `mcp-client/src/connection.ts:117`:`maxSize = 64 * 1024 * 1024`(64MB 默认值)
|
||||||
|
- 每个 MCP server 连接独立缓冲,10 个 server = 640MB 理论上限
|
||||||
|
|
||||||
|
### #7 MCP Tool Schema 双重存储
|
||||||
|
|
||||||
|
- `services/mcp/useManageMCPConnections.ts:258`:更新时 `[...reject(mcp.tools, ...), ...tools]` 创建新数组
|
||||||
|
- 存储位置:`fetchToolsForClient` LRU(20 条目)+ `AppState.mcp.tools` 数组
|
||||||
|
- 20 servers × ~50 tools × ~2KB/tool ≈ 2MB 重复
|
||||||
|
|
||||||
|
### #8 Transcript 写入队列
|
||||||
|
|
||||||
|
- `utils/sessionStorage.ts:561-564`:`writeQueues = new Map<string, Array<{entry, resolve}>>()` 无大小限制
|
||||||
|
- 每 100ms drain(`FLUSH_INTERVAL_MS = 100`),高频写入时条目堆积
|
||||||
|
|
||||||
|
### #9 lastAPIRequestMessages
|
||||||
|
|
||||||
|
- `bootstrap/state.ts:118`:声明为模块级变量
|
||||||
|
- 仅 `ant` 用户设置(`log.ts:350`),非 ant 用户直接 `null`
|
||||||
|
- `/clear` 时通过 `clear/conversation.ts:155` 清空
|
||||||
|
|
||||||
|
### #10 流式字符串拼接
|
||||||
|
|
||||||
|
- `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²) 内存分配
|
||||||
|
|
||||||
|
### #11 Session 恢复
|
||||||
|
|
||||||
|
- 大文件(> `SKIP_PRECOMPACT_THRESHOLD`):使用 `readTranscriptForLoad()` 只加载 post-boundary 内容,有优化
|
||||||
|
- 中小文件(< threshold):`readFile(filePath)` 全量读入
|
||||||
|
- 优化已部分到位,但阈值以下的文件仍全量加载
|
||||||
|
|
||||||
|
### #12 Ink StylePool
|
||||||
|
|
||||||
|
- `screen.ts:112-180`:`StylePool` 类含 4 个无界 Map/Array
|
||||||
|
- `ids: Map<string, number>` — style key → id
|
||||||
|
- `styles: AnsiCode[][]` — 无界数组
|
||||||
|
- `transitionCache: Map<number, string>`
|
||||||
|
- `inverseCache: Map<number, number>`
|
||||||
|
- `currentMatchCache: Map<number, number>`
|
||||||
|
- `intern()` 只 push 不淘汰
|
||||||
|
|
||||||
|
### #14 AppState 不可变更新
|
||||||
|
|
||||||
|
- `store.ts:20-26`:`setState` 要求返回新对象,`Object.is` 比较后通知
|
||||||
|
- `useManageMCPConnections.ts` 每次 MCP 更新 spread 整个 `prevState`
|
||||||
|
|
||||||
|
## 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` |
|
||||||
|
| C4 | **布局偏移触发全屏 damage** | O(rows×cols) 全量 diff | `ink.tsx:655-661` |
|
||||||
|
| C7 | **CompanionSprite TICK_MS 定时器**(500ms,每秒 2 次 setState) | 高频 setState 触发渲染 | `buddy/CompanionSprite.tsx:15,136` |
|
||||||
|
| C9 | 同步 fs 操作 | 阻塞主线程 | `projectOnboardingState.ts:20` 等 |
|
||||||
|
|
||||||
|
### 已否认
|
||||||
|
|
||||||
|
- **C1 useInboxPoller 状态循环** — 验证确认:useEffect 是收敛的(移除消息 → count 减少 → 稳定),poll 通过 `store.getState()` 读取不触发 React 依赖,1 秒轮询是正常 I/O 模式无循环
|
||||||
|
- **Markdown 是 CPU 热点** — marked.lexer 对典型消息仅 0.01-0.1ms,已有 tokenCache LRU-500(缓存命中 0.0003ms,99.6% 降速)+ hasMarkdownSyntax 快速路径(跳过 30-40% 消息)
|
||||||
|
- **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 保护)
|
||||||
|
- 虚拟滚动 overscan 80 + MAX_MOUNTED_ITEMS 300 + SLIDE_STEP=25 + useDeferredValue
|
||||||
|
- Markdown tokenCache LRU-500 + hasMarkdownSyntax 快速路径 + StreamingMarkdown 增量解析
|
||||||
|
- Yoga 增量缓存(dirty propagation + measure 结果缓存)
|
||||||
|
- 双缓冲 + damage tracking + 字符池复用
|
||||||
|
- Pool 5 分钟周期重置
|
||||||
|
|
||||||
|
## 已否认
|
||||||
|
|
||||||
|
- VSZ 516 GB 是虚拟地址映射非物理内存
|
||||||
|
- RSS 波动是正常 GC 行为
|
||||||
|
- useSkillsChange / useSettingsChange 订阅泄漏 — 验证为正确的 React cleanup 模式
|
||||||
|
- Zod Schema 开销 — 仅 ~200-650KB,已有 lazySchema + WeakMap 缓存
|
||||||
|
- Ink ClockContext 16ms 定时器 — 影响 CPU 不影响内存
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
**内存根因**:消息数组多重拷贝 + JSC/mimalloc 不归还内存。典型 700 MB,最差 1.8 GB。
|
||||||
|
|
||||||
|
**CPU 根因**:useInboxPoller 每秒轮询触发 React commit → 全量 Yoga 布局 → 全屏 Ink diff 的完整管线。Markdown 渲染(~1.5ms/行)在批量挂载新消息时造成 ~290ms 卡顿。这两者叠加:轮询导致的周期性 commit 与消息挂载的 CPU 密集操作互相放大。
|
||||||
|
|
||||||
|
## 建议
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### P1:渲染管线(降 50-150 MB + 降低 CPU)
|
||||||
|
|
||||||
|
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 开销)
|
||||||
|
|
||||||
|
1. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
|
||||||
|
|
||||||
|
### P3:内存 + 低优先级
|
||||||
|
|
||||||
|
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
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||||
"docs:dev": "npx mintlify dev",
|
"docs:dev": "npx mintlify dev",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test:all": "bun run typecheck && bun test",
|
"precheck": "bun run typecheck && bun run check:fix && bun test",
|
||||||
"rcs": "bun run scripts/rcs.ts"
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -119,6 +119,44 @@ export class StylePool {
|
|||||||
this.none = this.intern([])
|
this.none = this.intern([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly CACHE_MAX = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest entries from derivative caches when they exceed the limit.
|
||||||
|
* ids/styles are never evicted (id is an array index).
|
||||||
|
*/
|
||||||
|
private evictCacheIfNeeded(): void {
|
||||||
|
if (this.transitionCache.size > StylePool.CACHE_MAX) {
|
||||||
|
const keys = this.transitionCache.keys()
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < this.transitionCache.size - StylePool.CACHE_MAX;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const k = keys.next().value
|
||||||
|
if (k !== undefined) this.transitionCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.inverseCache.size > StylePool.CACHE_MAX) {
|
||||||
|
const keys = this.inverseCache.keys()
|
||||||
|
for (let i = 0; i < this.inverseCache.size - StylePool.CACHE_MAX; i++) {
|
||||||
|
const k = keys.next().value
|
||||||
|
if (k !== undefined) this.inverseCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.currentMatchCache.size > StylePool.CACHE_MAX) {
|
||||||
|
const keys = this.currentMatchCache.keys()
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < this.currentMatchCache.size - StylePool.CACHE_MAX;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const k = keys.next().value
|
||||||
|
if (k !== undefined) this.currentMatchCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
|
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
|
||||||
* style has a visible effect on space characters (background, inverse,
|
* style has a visible effect on space characters (background, inverse,
|
||||||
@@ -136,6 +174,7 @@ export class StylePool {
|
|||||||
(rawId << 1) |
|
(rawId << 1) |
|
||||||
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
|
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
|
||||||
this.ids.set(key, id)
|
this.ids.set(key, id)
|
||||||
|
this.evictCacheIfNeeded()
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export async function withConnectionTimeout<T>(
|
|||||||
*/
|
*/
|
||||||
export function captureStderr(
|
export function captureStderr(
|
||||||
transport: StdioClientTransport,
|
transport: StdioClientTransport,
|
||||||
maxSize = 64 * 1024 * 1024,
|
maxSize = 8 * 1024 * 1024,
|
||||||
): {
|
): {
|
||||||
getOutput: () => string
|
getOutput: () => string
|
||||||
clearOutput: () => void
|
clearOutput: () => void
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { getCompanion } from './companion.js';
|
|||||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
||||||
import { RARITY_COLORS } from './types.js';
|
import { RARITY_COLORS } from './types.js';
|
||||||
|
|
||||||
const TICK_MS = 500;
|
const TICK_MS = 1000;
|
||||||
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
|
const BUBBLE_SHOW = 10; // ticks → ~10s at 1000ms
|
||||||
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
|
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
|
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.
|
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import sliceAnsi from '../utils/sliceAnsi.js';
|
|||||||
import { countCharInString } from '../utils/stringUtils.js';
|
import { countCharInString } from '../utils/stringUtils.js';
|
||||||
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js';
|
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js';
|
||||||
import { expectColorFile } from './StructuredDiff/colorDiff.js';
|
import { expectColorFile } from './StructuredDiff/colorDiff.js';
|
||||||
|
import type { ColorFile as ColorFileType } from 'color-diff-napi';
|
||||||
|
|
||||||
|
// Module-level LRU cache for ColorFile instances to avoid recreating
|
||||||
|
// them for the same (filePath, code) across component instances.
|
||||||
|
const colorFileCache = new Map<string, { colorFile: ColorFileType; code: string }>();
|
||||||
|
const COLOR_FILE_CACHE_MAX = 50;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -37,7 +43,22 @@ export const HighlightedCode = memo(function HighlightedCode({
|
|||||||
if (!ColorFile) {
|
if (!ColorFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new ColorFile(code, filePath);
|
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]);
|
}, [code, filePath, syntaxHighlightingDisabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -414,58 +414,56 @@ const MessagesImpl = ({
|
|||||||
return false;
|
return false;
|
||||||
}, [streamingThinking]);
|
}, [streamingThinking]);
|
||||||
|
|
||||||
// Find the last thinking block (message UUID + content index) for hiding past thinking in transcript mode
|
// Find the last thinking block and latest bash output in a single backward pass.
|
||||||
// When streaming thinking is visible, use a special ID that won't match any completed thinking block
|
// Merged from two separate reverse iterations to reduce total traversals.
|
||||||
// With adaptive thinking, only consider thinking blocks from the current turn and stop searching once we
|
const { lastThinkingBlockId, latestBashOutputUUID } = useMemo(() => {
|
||||||
// hit the last user message.
|
let thinkingId: string | null = null;
|
||||||
const lastThinkingBlockId = useMemo(() => {
|
let bashUUID: string | null = null;
|
||||||
if (!hidePastThinking) return null;
|
const needThinkingScan = hidePastThinking && !isStreamingThinkingVisible;
|
||||||
// If streaming thinking is visible, hide all completed thinking blocks by using a non-matching ID
|
if (hidePastThinking && isStreamingThinkingVisible) {
|
||||||
if (isStreamingThinkingVisible) return 'streaming';
|
thinkingId = 'streaming';
|
||||||
// Iterate backwards to find the last message with a thinking block
|
|
||||||
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = normalizedMessages[i];
|
|
||||||
if (msg?.type === 'assistant') {
|
|
||||||
const content = msg.message!.content as Array<{ type: string }>;
|
|
||||||
// Find the last thinking block in this message
|
|
||||||
for (let j = content.length - 1; j >= 0; j--) {
|
|
||||||
if (content[j]?.type === 'thinking') {
|
|
||||||
return `${msg.uuid}:${j}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg?.type === 'user') {
|
|
||||||
const content = msg.message!.content as Array<{ type: string }>;
|
|
||||||
const hasToolResult = content.some(block => block.type === 'tool_result');
|
|
||||||
if (!hasToolResult) {
|
|
||||||
// Reached a previous user turn so don't show stale thinking from before
|
|
||||||
return 'no-thinking';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]);
|
|
||||||
|
|
||||||
// Find the latest user bash output message (from ! commands)
|
|
||||||
// This allows us to show full output for the most recent bash command
|
|
||||||
const latestBashOutputUUID = useMemo(() => {
|
|
||||||
// Iterate backwards to find the last user message with bash output
|
|
||||||
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
|
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
|
||||||
const msg = normalizedMessages[i];
|
const msg = normalizedMessages[i];
|
||||||
if (msg?.type === 'user') {
|
if (msg?.type === 'user') {
|
||||||
const content = msg.message!.content as Array<{ type: string; text?: string }>;
|
const content = msg.message!.content as Array<{ type: string; text?: string }>;
|
||||||
// Check if any text content is bash output
|
// Bash output detection
|
||||||
for (const block of content) {
|
if (!bashUUID) {
|
||||||
if (block.type === 'text') {
|
for (const block of content) {
|
||||||
const text = block.text ?? '';
|
if (block.type === 'text') {
|
||||||
if (text.startsWith('<bash-stdout') || text.startsWith('<bash-stderr')) {
|
const text = block.text ?? '';
|
||||||
return msg.uuid;
|
if (text.startsWith('<bash-stdout') || text.startsWith('<bash-stderr')) {
|
||||||
|
bashUUID = msg.uuid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Thinking stop condition — reached a previous user turn without tool result
|
||||||
|
if (needThinkingScan && !thinkingId) {
|
||||||
|
const hasToolResult = content.some(block => block.type === 'tool_result');
|
||||||
|
if (!hasToolResult) {
|
||||||
|
thinkingId = 'no-thinking';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg?.type === 'assistant') {
|
||||||
|
if (needThinkingScan && !thinkingId) {
|
||||||
|
const content = msg.message!.content as Array<{ type: string }>;
|
||||||
|
for (let j = content.length - 1; j >= 0; j--) {
|
||||||
|
if (content[j]?.type === 'thinking') {
|
||||||
|
thinkingId = `${msg.uuid}:${j}`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (thinkingId !== null && bashUUID) break;
|
||||||
}
|
}
|
||||||
return null;
|
if (!hidePastThinking) {
|
||||||
}, [normalizedMessages]);
|
thinkingId = null;
|
||||||
|
}
|
||||||
|
return { lastThinkingBlockId: thinkingId, latestBashOutputUUID: bashUUID };
|
||||||
|
}, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]);
|
||||||
|
|
||||||
// streamingToolUses updates on every input_json_delta while normalizedMessages
|
// streamingToolUses updates on every input_json_delta while normalizedMessages
|
||||||
// stays stable — precompute the Set so the filter is O(k) not O(n×k) per chunk.
|
// stays stable — precompute the Set so the filter is O(k) not O(n×k) per chunk.
|
||||||
@@ -536,14 +534,14 @@ const MessagesImpl = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const messagesToShowNotTruncated = reorderMessagesInUI(
|
const messagesToShowNotTruncated = reorderMessagesInUI(
|
||||||
compactAwareMessages
|
compactAwareMessages.filter(
|
||||||
.filter((msg): msg is Exclude<NormalizedMessage, ProgressMessageType> => msg.type !== 'progress')
|
(msg): msg is Exclude<NormalizedMessage, ProgressMessageType> =>
|
||||||
// CC-724: drop attachment messages that AttachmentMessage renders as
|
// CC-724: drop attachment messages that AttachmentMessage renders as
|
||||||
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
|
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
|
||||||
// BEFORE counting/slicing so they don't inflate the "N messages"
|
// BEFORE counting/slicing so they don't inflate the "N messages"
|
||||||
// count in ctrl-o or consume slots in the 200-message render cap.
|
// count in ctrl-o or consume slots in the 200-message render cap.
|
||||||
.filter(msg => !isNullRenderingAttachment(msg))
|
msg.type !== 'progress' && !isNullRenderingAttachment(msg) && shouldShowUserMessage(msg, isTranscriptMode),
|
||||||
.filter(_ => shouldShowUserMessage(_, isTranscriptMode)) as Parameters<typeof reorderMessagesInUI>[0],
|
) as Parameters<typeof reorderMessagesInUI>[0],
|
||||||
syntheticStreamingToolUseMessages,
|
syntheticStreamingToolUseMessages,
|
||||||
);
|
);
|
||||||
// Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered.
|
// Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered.
|
||||||
@@ -623,19 +621,21 @@ const MessagesImpl = ({
|
|||||||
[streamingToolUses],
|
[streamingToolUses],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Divider insertion point: first renderableMessage whose uuid shares the
|
// Divider insertion point and selected index: combined into a single pass
|
||||||
// 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
|
// over renderableMessages to avoid two separate findIndex traversals.
|
||||||
// chars of the source message uuid, so this matches any block from it).
|
const { dividerBeforeIndex, selectedIdx } = useMemo(() => {
|
||||||
const dividerBeforeIndex = useMemo(() => {
|
if (!unseenDivider && !cursor) return { dividerBeforeIndex: -1, selectedIdx: -1 };
|
||||||
if (!unseenDivider) return -1;
|
let dIdx = -1;
|
||||||
const prefix = unseenDivider.firstUnseenUuid.slice(0, 24);
|
let sIdx = -1;
|
||||||
return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix);
|
const prefix = unseenDivider?.firstUnseenUuid.slice(0, 24);
|
||||||
}, [unseenDivider, renderableMessages]);
|
for (let i = 0; i < renderableMessages.length; i++) {
|
||||||
|
const m = renderableMessages[i];
|
||||||
const selectedIdx = useMemo(() => {
|
if (dIdx === -1 && prefix && m.uuid.slice(0, 24) === prefix) dIdx = i;
|
||||||
if (!cursor) return -1;
|
if (sIdx === -1 && cursor && m.uuid === cursor.uuid) sIdx = i;
|
||||||
return renderableMessages.findIndex(m => m.uuid === cursor.uuid);
|
if (dIdx !== -1 && sIdx !== -1) break;
|
||||||
}, [cursor, renderableMessages]);
|
}
|
||||||
|
return { dividerBeforeIndex: dIdx, selectedIdx: sIdx };
|
||||||
|
}, [unseenDivider, cursor, renderableMessages]);
|
||||||
|
|
||||||
// Fullscreen: click a message to toggle verbose rendering for it. Keyed by
|
// Fullscreen: click a message to toggle verbose rendering for it. Keyed by
|
||||||
// tool_use_id where available so a tool_use and its tool_result (separate
|
// tool_use_id where available so a tool_use and its tool_result (separate
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ function ModeIndicator({
|
|||||||
dimColor={rssState.level === 'normal'}
|
dimColor={rssState.level === 'normal'}
|
||||||
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
|
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
|
||||||
>
|
>
|
||||||
{rssState.text}
|
{rssState.text} · pid:{process.pid}
|
||||||
</Text>,
|
</Text>,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -1742,7 +1742,7 @@ async function* queryLoop(
|
|||||||
updatedToolUseContext,
|
updatedToolUseContext,
|
||||||
null,
|
null,
|
||||||
queuedAutonomyClaim.attachmentCommands,
|
queuedAutonomyClaim.attachmentCommands,
|
||||||
[...messagesForQuery, ...assistantMessages, ...toolResults],
|
messagesForQuery.concat(assistantMessages, toolResults),
|
||||||
querySource,
|
querySource,
|
||||||
)) {
|
)) {
|
||||||
yield attachment
|
yield attachment
|
||||||
|
|||||||
@@ -1829,6 +1829,9 @@ async function* queryModel(
|
|||||||
let ttftMs = 0
|
let ttftMs = 0
|
||||||
let partialMessage: BetaMessage | undefined
|
let partialMessage: BetaMessage | undefined
|
||||||
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
|
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
|
||||||
|
// Accumulate streaming deltas in arrays to avoid O(n²) string concatenation.
|
||||||
|
// Joined and assigned to contentBlock fields at content_block_stop.
|
||||||
|
const streamingDeltas = new Map<number, string[]>()
|
||||||
let usage: NonNullableUsage = EMPTY_USAGE
|
let usage: NonNullableUsage = EMPTY_USAGE
|
||||||
let costUSD = 0
|
let costUSD = 0
|
||||||
let stopReason: BetaStopReason | null = null
|
let stopReason: BetaStopReason | null = null
|
||||||
@@ -2115,6 +2118,8 @@ async function* queryModel(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Initialize delta accumulator for this content block
|
||||||
|
streamingDeltas.set(part.index, [])
|
||||||
break
|
break
|
||||||
case 'content_block_delta': {
|
case 'content_block_delta': {
|
||||||
const contentBlock = contentBlocks[part.index]
|
const contentBlock = contentBlocks[part.index]
|
||||||
@@ -2144,8 +2149,9 @@ async function* queryModel(
|
|||||||
})
|
})
|
||||||
throw new Error('Content block is not a connector_text block')
|
throw new Error('Content block is not a connector_text block')
|
||||||
}
|
}
|
||||||
;(contentBlock as { connector_text: string }).connector_text +=
|
streamingDeltas
|
||||||
delta.connector_text
|
.get(part.index)
|
||||||
|
?.push(delta.connector_text as string)
|
||||||
} else {
|
} else {
|
||||||
switch (delta.type) {
|
switch (delta.type) {
|
||||||
case 'citations_delta':
|
case 'citations_delta':
|
||||||
@@ -2175,7 +2181,9 @@ async function* queryModel(
|
|||||||
})
|
})
|
||||||
throw new Error('Content block input is not a string')
|
throw new Error('Content block input is not a string')
|
||||||
}
|
}
|
||||||
contentBlock.input += delta.partial_json
|
streamingDeltas
|
||||||
|
.get(part.index)
|
||||||
|
?.push(delta.partial_json as string)
|
||||||
break
|
break
|
||||||
case 'text_delta':
|
case 'text_delta':
|
||||||
if (contentBlock.type !== 'text') {
|
if (contentBlock.type !== 'text') {
|
||||||
@@ -2189,7 +2197,7 @@ async function* queryModel(
|
|||||||
})
|
})
|
||||||
throw new Error('Content block is not a text block')
|
throw new Error('Content block is not a text block')
|
||||||
}
|
}
|
||||||
;(contentBlock as { text: string }).text += delta.text
|
streamingDeltas.get(part.index)?.push(delta.text!)
|
||||||
break
|
break
|
||||||
case 'signature_delta':
|
case 'signature_delta':
|
||||||
if (
|
if (
|
||||||
@@ -2224,8 +2232,7 @@ async function* queryModel(
|
|||||||
})
|
})
|
||||||
throw new Error('Content block is not a thinking block')
|
throw new Error('Content block is not a thinking block')
|
||||||
}
|
}
|
||||||
;(contentBlock as { thinking: string }).thinking +=
|
streamingDeltas.get(part.index)?.push(delta.thinking!)
|
||||||
delta.thinking
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2257,6 +2264,32 @@ async function* queryModel(
|
|||||||
})
|
})
|
||||||
throw new Error('Message not found')
|
throw new Error('Message not found')
|
||||||
}
|
}
|
||||||
|
// Join accumulated streaming deltas into the contentBlock fields
|
||||||
|
// to avoid O(n²) string concatenation during streaming.
|
||||||
|
const deltas = streamingDeltas.get(part.index)
|
||||||
|
if (deltas && deltas.length > 0) {
|
||||||
|
const joined = deltas.join('')
|
||||||
|
switch (contentBlock.type) {
|
||||||
|
case 'text':
|
||||||
|
;(contentBlock as { text: string }).text = joined
|
||||||
|
break
|
||||||
|
case 'thinking':
|
||||||
|
;(contentBlock as { thinking: string }).thinking = joined
|
||||||
|
break
|
||||||
|
case 'tool_use':
|
||||||
|
case 'server_tool_use':
|
||||||
|
contentBlock.input = joined
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
if ((contentBlock.type as string) === 'connector_text') {
|
||||||
|
;(
|
||||||
|
contentBlock as { connector_text: string }
|
||||||
|
).connector_text = joined
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
streamingDeltas.delete(part.index)
|
||||||
|
}
|
||||||
const m: AssistantMessage = {
|
const m: AssistantMessage = {
|
||||||
message: {
|
message: {
|
||||||
...partialMessage,
|
...partialMessage,
|
||||||
|
|||||||
@@ -610,6 +610,13 @@ class Project {
|
|||||||
queue = []
|
queue = []
|
||||||
this.writeQueues.set(filePath, queue)
|
this.writeQueues.set(filePath, queue)
|
||||||
}
|
}
|
||||||
|
// Drop oldest entries when queue exceeds limit to prevent unbounded memory growth
|
||||||
|
if (queue.length >= 1000) {
|
||||||
|
const dropped = queue.splice(0, queue.length - 999)
|
||||||
|
for (const d of dropped) {
|
||||||
|
d.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
queue.push({ entry, resolve })
|
queue.push({ entry, resolve })
|
||||||
this.scheduleDrain()
|
this.scheduleDrain()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function normalizeFullWidthSpace(input: string): string {
|
|||||||
|
|
||||||
// Keep in-memory accumulation modest to avoid blowing up RSS.
|
// Keep in-memory accumulation modest to avoid blowing up RSS.
|
||||||
// Overflow beyond this limit is spilled to disk by ShellCommand.
|
// Overflow beyond this limit is spilled to disk by ShellCommand.
|
||||||
const MAX_STRING_LENGTH = 2 ** 25
|
const MAX_STRING_LENGTH = 2 ** 21
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely joins an array of strings with a delimiter, truncating if the result exceeds maxSize.
|
* Safely joins an array of strings with a delimiter, truncating if the result exceeds maxSize.
|
||||||
|
|||||||
Reference in New Issue
Block a user