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:
parent
aff7b0e853
commit
b7bbbeb039
12
CLAUDE.md
12
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 中。
|
||||
|
|
|
|||
|
|
@ -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\<string, Set\> | 每个 assistant 消息一个 Set |
|
||||
| `toolUseIDToMessageID` | Map\<string, string\> | 所有 tool_use ID |
|
||||
| `toolUseByToolUseID` | Map\<string, ToolUseBlockParam\> | **保留完整 tool_use block** |
|
||||
| `siblingToolUseIDs` | Map\<string, Set\> | 兄弟 tool_use 索引 |
|
||||
| `progressMessagesByToolUseID` | Map\<string, ProgressMessage[]\> | 进度消息数组 |
|
||||
| `toolResultByToolUseID` | Map\<string, NormalizedMessage\> | **保留完整 tool_result 消息引用** |
|
||||
| `resolvedToolUseIDs` / `erroredToolUseIDs` | Set\<string\> | 已完成/错误 ID |
|
||||
1. `normalizedMessages` (行 405) — `normalizeMessages` + `filter` = 2 次
|
||||
2. `lastThinkingBlockId` (行 421-446) — 反向遍历 = 1 次
|
||||
3. `latestBashOutputUUID` (行 450-468) — 反向遍历 = 1 次
|
||||
4. `normalizedToolUseIDs` (行 472) — `getToolUseIDs` = 1 次
|
||||
5. `streamingToolUsesWithoutInProgress` (行 474-480) — `filter` = 1 次
|
||||
6. `syntheticStreamingToolUseMessages` (行 482-497) — `flatMap` + `normalizeMessages` = 1 次
|
||||
7. **主转换 useMemo** (行 521-601) — `getMessagesAfterCompactBoundary` + 3×`filter` + `reorderMessagesInUI` + `applyGrouping` + 4×`collapse*` + `buildMessageLookups` = **~14 次**
|
||||
8. `renderableMessages` (行 604-619) — `slice` = 1 次
|
||||
9. `dividerBeforeIndex` (行 629-633) — `findIndex` = 1 次
|
||||
10. `selectedIdx` (行 635-638) — `findIndex` = 1 次
|
||||
|
||||
此 useMemo(`Messages.tsx:519`)依赖 normalizedMessages,任何消息变更(含流式 delta)触发重建。已拆分 renderRange 避免滚动触发(注释明确记录:50ms alloc per scroll → GC → 100-173ms STW on 1GB heap)。
|
||||
**总计 ~24 次遍历**,主转换 useMemo 单独贡献 14 次。
|
||||
|
||||
**useDeferredValue 双缓冲**(`REPL.tsx:1569`):流式期间 `messages` 和 `deferredMessages` 同时持有两份完整数组,直到 React 调度更新。在 27k 消息场景下,额外 ~100-200MB 临时占用。
|
||||
### #3 ColorFile 无 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<string, Array<{entry, resolve}>>()` 无大小限制
|
||||
- 每 100ms drain(`FLUSH_INTERVAL_MS = 100`),高频写入时条目堆积
|
||||
|
||||
每次 `messages` 变更触发 `setMessages` → React 重渲染 → 依赖 messages 的 useCallback/useMemo 全部重建。但 `getToolUseContext` 和 `onQueryImpl` **没有把 `messages` 放入依赖数组**(通过 `messagesRef.current` 参数传递规避),所以这些闭包不会因 messages 变化而重建。**这实际上是正确的设计**——用 ref 规避了闭包捕获问题。
|
||||
### #9 lastAPIRequestMessages
|
||||
|
||||
**真正的 hooks 问题**在于 useMemo 链(`Messages.tsx`):
|
||||
- `bootstrap/state.ts:118`:声明为模块级变量
|
||||
- 仅 `ant` 用户设置(`log.ts:350`),非 ant 用户直接 `null`
|
||||
- `/clear` 时通过 `clear/conversation.ts:155` 清空
|
||||
|
||||
```
|
||||
messages → normalizedMessages (O(n))
|
||||
→ compactAwareMessages (O(n) filter)
|
||||
→ messagesToShow (O(n) filter + reorder)
|
||||
→ groupedMessages (O(n))
|
||||
→ collapsed (O(n))
|
||||
→ lookups (8 Map/Set, O(n))
|
||||
```
|
||||
### #10 流式字符串拼接
|
||||
|
||||
流式期间每个 delta 触发 `messages` 变更 → 整条链全量重算。注释记录:50ms alloc per scroll → GC → 100-173ms STW on 1GB heap(`Messages.tsx:516-518`)。
|
||||
- `claude.ts` 中 4 处 `+=` 操作:
|
||||
- 行 2147-2148:`connector_text += delta.connector_text`
|
||||
- 行 2178:`contentBlock.input += delta.partial_json`
|
||||
- 行 2192:`contentBlock.text += delta.text`
|
||||
- 行 2227-2228:`contentBlock.thinking += delta.thinking`
|
||||
- 长流式响应时产生 O(n²) 内存分配
|
||||
|
||||
**无界 useRef**(`REPL.tsx`):
|
||||
### #11 Session 恢复
|
||||
|
||||
| Ref | 增长方式 | 清理 | 影响 |
|
||||
|-----|----------|------|------|
|
||||
| `bashTools` | `.add()` 每个 bash 命令 | `clearConversation` 时 clear | Set\<string\>,通常 <100 |
|
||||
| `discoveredSkillNamesRef` | `.add()` 每个发现的 skill | `clearConversation` 时 clear | Set\<string\>,通常 <50 |
|
||||
| `apiMetricsRef` | `.push()` 每次请求 | turn 结束时 `= []` | 临时,turn 内累积 |
|
||||
| `responseLengthRef` | 累加 | compact 时重置为 0 | 单数字 |
|
||||
| `loadedNestedMemoryPathsRef` | `.add()` 每个 CLAUDE.md | compact/clear 时 clear | Set\<string\> |
|
||||
- 大文件(> `SKIP_PRECOMPACT_THRESHOLD`):使用 `readTranscriptForLoad()` 只加载 post-boundary 内容,有优化
|
||||
- 中小文件(< threshold):`readFile(filePath)` 全量读入
|
||||
- 优化已部分到位,但阈值以下的文件仍全量加载
|
||||
|
||||
结论:**这些 ref 都有清理机制**,不是主要问题。核心问题仍是 useMemo 链在流式期间的全量重算。
|
||||
### #12 Ink StylePool
|
||||
|
||||
### P1:虚拟滚动组件(~50 MB)— Round 3 新发现
|
||||
- `screen.ts:112-180`:`StylePool` 类含 4 个无界 Map/Array
|
||||
- `ids: Map<string, number>` — style key → id
|
||||
- `styles: AnsiCode[][]` — 无界数组
|
||||
- `transitionCache: Map<number, string>`
|
||||
- `inverseCache: Map<number, number>`
|
||||
- `currentMatchCache: Map<number, number>`
|
||||
- `intern()` 只 push 不淘汰
|
||||
|
||||
`src/hooks/useVirtualScroll.ts` + React Ink 渲染管线:
|
||||
- MAX_MOUNTED_ITEMS = 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
|
||||
|
|
|
|||
158
package.json
158
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 <claude-code-best@proton.me>",
|
||||
|
|
@ -22,20 +22,23 @@
|
|||
"repl"
|
||||
],
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"ccb": "dist/cli.js",
|
||||
"claude-code-best": "dist/cli.js"
|
||||
"ccb": "dist/cli-node.js",
|
||||
"ccb-bun": "dist/cli-bun.js",
|
||||
"claude-code-best": "dist/cli-node.js"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/@ant/*"
|
||||
"packages/@ant/*",
|
||||
"packages/@anthropic-ai/*"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/download-ripgrep.ts",
|
||||
"scripts/postinstall.cjs"
|
||||
"scripts/postinstall.cjs",
|
||||
"scripts/run-parallel.mjs",
|
||||
"scripts/setup-chrome-mcp.mjs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run build.ts",
|
||||
|
|
@ -44,20 +47,32 @@
|
|||
"build:bun": "bun run build.ts",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build",
|
||||
"lint": "biome lint src/",
|
||||
"lint:fix": "biome lint --fix src/",
|
||||
"format": "biome format --write src/",
|
||||
"prepublishOnly": "bun run build:vite",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --fix .",
|
||||
"format": "biome format --write .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --fix .",
|
||||
"prepare": "husky",
|
||||
"test": "bun test",
|
||||
"test:production": "bun run scripts/production-test.ts",
|
||||
"test:production:offline": "bun run scripts/production-test.ts --offline",
|
||||
"test:production:verbose": "bun run scripts/production-test.ts --verbose",
|
||||
"test:production:bun": "bun run scripts/production-test.ts --bun",
|
||||
"check:bundle": "bun run scripts/check-bundle-integrity.ts",
|
||||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node scripts/postinstall.cjs",
|
||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"precheck": "bun run typecheck && bun run check:fix && bun test",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0"
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
|
|
@ -65,59 +80,74 @@
|
|||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
"@anthropic-ai/mcpb": "^2.1.2",
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.28",
|
||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1037.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
|
||||
"@aws-sdk/client-sts": "^3.1037.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.36",
|
||||
"@aws-sdk/credential-providers": "^3.1037.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
"@claude-code-best/builtin-tools": "workspace:*",
|
||||
"@claude-code-best/mcp-client": "workspace:*",
|
||||
"@claude-code-best/weixin": "workspace:*",
|
||||
"@commander-js/extra-typings": "^14.0.0",
|
||||
"@growthbook/growthbook": "^1.6.5",
|
||||
"@langfuse/otel": "^5.1.0",
|
||||
"@langfuse/tracing": "^5.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/core": "^2.6.1",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.6.1",
|
||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
||||
"@opentelemetry/api-logs": "^0.215.0",
|
||||
"@opentelemetry/core": "^2.7.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.215.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@smithy/core": "^3.23.13",
|
||||
"@smithy/node-http-handler": "^4.5.1",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@sentry/node": "^10.49.0",
|
||||
"@smithy/core": "^3.23.15",
|
||||
"@smithy/node-http-handler": "^4.5.3",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/cacache": "^20.0.1",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@types/plist": "^3.0.5",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-reconciler": "^0.33.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/stack-utils": "^2.0.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ajv": "^8.18.0",
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.15.2",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
|
|
@ -132,32 +162,32 @@
|
|||
"execa": "^9.6.1",
|
||||
"fflate": "^0.8.2",
|
||||
"figures": "^6.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"ignore": "^7.0.5",
|
||||
"image-processor-napi": "workspace:*",
|
||||
"indent-string": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^6.1.1",
|
||||
"lint-staged": "^17.0.7",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lru-cache": "^11.2.7",
|
||||
"marked": "^17.0.5",
|
||||
"knip": "^6.4.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"marked": "^17.0.6",
|
||||
"modifiers-napi": "workspace:*",
|
||||
"openai": "^6.33.0",
|
||||
"openai": "^6.34.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picomatch": "^4.0.4",
|
||||
"plist": "^3.1.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"rollup": "^4.60.2",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
|
|
@ -166,22 +196,32 @@
|
|||
"strip-ansi": "^7.2.0",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"turndown": "^7.2.2",
|
||||
"type-fest": "^5.5.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.24.6",
|
||||
"turndown": "^7.2.4",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^7.25.0",
|
||||
"url-handler-napi": "workspace:*",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^6.0.0",
|
||||
"vite": "^8.0.8",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"vscode-languageserver-protocol": "^3.17.5",
|
||||
"vscode-languageserver-types": "^3.17.5",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
"ws": "^8.20.0",
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@inquirer/prompts": "8.4.2",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"follow-redirects": "1.16.0",
|
||||
"hono": "4.12.15",
|
||||
"postcss": "8.5.10",
|
||||
"uuid": "14.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs,jsx}": [
|
||||
"biome check --fix --no-errors-on-unmatched"
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
import { feature } from 'bun:bundle'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { AppState } from '../state/AppStateStore.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
|
||||
import { RARITY_COLORS } from './types.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import type { AppState } from '../state/AppStateStore.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||
import type { Theme } from '../utils/theme.js';
|
||||
import { getCompanion } from './companion.js';
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
||||
import { RARITY_COLORS } from './types.js';
|
||||
|
||||
const TICK_MS = 500
|
||||
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
|
||||
const TICK_MS = 1000;
|
||||
const BUBBLE_SHOW = 10; // ticks → ~10s at 1000ms
|
||||
const FADE_WINDOW = 3; // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
||||
|
||||
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
||||
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];
|
||||
|
||||
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
||||
const H = figures.heart
|
||||
const H = figures.heart;
|
||||
const PET_HEARTS = [
|
||||
` ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
`${H} ${H} ${H} `,
|
||||
'· · · ',
|
||||
]
|
||||
];
|
||||
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const words = text.split(' ')
|
||||
const lines: string[] = []
|
||||
let cur = ''
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if (cur.length + w.length + 1 > width && cur) {
|
||||
lines.push(cur)
|
||||
cur = w
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
return lines
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function SpeechBubble({
|
||||
|
|
@ -53,40 +53,29 @@ function SpeechBubble({
|
|||
fading,
|
||||
tail,
|
||||
}: {
|
||||
text: string
|
||||
color: keyof Theme
|
||||
fading: boolean
|
||||
tail: 'down' | 'right'
|
||||
text: string;
|
||||
color: keyof Theme;
|
||||
fading: boolean;
|
||||
tail: 'down' | 'right';
|
||||
}): React.ReactNode {
|
||||
const lines = wrap(text, 30)
|
||||
const borderColor = fading ? 'inactive' : color
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : color;
|
||||
const bubble = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
width={34}
|
||||
>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||
{lines.map((l, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
italic
|
||||
dimColor={!fading}
|
||||
color={fading ? 'inactive' : undefined}
|
||||
>
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
if (tail === 'right') {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||
|
|
@ -96,18 +85,18 @@ function SpeechBubble({
|
|||
<Text color={borderColor}>╲</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100
|
||||
const SPRITE_BODY_WIDTH = 12
|
||||
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2
|
||||
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||
const SPRITE_BODY_WIDTH = 12;
|
||||
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2;
|
||||
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24;
|
||||
|
||||
function spriteColWidth(nameWidth: number): number {
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
|
||||
}
|
||||
|
||||
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
||||
|
|
@ -115,89 +104,73 @@ function spriteColWidth(nameWidth: number): number {
|
|||
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
|
||||
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||
export function companionReservedColumns(
|
||||
terminalColumns: number,
|
||||
speaking: boolean,
|
||||
): number {
|
||||
if (!feature('BUDDY')) return 0
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
|
||||
const nameWidth = stringWidth(companion.name)
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
|
||||
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||
if (!feature('BUDDY')) return 0;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0;
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
||||
const nameWidth = stringWidth(companion.name);
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
|
||||
}
|
||||
|
||||
export function CompanionSprite(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const petAt = useAppState(s => s.companionPetAt)
|
||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
||||
const setAppState = useSetAppState()
|
||||
const { columns } = useTerminalSize()
|
||||
const [tick, setTick] = useState(0)
|
||||
const lastSpokeTick = useRef(0)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const petAt = useAppState(s => s.companionPetAt);
|
||||
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||
const setAppState = useSetAppState();
|
||||
const { columns } = useTerminalSize();
|
||||
const [tick, setTick] = useState(0);
|
||||
const lastSpokeTick = useRef(0);
|
||||
// Sync-during-render (not useEffect) so the first post-pet render already
|
||||
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
||||
const [{ petStartTick, forPetAt }, setPetStart] = useState({
|
||||
petStartTick: 0,
|
||||
forPetAt: petAt,
|
||||
})
|
||||
});
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt })
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
setT => setT((t: number) => t + 1),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
lastSpokeTick.current = tick
|
||||
if (!reaction) return;
|
||||
lastSpokeTick.current = tick;
|
||||
const timer = setTimeout(
|
||||
setA =>
|
||||
setA((prev: AppState) =>
|
||||
prev.companionReaction === undefined
|
||||
? prev
|
||||
: { ...prev, companionReaction: undefined },
|
||||
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
|
||||
),
|
||||
BUBBLE_SHOW * TICK_MS,
|
||||
setAppState,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
||||
}, [reaction, setAppState])
|
||||
}, [reaction, setAppState]);
|
||||
|
||||
if (!feature('BUDDY')) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY')) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
const color = RARITY_COLORS[companion.rarity]
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name))
|
||||
const color = RARITY_COLORS[companion.rarity];
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name));
|
||||
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
|
||||
const fading =
|
||||
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
|
||||
const petAge = petAt ? tick - petStartTick : Infinity
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS
|
||||
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||
|
||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
||||
// replaces the name beside the face (no room for a bubble).
|
||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||
const quip =
|
||||
reaction && reaction.length > NARROW_QUIP_CAP
|
||||
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
|
||||
: reaction
|
||||
const label = quip
|
||||
? `"${quip}"`
|
||||
: focused
|
||||
? ` ${companion.name} `
|
||||
: companion.name
|
||||
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
|
||||
return (
|
||||
<Box paddingX={1} alignSelf="flex-end">
|
||||
<Text>
|
||||
|
|
@ -210,44 +183,34 @@ export function CompanionSprite(): React.ReactNode {
|
|||
dimColor={!focused && !reaction}
|
||||
bold={focused}
|
||||
inverse={focused && !reaction}
|
||||
color={
|
||||
reaction
|
||||
? fading
|
||||
? 'inactive'
|
||||
: color
|
||||
: focused
|
||||
? color
|
||||
: undefined
|
||||
}
|
||||
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
const frameCount = spriteFrameCount(companion.species)
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
|
||||
const frameCount = spriteFrameCount(companion.species);
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||
|
||||
let spriteFrame: number
|
||||
let blink = false
|
||||
let spriteFrame: number;
|
||||
let blink = false;
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
spriteFrame = tick % frameCount
|
||||
spriteFrame = tick % frameCount;
|
||||
} else {
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
|
||||
if (step === -1) {
|
||||
spriteFrame = 0
|
||||
blink = true
|
||||
spriteFrame = 0;
|
||||
blink = true;
|
||||
} else {
|
||||
spriteFrame = step % frameCount
|
||||
spriteFrame = step % frameCount;
|
||||
}
|
||||
}
|
||||
|
||||
const body = renderSprite(companion, spriteFrame).map(line =>
|
||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
||||
)
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body
|
||||
const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line));
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body;
|
||||
|
||||
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
||||
// focused shows inverse name. The enter-to-open hint lives in
|
||||
|
|
@ -255,31 +218,20 @@ export function CompanionSprite(): React.ReactNode {
|
|||
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
||||
// inline-bubble row wrapper from squeezing the sprite to fit.
|
||||
const spriteColumn = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
width={colWidth}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
{sprite.map((line, i) => (
|
||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
<Text
|
||||
italic
|
||||
bold={focused}
|
||||
dimColor={!focused}
|
||||
color={focused ? color : undefined}
|
||||
inverse={focused}
|
||||
>
|
||||
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||
{focused ? ` ${companion.name} ` : companion.name}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
|
|
@ -288,19 +240,14 @@ export function CompanionSprite(): React.ReactNode {
|
|||
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
||||
// because floating into Static scrollback can't be cleared.
|
||||
if (isFullscreenActive()) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={color}
|
||||
fading={fading}
|
||||
tail="right"
|
||||
/>
|
||||
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
|
||||
{spriteColumn}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
||||
|
|
@ -308,33 +255,29 @@ export function CompanionSprite(): React.ReactNode {
|
|||
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
||||
// just reads companionReaction and renders the fade.
|
||||
export function CompanionFloatingBubble(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const [{ tick, forReaction }, setTick] = useState({
|
||||
tick: 0,
|
||||
forReaction: reaction,
|
||||
})
|
||||
});
|
||||
|
||||
// Reset tick synchronously when reaction changes (not in useEffect, which
|
||||
// runs post-render and would show one stale-faded frame). Storing the
|
||||
// reaction the tick is counting FOR alongside the tick itself means the
|
||||
// fade computation never sees a tick from a previous reaction.
|
||||
if (reaction !== forReaction) {
|
||||
setTick({ tick: 0, forReaction: reaction })
|
||||
setTick({ tick: 0, forReaction: reaction });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
const timer = setInterval(
|
||||
set => set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [reaction])
|
||||
if (!reaction) return;
|
||||
const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, [reaction]);
|
||||
|
||||
if (!feature('BUDDY') || !reaction) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY') || !reaction) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
return (
|
||||
<SpeechBubble
|
||||
|
|
@ -343,5 +286,5 @@ export function CompanionFloatingBubble(): React.ReactNode {
|
|||
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
|
||||
tail="down"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
import * as React from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSettings } from '../hooks/useSettings.js'
|
||||
import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink'
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
|
||||
import sliceAnsi from '../utils/sliceAnsi.js'
|
||||
import { countCharInString } from '../utils/stringUtils.js'
|
||||
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js'
|
||||
import { expectColorFile } from './StructuredDiff/colorDiff.js'
|
||||
import * as React from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSettings } from '../hooks/useSettings.js';
|
||||
import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink';
|
||||
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
|
||||
import sliceAnsi from '../utils/sliceAnsi.js';
|
||||
import { countCharInString } from '../utils/stringUtils.js';
|
||||
import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js';
|
||||
import { expectColorFile } from './StructuredDiff/colorDiff.js';
|
||||
import type { ColorFile as ColorFileType } from 'color-diff-napi';
|
||||
|
||||
// Module-level LRU cache for ColorFile instances to avoid recreating
|
||||
// them for the same (filePath, code) across component instances.
|
||||
const colorFileCache = new Map<string, { colorFile: ColorFileType; code: string }>();
|
||||
const COLOR_FILE_CACHE_MAX = 50;
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
filePath: string
|
||||
width?: number
|
||||
dim?: boolean
|
||||
}
|
||||
code: string;
|
||||
filePath: string;
|
||||
width?: number;
|
||||
dim?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_WIDTH = 80
|
||||
const DEFAULT_WIDTH = 80;
|
||||
|
||||
export const HighlightedCode = memo(function HighlightedCode({
|
||||
code,
|
||||
|
|
@ -23,39 +29,53 @@ export const HighlightedCode = memo(function HighlightedCode({
|
|||
width,
|
||||
dim = false,
|
||||
}: Props): React.ReactElement {
|
||||
const ref = useRef<DOMElement>(null)
|
||||
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH)
|
||||
const [theme] = useTheme()
|
||||
const settings = useSettings()
|
||||
const syntaxHighlightingDisabled =
|
||||
settings.syntaxHighlightingDisabled ?? false
|
||||
const ref = useRef<DOMElement>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH);
|
||||
const [theme] = useTheme();
|
||||
const settings = useSettings();
|
||||
const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false;
|
||||
|
||||
const colorFile = useMemo(() => {
|
||||
if (syntaxHighlightingDisabled) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const ColorFile = expectColorFile()
|
||||
const ColorFile = expectColorFile();
|
||||
if (!ColorFile) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return new ColorFile(code, filePath)
|
||||
}, [code, filePath, syntaxHighlightingDisabled])
|
||||
const cacheKey = `${filePath}\0${code.length}`;
|
||||
const cached = colorFileCache.get(cacheKey);
|
||||
if (cached && cached.code === code) {
|
||||
// Move to end (most recently used)
|
||||
colorFileCache.delete(cacheKey);
|
||||
colorFileCache.set(cacheKey, cached);
|
||||
return cached.colorFile;
|
||||
}
|
||||
const instance = new ColorFile(code, filePath);
|
||||
// Evict oldest entry if cache is full
|
||||
if (colorFileCache.size >= COLOR_FILE_CACHE_MAX) {
|
||||
const oldest = colorFileCache.keys().next().value;
|
||||
if (oldest !== undefined) colorFileCache.delete(oldest);
|
||||
}
|
||||
colorFileCache.set(cacheKey, { colorFile: instance, code });
|
||||
return instance;
|
||||
}, [code, filePath, syntaxHighlightingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!width && ref.current) {
|
||||
const { width: elementWidth } = measureElement(ref.current)
|
||||
const { width: elementWidth } = measureElement(ref.current);
|
||||
if (elementWidth > 0) {
|
||||
setMeasuredWidth(elementWidth - 2)
|
||||
setMeasuredWidth(elementWidth - 2);
|
||||
}
|
||||
}
|
||||
}, [width])
|
||||
}, [width]);
|
||||
|
||||
const lines = useMemo(() => {
|
||||
if (colorFile === null) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return colorFile.render(theme, measuredWidth, dim)
|
||||
}, [colorFile, theme, measuredWidth, dim])
|
||||
return colorFile.render(theme, measuredWidth, dim);
|
||||
}, [colorFile, theme, measuredWidth, dim]);
|
||||
|
||||
// Gutter width matches ColorFile's layout in lib.rs: space + right-aligned
|
||||
// line number (max_digits = lineCount.toString().length) + space. No marker
|
||||
|
|
@ -64,10 +84,10 @@ export const HighlightedCode = memo(function HighlightedCode({
|
|||
// (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native
|
||||
// selection where noSelect is meaningless.
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!isFullscreenEnvEnabled()) return 0
|
||||
const lineCount = countCharInString(code, '\n') + 1
|
||||
return lineCount.toString().length + 2
|
||||
}, [code])
|
||||
if (!isFullscreenEnvEnabled()) return 0;
|
||||
const lineCount = countCharInString(code, '\n') + 1;
|
||||
return lineCount.toString().length + 2;
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
|
|
@ -84,26 +104,15 @@ export const HighlightedCode = memo(function HighlightedCode({
|
|||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<HighlightedCodeFallback
|
||||
code={code}
|
||||
filePath={filePath}
|
||||
dim={dim}
|
||||
skipColoring={syntaxHighlightingDisabled}
|
||||
/>
|
||||
<HighlightedCodeFallback code={code} filePath={filePath} dim={dim} skipColoring={syntaxHighlightingDisabled} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
function CodeLine({
|
||||
line,
|
||||
gutterWidth,
|
||||
}: {
|
||||
line: string
|
||||
gutterWidth: number
|
||||
}): React.ReactNode {
|
||||
const gutter = sliceAnsi(line, 0, gutterWidth)
|
||||
const content = sliceAnsi(line, gutterWidth)
|
||||
function CodeLine({ line, gutterWidth }: { line: string; gutterWidth: number }): React.ReactNode {
|
||||
const gutter = sliceAnsi(line, 0, gutterWidth);
|
||||
const content = sliceAnsi(line, gutterWidth);
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<NoSelect fromLeftEdge>
|
||||
|
|
@ -115,5 +124,5 @@ function CodeLine({
|
|||
<Ansi>{content}</Ansi>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,125 +1,130 @@
|
|||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { feature } from 'bun:bundle'
|
||||
import { feature } from 'bun:bundle';
|
||||
// Dead code elimination: conditional import for COORDINATOR_MODE
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const coordinatorModule = feature('COORDINATOR_MODE')
|
||||
? (require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js'))
|
||||
: undefined
|
||||
: undefined;
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import { Box, Text, Link } from '@anthropic/ink'
|
||||
import * as React from 'react'
|
||||
import figures from 'figures'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js'
|
||||
import type { ToolPermissionContext } from '../../Tool.js'
|
||||
import { isVimModeEnabled } from './utils.js'
|
||||
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'
|
||||
import { Box, Text, Link } from '@anthropic/ink';
|
||||
import * as React from 'react';
|
||||
import figures from 'figures';
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js';
|
||||
import type { ToolPermissionContext } from '../../Tool.js';
|
||||
import { isVimModeEnabled } from './utils.js';
|
||||
import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js';
|
||||
import {
|
||||
isDefaultMode,
|
||||
permissionModeSymbol,
|
||||
permissionModeTitle,
|
||||
getModeColor,
|
||||
} from '../../utils/permissions/PermissionMode.js'
|
||||
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js'
|
||||
import { isBackgroundTask } from '../../tasks/types.js'
|
||||
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js'
|
||||
import { count } from '../../utils/array.js'
|
||||
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'
|
||||
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
|
||||
import { TeamStatus } from '../teams/TeamStatus.js'
|
||||
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'
|
||||
import { useAppState, useAppStateStore } from 'src/state/AppState.js'
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||
import HistorySearchInput from './HistorySearchInput.js'
|
||||
import { usePrStatus } from '../../hooks/usePrStatus.js'
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { useTasksV2 } from '../../hooks/useTasksV2.js'
|
||||
import { formatDuration } from '../../utils/format.js'
|
||||
import { VoiceWarmupHint } from './VoiceIndicator.js'
|
||||
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
|
||||
import { useVoiceState } from '../../context/voice.js'
|
||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
||||
import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { getPlatform } from '../../utils/platform.js'
|
||||
import { PrBadge } from '../PrBadge.js'
|
||||
import * as proactiveModuleValue from '../../proactive/index.js'
|
||||
} from '../../utils/permissions/PermissionMode.js';
|
||||
import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js';
|
||||
import { isBackgroundTask } from '../../tasks/types.js';
|
||||
import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
|
||||
import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js';
|
||||
import { count } from '../../utils/array.js';
|
||||
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
|
||||
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js';
|
||||
import { TeamStatus } from '../teams/TeamStatus.js';
|
||||
import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js';
|
||||
import { useAppState, useAppStateStore } from 'src/state/AppState.js';
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js';
|
||||
import HistorySearchInput from './HistorySearchInput.js';
|
||||
import { usePrStatus } from '../../hooks/usePrStatus.js';
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { useTasksV2 } from '../../hooks/useTasksV2.js';
|
||||
import { formatDuration, formatFileSize } from '../../utils/format.js';
|
||||
import { VoiceWarmupHint } from './VoiceIndicator.js';
|
||||
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js';
|
||||
import { useVoiceState } from '../../context/voice.js';
|
||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js';
|
||||
import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { getPlatform } from '../../utils/platform.js';
|
||||
import { PrBadge } from '../PrBadge.js';
|
||||
|
||||
const proactiveModule =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? proactiveModuleValue
|
||||
: null
|
||||
const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
|
||||
const NULL = () => null
|
||||
const MAX_VOICE_HINT_SHOWS = 3
|
||||
// Dead code elimination: conditional import for proactive mode
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') : null;
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
|
||||
const NULL = () => null;
|
||||
const MAX_VOICE_HINT_SHOWS = 3;
|
||||
|
||||
const RSS_UPDATE_INTERVAL_MS = 5_000;
|
||||
|
||||
type RssState = { text: string; level: 'normal' | 'warning' | 'error' };
|
||||
|
||||
function useRssDisplay(): RssState | null {
|
||||
const [state, setState] = useState<RssState | null>(null);
|
||||
useEffect(() => {
|
||||
function update(): void {
|
||||
const mb = process.memoryUsage().rss / (1024 * 1024);
|
||||
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal';
|
||||
const text = formatFileSize(mb * 1024 * 1024);
|
||||
setState(prev => (prev?.text === text ? prev : { text, level }));
|
||||
}
|
||||
update();
|
||||
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return state;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
exitMessage: {
|
||||
show: boolean
|
||||
key?: string
|
||||
}
|
||||
vimMode: VimMode | undefined
|
||||
mode: PromptInputMode
|
||||
toolPermissionContext: ToolPermissionContext
|
||||
suppressHint: boolean
|
||||
isLoading: boolean
|
||||
showMemoryTypeSelector?: boolean
|
||||
tasksSelected: boolean
|
||||
teamsSelected: boolean
|
||||
tmuxSelected: boolean
|
||||
teammateFooterIndex?: number
|
||||
isPasting?: boolean
|
||||
isSearching: boolean
|
||||
historyQuery: string
|
||||
setHistoryQuery: (query: string) => void
|
||||
historyFailedMatch: boolean
|
||||
onOpenTasksDialog?: (taskId?: string) => void
|
||||
}
|
||||
show: boolean;
|
||||
key?: string;
|
||||
};
|
||||
vimMode: VimMode | undefined;
|
||||
mode: PromptInputMode;
|
||||
toolPermissionContext: ToolPermissionContext;
|
||||
suppressHint: boolean;
|
||||
isLoading: boolean;
|
||||
showMemoryTypeSelector?: boolean;
|
||||
tasksSelected: boolean;
|
||||
teamsSelected: boolean;
|
||||
tmuxSelected: boolean;
|
||||
teammateFooterIndex?: number;
|
||||
isPasting?: boolean;
|
||||
isSearching: boolean;
|
||||
historyQuery: string;
|
||||
setHistoryQuery: (query: string) => void;
|
||||
historyFailedMatch: boolean;
|
||||
onOpenTasksDialog?: (taskId?: string) => void;
|
||||
};
|
||||
|
||||
function ProactiveCountdown(): React.ReactNode {
|
||||
const nextTickAt = useSyncExternalStore(
|
||||
proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE,
|
||||
proactiveModule?.getNextTickAt ?? NULL,
|
||||
NULL,
|
||||
)
|
||||
);
|
||||
|
||||
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null)
|
||||
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (nextTickAt === null) {
|
||||
setRemainingSeconds(null)
|
||||
return
|
||||
setRemainingSeconds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
function update(): void {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.ceil((nextTickAt! - Date.now()) / 1000),
|
||||
)
|
||||
setRemainingSeconds(remaining)
|
||||
const remaining = Math.max(0, Math.ceil((nextTickAt! - Date.now()) / 1000));
|
||||
setRemainingSeconds(remaining);
|
||||
}
|
||||
|
||||
update()
|
||||
const interval = setInterval(update, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [nextTickAt])
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [nextTickAt]);
|
||||
|
||||
if (remainingSeconds === null) return null
|
||||
if (remainingSeconds === null) return null;
|
||||
|
||||
return (
|
||||
<Text dimColor>
|
||||
waiting{' '}
|
||||
{formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}
|
||||
</Text>
|
||||
)
|
||||
return <Text dimColor>waiting {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}</Text>;
|
||||
}
|
||||
|
||||
export function PromptInputFooterLeftSide({
|
||||
|
|
@ -145,26 +150,22 @@ export function PromptInputFooterLeftSide({
|
|||
<Text dimColor key="exit-message">
|
||||
Press {exitMessage.key} again to exit
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (isPasting) {
|
||||
return (
|
||||
<Text dimColor key="pasting-message">
|
||||
Pasting text…
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching
|
||||
const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching;
|
||||
|
||||
return (
|
||||
<Box justifyContent="flex-start" gap={1}>
|
||||
{isSearching && (
|
||||
<HistorySearchInput
|
||||
value={historyQuery}
|
||||
onChange={setHistoryQuery}
|
||||
historyFailedMatch={historyFailedMatch}
|
||||
/>
|
||||
<HistorySearchInput value={historyQuery} onChange={setHistoryQuery} historyFailedMatch={historyFailedMatch} />
|
||||
)}
|
||||
{showVim ? (
|
||||
<Text dimColor key="vim-insert">
|
||||
|
|
@ -183,20 +184,20 @@ export function PromptInputFooterLeftSide({
|
|||
onOpenTasksDialog={onOpenTasksDialog}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type ModeIndicatorProps = {
|
||||
mode: PromptInputMode
|
||||
toolPermissionContext: ToolPermissionContext
|
||||
showHint: boolean
|
||||
isLoading: boolean
|
||||
tasksSelected: boolean
|
||||
teamsSelected: boolean
|
||||
tmuxSelected: boolean
|
||||
teammateFooterIndex?: number
|
||||
onOpenTasksDialog?: (taskId?: string) => void
|
||||
}
|
||||
mode: PromptInputMode;
|
||||
toolPermissionContext: ToolPermissionContext;
|
||||
showHint: boolean;
|
||||
isLoading: boolean;
|
||||
tasksSelected: boolean;
|
||||
teamsSelected: boolean;
|
||||
tmuxSelected: boolean;
|
||||
teammateFooterIndex?: number;
|
||||
onOpenTasksDialog?: (taskId?: string) => void;
|
||||
};
|
||||
|
||||
function ModeIndicator({
|
||||
mode,
|
||||
|
|
@ -209,110 +210,70 @@ function ModeIndicator({
|
|||
teammateFooterIndex,
|
||||
onOpenTasksDialog,
|
||||
}: ModeIndicatorProps): React.ReactNode {
|
||||
const { columns } = useTerminalSize()
|
||||
const modeCycleShortcut = useShortcutDisplay(
|
||||
'chat:cycleMode',
|
||||
'Chat',
|
||||
'shift+tab',
|
||||
)
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const teamContext = useAppState(s => s.teamContext)
|
||||
const { columns } = useTerminalSize();
|
||||
const modeCycleShortcut = useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab');
|
||||
const tasks = useAppState(s => s.tasks);
|
||||
const teamContext = useAppState(s => s.teamContext);
|
||||
// Set once in initialState (main.tsx --remote mode) and never mutated — lazy
|
||||
// init captures the immutable value without a subscription.
|
||||
const store = useAppStateStore()
|
||||
const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl)
|
||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
const expandedView = useAppState(s => s.expandedView)
|
||||
const showSpinnerTree = expandedView === 'teammates'
|
||||
const prStatus = usePrStatus(isLoading, isPrStatusEnabled())
|
||||
const hasTmuxSession = useAppState(
|
||||
s =>
|
||||
process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined,
|
||||
)
|
||||
const store = useAppStateStore();
|
||||
const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl);
|
||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode);
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
|
||||
const expandedView = useAppState(s => s.expandedView);
|
||||
const showSpinnerTree = expandedView === 'teammates';
|
||||
const prStatus = usePrStatus(isLoading, isPrStatusEnabled());
|
||||
const hasTmuxSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined);
|
||||
|
||||
const nextTickAt = useSyncExternalStore(
|
||||
proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE,
|
||||
proactiveModule?.getNextTickAt ?? NULL,
|
||||
NULL,
|
||||
)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
|
||||
const voiceState = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceState)
|
||||
: ('idle' as const)
|
||||
const voiceWarmingUp = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceState(s => s.voiceWarmingUp)
|
||||
: false
|
||||
const hasSelection = useHasSelection()
|
||||
const selGetState = useSelection().getState
|
||||
const hasNextTick = nextTickAt !== null
|
||||
const isCoordinator = feature('COORDINATOR_MODE')
|
||||
? coordinatorModule?.isCoordinatorMode() === true
|
||||
: false
|
||||
);
|
||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
||||
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
|
||||
const hasSelection = useHasSelection();
|
||||
const selGetState = useSelection().getState;
|
||||
const hasNextTick = nextTickAt !== null;
|
||||
const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false;
|
||||
const runningTaskCount = useMemo(
|
||||
() =>
|
||||
count(
|
||||
Object.values(tasks),
|
||||
t =>
|
||||
isBackgroundTask(t) &&
|
||||
!(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
|
||||
t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)),
|
||||
),
|
||||
[tasks],
|
||||
)
|
||||
const tasksV2 = useTasksV2()
|
||||
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0
|
||||
const escShortcut = useShortcutDisplay(
|
||||
'chat:cancel',
|
||||
'Chat',
|
||||
'esc',
|
||||
).toLowerCase()
|
||||
const todosShortcut = useShortcutDisplay(
|
||||
'app:toggleTodos',
|
||||
'Global',
|
||||
'ctrl+t',
|
||||
)
|
||||
const killAgentsShortcut = useShortcutDisplay(
|
||||
'chat:killAgents',
|
||||
'Chat',
|
||||
'ctrl+x ctrl+k',
|
||||
)
|
||||
const voiceKeyShortcut = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
|
||||
: ''
|
||||
);
|
||||
const tasksV2 = useTasksV2();
|
||||
const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0;
|
||||
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
|
||||
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
|
||||
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
|
||||
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
|
||||
// Captured at mount so the hint doesn't flicker mid-session if another
|
||||
// CC instance increments the counter. Incremented once via useEffect the
|
||||
// first time voice is enabled in this session — approximates "hint was
|
||||
// shown" without tracking the exact render-time condition (which depends
|
||||
// on parts/hintParts computed after the early-return hooks boundary).
|
||||
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useState(
|
||||
() =>
|
||||
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
|
||||
MAX_VOICE_HINT_SHOWS,
|
||||
)
|
||||
: [false]
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
|
||||
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
|
||||
: [false];
|
||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
|
||||
useEffect(() => {
|
||||
if (feature('VOICE_MODE')) {
|
||||
if (!voiceEnabled || !voiceHintUnderCap) return
|
||||
if (voiceHintIncrementedRef?.current) return
|
||||
if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true
|
||||
const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1
|
||||
if (!voiceEnabled || !voiceHintUnderCap) return;
|
||||
if (voiceHintIncrementedRef?.current) return;
|
||||
if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true;
|
||||
const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1;
|
||||
saveGlobalConfig(prev => {
|
||||
if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev
|
||||
return { ...prev, voiceFooterHintSeenCount: newCount }
|
||||
})
|
||||
if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev;
|
||||
return { ...prev, voiceFooterHintSeenCount: newCount };
|
||||
});
|
||||
}
|
||||
}, [voiceEnabled, voiceHintUnderCap])
|
||||
const isKillAgentsConfirmShowing = useAppState(
|
||||
s => s.notifications.current?.key === 'kill-agents-confirm',
|
||||
)
|
||||
}, [voiceEnabled, voiceHintUnderCap]);
|
||||
const isKillAgentsConfirmShowing = useAppState(s => s.notifications.current?.key === 'kill-agents-confirm');
|
||||
const rssState = useRssDisplay();
|
||||
|
||||
// Derive team info from teamContext (no filesystem I/O needed)
|
||||
// Match the same logic as TeamStatus to avoid trailing separator
|
||||
|
|
@ -321,27 +282,21 @@ function ModeIndicator({
|
|||
isAgentSwarmsEnabled() &&
|
||||
!isInProcessEnabled() &&
|
||||
teamContext !== undefined &&
|
||||
count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0
|
||||
count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0;
|
||||
|
||||
if (mode === 'bash') {
|
||||
return <Text color="bashBorder">! for bash mode</Text>
|
||||
return <Text color="bashBorder">! for bash mode</Text>;
|
||||
}
|
||||
|
||||
const currentMode = toolPermissionContext?.mode
|
||||
const hasActiveMode = !isDefaultMode(currentMode)
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
|
||||
const isViewingTeammate =
|
||||
viewSelectionMode === 'viewing-agent' &&
|
||||
viewedTask?.type === 'in_process_teammate'
|
||||
const isViewingCompletedTeammate =
|
||||
isViewingTeammate && viewedTask != null && viewedTask.status !== 'running'
|
||||
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate
|
||||
const currentMode = toolPermissionContext?.mode;
|
||||
const hasActiveMode = !isDefaultMode(currentMode);
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
|
||||
const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate';
|
||||
const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running';
|
||||
const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate;
|
||||
|
||||
// Count primary items (permission mode or coordinator mode, background tasks, and teams)
|
||||
const primaryItemCount =
|
||||
(isCoordinator || hasActiveMode ? 1 : 0) +
|
||||
(hasBackgroundTasks ? 1 : 0) +
|
||||
(hasTeams ? 1 : 0)
|
||||
const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0);
|
||||
|
||||
// PR indicator is short (~10 chars) — unlike the old diff indicator the
|
||||
// >=100 threshold was tuned for. Now that auto mode is effectively the
|
||||
|
|
@ -353,19 +308,16 @@ function ModeIndicator({
|
|||
prStatus.reviewState !== null &&
|
||||
prStatus.url !== null &&
|
||||
primaryItemCount < 2 &&
|
||||
(primaryItemCount === 0 || columns >= 80)
|
||||
(primaryItemCount === 0 || columns >= 80);
|
||||
|
||||
// Hide the shift+tab hint when there are 2 primary items
|
||||
const shouldShowModeHint = primaryItemCount < 2
|
||||
const shouldShowModeHint = primaryItemCount < 2;
|
||||
|
||||
// Check if we have in-process teammates (showing pills)
|
||||
// In spinner-tree mode, pills are disabled - teammates appear in the spinner tree instead
|
||||
const hasInProcessTeammates =
|
||||
!showSpinnerTree &&
|
||||
hasBackgroundTasks &&
|
||||
Object.values(tasks).some(t => t.type === 'in_process_teammate')
|
||||
const hasTeammatePills =
|
||||
hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate)
|
||||
!showSpinnerTree && hasBackgroundTasks && Object.values(tasks).some(t => t.type === 'in_process_teammate');
|
||||
const hasTeammatePills = hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate);
|
||||
|
||||
// In remote mode (`claude assistant`, --teleport) the agent runs elsewhere;
|
||||
// the local permission mode shown here doesn't reflect the agent's state.
|
||||
|
|
@ -374,20 +326,15 @@ function ModeIndicator({
|
|||
const modePart =
|
||||
currentMode && hasActiveMode && !getIsRemoteMode() ? (
|
||||
<Text color={getModeColor(currentMode)} key="mode">
|
||||
{permissionModeSymbol(currentMode)}{' '}
|
||||
{permissionModeTitle(currentMode).toLowerCase()} on
|
||||
{permissionModeSymbol(currentMode)} {permissionModeTitle(currentMode).toLowerCase()} on
|
||||
{shouldShowModeHint && (
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
<KeyboardShortcutHint
|
||||
shortcut={modeCycleShortcut}
|
||||
action="cycle"
|
||||
parens
|
||||
/>
|
||||
<KeyboardShortcutHint shortcut={modeCycleShortcut} action="cycle" parens />
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
// Build parts array - exclude BackgroundTaskStatus when we have teammate pills
|
||||
// (teammate pills get their own row)
|
||||
|
|
@ -404,37 +351,32 @@ function ModeIndicator({
|
|||
// its click-target Box isn't nested inside the <Text wrap="truncate">
|
||||
// wrapper (reconciler throws on Box-in-Text).
|
||||
// Tmux pill (ant-only) — appears right after tasks in nav order
|
||||
...(process.env.USER_TYPE === 'ant' && hasTmuxSession
|
||||
? [<TungstenPill key="tmux" selected={tmuxSelected} />]
|
||||
: []),
|
||||
...(process.env.USER_TYPE === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []),
|
||||
...(isAgentSwarmsEnabled() && hasTeams
|
||||
? [
|
||||
<TeamStatus
|
||||
key="teams"
|
||||
teamsSelected={teamsSelected}
|
||||
showHint={showHint && !hasBackgroundTasks}
|
||||
/>,
|
||||
]
|
||||
? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />]
|
||||
: []),
|
||||
...(shouldShowPrStatus
|
||||
? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />]
|
||||
: []),
|
||||
// RSS memory indicator — always visible
|
||||
...(rssState
|
||||
? [
|
||||
<PrBadge
|
||||
key="pr-status"
|
||||
number={prStatus.number!}
|
||||
url={prStatus.url!}
|
||||
reviewState={prStatus.reviewState!}
|
||||
/>,
|
||||
<Text
|
||||
key="rss"
|
||||
dimColor={rssState.level === 'normal'}
|
||||
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
|
||||
>
|
||||
{rssState.text} · pid:{process.pid}
|
||||
</Text>,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
];
|
||||
|
||||
// Check if any in-process teammates exist (for hint text cycling)
|
||||
const hasAnyInProcessTeammates = Object.values(tasks).some(
|
||||
t => t.type === 'in_process_teammate' && t.status === 'running',
|
||||
)
|
||||
const hasRunningAgentTasks = Object.values(tasks).some(
|
||||
t => t.type === 'local_agent' && t.status === 'running',
|
||||
)
|
||||
);
|
||||
const hasRunningAgentTasks = Object.values(tasks).some(t => t.type === 'local_agent' && t.status === 'running');
|
||||
|
||||
// Get hint parts separately for potential second-line rendering
|
||||
const hintParts = showHint
|
||||
|
|
@ -449,32 +391,25 @@ function ModeIndicator({
|
|||
hasRunningAgentTasks,
|
||||
isKillAgentsConfirmShowing,
|
||||
)
|
||||
: []
|
||||
: [];
|
||||
|
||||
if (isViewingCompletedTeammate) {
|
||||
parts.push(
|
||||
<Text dimColor key="esc-return">
|
||||
<KeyboardShortcutHint
|
||||
shortcut={escShortcut}
|
||||
action="return to team lead"
|
||||
/>
|
||||
<KeyboardShortcutHint shortcut={escShortcut} action="return to team lead" />
|
||||
</Text>,
|
||||
)
|
||||
);
|
||||
} else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) {
|
||||
parts.push(<ProactiveCountdown key="proactive" />)
|
||||
parts.push(<ProactiveCountdown key="proactive" />);
|
||||
} else if (!hasTeammatePills && showHint) {
|
||||
parts.push(...hintParts)
|
||||
parts.push(...hintParts);
|
||||
}
|
||||
|
||||
// When we have teammate pills, always render them on their own line above other parts
|
||||
if (hasTeammatePills) {
|
||||
// Don't append spinner hints when viewing a completed teammate —
|
||||
// the "esc to return to team lead" hint already replaces "esc to interrupt"
|
||||
const otherParts = [
|
||||
...(modePart ? [modePart] : []),
|
||||
...parts,
|
||||
...(isViewingCompletedTeammate ? [] : hintParts),
|
||||
]
|
||||
const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)];
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
|
|
@ -492,21 +427,18 @@ function ModeIndicator({
|
|||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Add "↓ to manage tasks" hint when panel has visible rows
|
||||
const hasCoordinatorTasks =
|
||||
process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0
|
||||
const hasCoordinatorTasks = process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0;
|
||||
|
||||
// Tasks pill renders as a Box sibling (not a parts entry) so its
|
||||
// click-target Box isn't nested inside <Text wrap="truncate"> — the
|
||||
// reconciler throws on Box-in-Text. Computed here so the empty-checks
|
||||
// below still treat "pill present" as non-empty.
|
||||
const tasksPart =
|
||||
hasBackgroundTasks &&
|
||||
!hasTeammatePills &&
|
||||
!shouldHideTasksFooter(tasks, showSpinnerTree) ? (
|
||||
hasBackgroundTasks && !hasTeammatePills && !shouldHideTasksFooter(tasks, showSpinnerTree) ? (
|
||||
<BackgroundTaskStatus
|
||||
tasksSelected={tasksSelected}
|
||||
isViewingTeammate={isViewingTeammate}
|
||||
|
|
@ -514,27 +446,27 @@ function ModeIndicator({
|
|||
isLeaderIdle={!isLoading}
|
||||
onOpenDialog={onOpenTasksDialog}
|
||||
/>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
if (parts.length === 0 && !tasksPart && !modePart && showHint) {
|
||||
parts.push(
|
||||
<Text dimColor key="shortcuts-hint">
|
||||
? for shortcuts
|
||||
</Text>,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Only replace the idle voice hint when there's something to say — otherwise
|
||||
// fall through instead of showing an empty Byline. "esc to clear" was removed
|
||||
// (looked like "esc to interrupt" when idle; esc-clears-selection is standard
|
||||
// UX) leaving only ctrl+c (copyOnSelect off) and the xterm.js native-select hint.
|
||||
const copyOnSelect = getGlobalConfig().copyOnSelect ?? true
|
||||
const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs())
|
||||
const copyOnSelect = getGlobalConfig().copyOnSelect ?? true;
|
||||
const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs());
|
||||
|
||||
// Warmup hint takes priority — when the user is actively holding
|
||||
// the activation key, show feedback regardless of other hints.
|
||||
if (feature('VOICE_MODE') && voiceEnabled && voiceWarmingUp) {
|
||||
parts.push(<VoiceWarmupHint key="voice-warmup" />)
|
||||
parts.push(<VoiceWarmupHint key="voice-warmup" />);
|
||||
} else if (isFullscreenEnvEnabled() && selectionHintHasContent) {
|
||||
// xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is
|
||||
// platform-specific and gated on macOS (SelectionService.shouldForceSelection):
|
||||
|
|
@ -546,26 +478,21 @@ function ModeIndicator({
|
|||
// option+click hint they just tried.
|
||||
// Non-reactive getState() read is safe: lastPressHadAlt is immutable
|
||||
// while hasSelection is true (set pre-drag, cleared with selection).
|
||||
const isMac = getPlatform() === 'macos'
|
||||
const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false)
|
||||
const isMac = getPlatform() === 'macos';
|
||||
const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false);
|
||||
parts.push(
|
||||
<Text dimColor key="selection-copy">
|
||||
<Byline>
|
||||
{!copyOnSelect && (
|
||||
<KeyboardShortcutHint shortcut="ctrl+c" action="copy" />
|
||||
)}
|
||||
{!copyOnSelect && <KeyboardShortcutHint shortcut="ctrl+c" action="copy" />}
|
||||
{isXtermJs() &&
|
||||
(altClickFailed ? (
|
||||
<Text>set macOptionClickForcesSelection in VS Code settings</Text>
|
||||
) : (
|
||||
<KeyboardShortcutHint
|
||||
shortcut={isMac ? 'option+click' : 'shift+click'}
|
||||
action="native select"
|
||||
/>
|
||||
<KeyboardShortcutHint shortcut={isMac ? 'option+click' : 'shift+click'} action="native select" />
|
||||
))}
|
||||
</Byline>
|
||||
</Text>,
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
feature('VOICE_MODE') &&
|
||||
parts.length > 0 &&
|
||||
|
|
@ -579,7 +506,7 @@ function ModeIndicator({
|
|||
<Text dimColor key="voice-hint">
|
||||
hold {voiceKeyShortcut} to speak
|
||||
</Text>,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) {
|
||||
|
|
@ -591,7 +518,7 @@ function ModeIndicator({
|
|||
<KeyboardShortcutHint shortcut="↓" action="manage" />
|
||||
)}
|
||||
</Text>,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// In fullscreen the bottom section is flexShrink:0 — every row here
|
||||
|
|
@ -603,7 +530,7 @@ function ModeIndicator({
|
|||
// from 0→1 row. Always render 1 row in fullscreen; return a space when
|
||||
// empty so Yoga reserves the row without painting anything visible.
|
||||
if (parts.length === 0 && !tasksPart && !modePart) {
|
||||
return isFullscreenEnvEnabled() ? <Text> </Text> : null
|
||||
return isFullscreenEnvEnabled() ? <Text> </Text> : null;
|
||||
}
|
||||
|
||||
// flexShrink=0 keeps mode + pill at natural width; the remaining parts
|
||||
|
|
@ -628,7 +555,7 @@ function ModeIndicator({
|
|||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getSpinnerHintParts(
|
||||
|
|
@ -642,27 +569,27 @@ function getSpinnerHintParts(
|
|||
hasRunningAgentTasks: boolean,
|
||||
isKillAgentsConfirmShowing: boolean,
|
||||
): React.ReactElement[] {
|
||||
let toggleAction: string
|
||||
let toggleAction: string;
|
||||
if (hasTeammates) {
|
||||
// Cycling: none → tasks → teammates → none
|
||||
switch (expandedView) {
|
||||
case 'none':
|
||||
toggleAction = 'show tasks'
|
||||
break
|
||||
toggleAction = 'show tasks';
|
||||
break;
|
||||
case 'tasks':
|
||||
toggleAction = 'show teammates'
|
||||
break
|
||||
toggleAction = 'show teammates';
|
||||
break;
|
||||
case 'teammates':
|
||||
toggleAction = 'hide'
|
||||
break
|
||||
toggleAction = 'hide';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks'
|
||||
toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks';
|
||||
}
|
||||
|
||||
// Show the toggle hint only when there are task items to display or
|
||||
// teammates to cycle to
|
||||
const showToggleHint = hasTaskItems || hasTeammates
|
||||
const showToggleHint = hasTaskItems || hasTeammates;
|
||||
|
||||
return [
|
||||
...(isLoading
|
||||
|
|
@ -675,26 +602,20 @@ function getSpinnerHintParts(
|
|||
...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing
|
||||
? [
|
||||
<Text dimColor key="kill-agents">
|
||||
<KeyboardShortcutHint
|
||||
shortcut={killAgentsShortcut}
|
||||
action="stop agents"
|
||||
/>
|
||||
<KeyboardShortcutHint shortcut={killAgentsShortcut} action="stop agents" />
|
||||
</Text>,
|
||||
]
|
||||
: []),
|
||||
...(showToggleHint
|
||||
? [
|
||||
<Text dimColor key="toggle-tasks">
|
||||
<KeyboardShortcutHint
|
||||
shortcut={todosShortcut}
|
||||
action={toggleAction}
|
||||
/>
|
||||
<KeyboardShortcutHint shortcut={todosShortcut} action={toggleAction} />
|
||||
</Text>,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
function isPrStatusEnabled(): boolean {
|
||||
return getGlobalConfig().prStatusFooterEnabled ?? true
|
||||
return getGlobalConfig().prStatusFooterEnabled ?? true;
|
||||
}
|
||||
|
|
|
|||
255
src/query.ts
255
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user