mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
85 Commits
feature/la
...
v2.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100e9d2da0 | ||
|
|
a1108870e3 | ||
|
|
18d6656a6a | ||
|
|
d0915fc880 | ||
|
|
cf2bf29dcd | ||
|
|
75952bde9c | ||
|
|
e7220c530f | ||
|
|
6ff839d625 | ||
|
|
88057b10d4 | ||
|
|
4d0048a60a | ||
|
|
8a5ef8c9cb | ||
|
|
f8a289b868 | ||
|
|
45c892fc18 | ||
|
|
5b333e2246 | ||
|
|
5e215bb061 | ||
|
|
b28de717dd | ||
|
|
5c1be19511 | ||
|
|
2545dcabfd | ||
|
|
40fbc4afc4 | ||
|
|
d3eebfed15 | ||
|
|
6becb8b2d4 | ||
|
|
3a2b6dde7c | ||
|
|
4ca7a4895a | ||
|
|
ba74e0976c | ||
|
|
86df024e75 | ||
|
|
c3af45023d | ||
|
|
2847cab787 | ||
|
|
198c09b263 | ||
|
|
4cbf406c70 | ||
|
|
f72b867aa6 | ||
|
|
0290fe3227 | ||
|
|
1b10ea391a | ||
|
|
f724300079 | ||
|
|
3eba5ade1a | ||
|
|
385baf5737 | ||
|
|
0977b0520e | ||
|
|
96f1700e55 | ||
|
|
ef10ad2839 | ||
|
|
f484fc34c8 | ||
|
|
ab0bbbc4b5 | ||
|
|
a81995052f | ||
|
|
ff2074c798 | ||
|
|
491c16da25 | ||
|
|
9ea9859dce | ||
|
|
c32f26cf21 | ||
|
|
6182015005 | ||
|
|
d136872cc9 | ||
|
|
465c95ae53 | ||
|
|
42100d6268 | ||
|
|
ca29e4e8f7 | ||
|
|
cd8136f4b1 | ||
|
|
71c89e9de4 | ||
|
|
632f3e199e | ||
|
|
282d515043 | ||
|
|
00da5d7d1a | ||
|
|
08cd02cd37 | ||
|
|
7effbca8db | ||
|
|
edae3a7d37 | ||
|
|
7a6e65caf7 | ||
|
|
6b7cfda9b1 | ||
|
|
f8388e44ed | ||
|
|
189766c5af | ||
|
|
452a7e6a15 | ||
|
|
29a1edbf46 | ||
|
|
f2e9af4927 | ||
|
|
4f1649e249 | ||
|
|
a2cfaf9111 | ||
|
|
9e365f1ffa | ||
|
|
51b8ad46bf | ||
|
|
2bad8df5d7 | ||
|
|
327658979a | ||
|
|
7e61e71c54 | ||
|
|
4b97e6638e | ||
|
|
b8b48bf7ed | ||
|
|
de9dbcdcbb | ||
|
|
0a9e6c0313 | ||
|
|
73130bded3 | ||
|
|
1a1d57057e | ||
|
|
7f864a4743 | ||
|
|
c81dac8c3c | ||
|
|
4266149820 | ||
|
|
7cc1785fc0 | ||
|
|
c80e593212 | ||
|
|
b47731a3f3 | ||
|
|
a65df4a102 |
@@ -1,8 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -31,6 +31,9 @@ jobs:
|
||||
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint and format check
|
||||
run: bunx biome ci .
|
||||
|
||||
- name: Type check
|
||||
run: bun run typecheck
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,7 +5,8 @@ coverage
|
||||
.env
|
||||
*.log
|
||||
.idea
|
||||
.vscode
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.lock
|
||||
src/utils/vendor/
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
bun 1.3.13
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"ms-typescript.typescript",
|
||||
"oven.bun-vscode",
|
||||
"editorconfig.editorconfig"
|
||||
]
|
||||
}
|
||||
31
CLAUDE.md
31
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 — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bun run precheck` 必须零错误通过**(包含 typecheck + lint fix + test)。
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -47,10 +47,12 @@ 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)
|
||||
bun run lint # check only
|
||||
bun run lint:fix # auto-fix
|
||||
bun run format # format all src/
|
||||
# Lint & Format (Biome) — 日常开发用 precheck 代替单独调用
|
||||
bun run lint # lint check (全项目)
|
||||
bun run lint:fix # auto-fix lint issues
|
||||
bun run format # format all (全项目)
|
||||
bun run check # lint + format check (全项目)
|
||||
bun run check:fix # lint + format auto-fix
|
||||
|
||||
# Health check
|
||||
bun run health
|
||||
@@ -58,9 +60,8 @@ bun run health
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
bun run typecheck
|
||||
# Full check (typecheck + lint fix + test) — 任务完成后必须运行
|
||||
bun run precheck
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
@@ -82,9 +83,11 @@ bun run docs:dev
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
|
||||
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。
|
||||
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
@@ -308,7 +311,7 @@ mock.module("src/utils/debug.ts", debugMock);
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run precheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
@@ -321,14 +324,16 @@ bun run typecheck
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **precheck must pass** — `bun run precheck`(typecheck + lint fix + test)必须零错误,任何修改都不能引入新的类型/lint/测试错误。
|
||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
|
||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Biome 配置** — 42 条 lint 规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/`、`scripts/`、`packages/`,含 `packages/@ant/`)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐(2-space 缩进)。修改任何代码后应运行 `bun run precheck` 确认无类型/lint/格式/测试问题,pre-commit hook 会自动拦截不合格提交。
|
||||
- **tsc 与 Biome 冲突处理** — 当 tsc 要求声明属性(赋值使用)但 biome 报 `noUnusedPrivateClassMembers`(只写不读)时,用 `// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>` 抑制 lint 警告,保留类型声明。`biome ci` 必须零 warnings。
|
||||
- **`@ts-expect-error` 维护** — 只在下方代码确实有类型错误时保留 `@ts-expect-error`。如果类型系统已更新导致 directive 变为 unused(TS2578),直接移除注释。MACRO 替换产生的永假比较(如 `'production' === 'development'`)仍需保留 `@ts-expect-error`。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
|
||||
- 🚀 [想要启动项目](#快速开始源码版)
|
||||
- 🚀 [想要启动项目](#-快速开始源码版)
|
||||
- 🐛 [想要调试项目](#vs-code-调试)
|
||||
- 📖 [想要学习项目](#teach-me-学习项目)
|
||||
|
||||
@@ -55,6 +55,8 @@ ccb update # 更新到最新版本
|
||||
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
||||
```
|
||||
|
||||
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
|
||||
|
||||
## ⚡ 快速开始(源码版)
|
||||
|
||||
### ⚙️ 环境要求
|
||||
|
||||
223
biome.json
223
biome.json
@@ -1,114 +1,113 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist", "!!**/packages/@ant"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noRedeclare": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noGlobalIsNan": "off",
|
||||
"noFallthroughSwitchClause": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noConsole": "off",
|
||||
"noConfusingLabels": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noUnusedTemplateLiteral": "off",
|
||||
"useTemplate": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noBannedTypes": "off",
|
||||
"noUselessConstructor": "off",
|
||||
"noStaticOnlyClass": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noUselessFragments": "off",
|
||||
"noUselessTernary": "off",
|
||||
"noUselessLoneBlockStatements": "off",
|
||||
"noUselessEmptyExport": "off",
|
||||
"useArrowFunction": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedImports": "off",
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnreachable": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"noVoidTypeReturn": "off",
|
||||
"noConstantCondition": "off",
|
||||
"noUnusedFunctionParameters": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": false
|
||||
},
|
||||
"nursery": {
|
||||
"recommended": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.tsx"],
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["scripts/**", "packages/**", "**/*.js", "**/*.mjs", "**/*.jsx"],
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noRedeclare": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noGlobalIsNan": "off",
|
||||
"noFallthroughSwitchClause": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noConsole": "off",
|
||||
"noConfusingLabels": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noUnusedTemplateLiteral": "off",
|
||||
"useTemplate": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noBannedTypes": "off",
|
||||
"noUselessConstructor": "off",
|
||||
"noStaticOnlyClass": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noUselessFragments": "off",
|
||||
"noUselessTernary": "off",
|
||||
"noUselessLoneBlockStatements": "off",
|
||||
"noUselessEmptyExport": "off",
|
||||
"useArrowFunction": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedImports": "off",
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnreachable": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"noVoidTypeReturn": "off",
|
||||
"noConstantCondition": "off",
|
||||
"noUnusedFunctionParameters": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": false
|
||||
},
|
||||
"nursery": {
|
||||
"recommended": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.tsx"],
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
|
||||
3
build.ts
3
build.ts
@@ -56,7 +56,8 @@ for (const file of files) {
|
||||
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
|
||||
let bunPatched = 0
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.js')) continue
|
||||
const filePath = join(outdir, file)
|
||||
|
||||
44
bun.lock
44
bun.lock
@@ -103,11 +103,13 @@
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
"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.4.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"marked": "^17.0.6",
|
||||
@@ -1538,6 +1540,8 @@
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
@@ -1632,8 +1636,12 @@
|
||||
|
||||
"cli-boxes": ["cli-boxes@4.0.1", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-highlight": ["cli-highlight@2.1.11", "https://registry.npmmirror.com/cli-highlight/-/cli-highlight-2.1.11.tgz", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
||||
|
||||
"cli-width": ["cli-width@4.1.0", "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||
|
||||
"cliui": ["cliui@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
|
||||
@@ -1820,6 +1828,8 @@
|
||||
|
||||
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
@@ -1840,6 +1850,8 @@
|
||||
|
||||
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
@@ -2014,6 +2026,8 @@
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
@@ -2140,6 +2154,10 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="],
|
||||
|
||||
"listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
@@ -2162,6 +2180,8 @@
|
||||
|
||||
"lodash.once": ["lodash.once@4.1.1", "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||
|
||||
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||
|
||||
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
@@ -2292,6 +2312,8 @@
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -2540,10 +2562,14 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||
@@ -2604,6 +2630,8 @@
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.4", "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
|
||||
"smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
@@ -2622,6 +2650,8 @@
|
||||
|
||||
"streamdown": ["streamdown@1.6.11", "https://registry.npmmirror.com/streamdown/-/streamdown-1.6.11.tgz", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="],
|
||||
|
||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||
|
||||
"string-width": ["string-width@8.2.0", "https://registry.npmmirror.com/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
@@ -3280,6 +3310,14 @@
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"lint-staged/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"listr2/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
@@ -3310,6 +3348,8 @@
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||
@@ -3564,6 +3604,10 @@
|
||||
|
||||
"is-admin/execa/strip-final-newline": ["strip-final-newline@2.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"listr2/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"qrcode/yargs/cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"qrcode/yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -185,4 +185,4 @@
|
||||
"destination": "/docs/introduction/what-is-claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
492
docs/agent/sur-loop-scheduled-oom.md
Normal file
492
docs/agent/sur-loop-scheduled-oom.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# System Understanding Report — Loop / Scheduled Autonomy OOM
|
||||
|
||||
- **Flow id**: `recurring-bug-loop-oom` (pilot flow for autonomy ↔ deep-debug binding)
|
||||
- **Branch**: `fix/loop-scheduled-autonomy-oom`
|
||||
- **Worktree**: `E:\Source_code\Claude-code-bast-loop-scheduled-oom-fix`
|
||||
- **Author**: back-filled from existing working-tree diff (no commits ahead of `main`)
|
||||
- **Status**: `report` (this document) — pending human approval before `regression-test` advances
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
### Symptom
|
||||
|
||||
Long-running sessions with active scheduled tasks (cron) and/or HEARTBEAT-driven proactive ticks accumulated growing memory, eventually OOM'ing the Bun process. The visible signature was:
|
||||
|
||||
- `runs.json` under `.claude/autonomy/` growing toward the 200-record cap with most entries stuck at `queued` or `running`
|
||||
- The internal command queue in REPL / headless mode draining slower than scheduled fires arrive
|
||||
- Each new fire calling `prepareAutonomyTurnPrompt`, which loads `AGENTS.md` + `HEARTBEAT.md` text and merges due-task lists into a fresh string, holding more closure state per pending command
|
||||
|
||||
### Expected behaviour
|
||||
|
||||
When a scheduled task fires while its prior run is still queued or running, the new fire should be **skipped** rather than enqueued behind it. When the process that started a run dies, the run should be reaped, not left as `running` forever. Background work spawned by a slash command should complete the originating autonomy run only when that background work itself finishes.
|
||||
|
||||
### Actual behaviour (before fix)
|
||||
|
||||
1. `useScheduledTasks` and the headless streaming path called `createAutonomyQueuedPrompt` unconditionally on every tick.
|
||||
2. `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn` *before* the run record was persisted, so even a duplicate fire that should have been dropped already mutated heartbeat-task last-run state.
|
||||
3. `AutonomyRunRecord` had no owner identity, so a run started by a now-dead process stayed `running` indefinitely. Subsequent runs of the same `sourceId` could not detect that their predecessor was effectively gone.
|
||||
4. Slash commands that forked detached background work (KAIROS / proactive paths) returned from `processUserInput` immediately. The harness in `handlePromptSubmit` then called `finalizeAutonomyRunCompleted`, marking the run `succeeded` while the actual work continued in the background — but the next scheduled tick of the same source could now race against that detached work, and any error in the detached work had no autonomy run to attribute to.
|
||||
|
||||
### Reproduction shape
|
||||
|
||||
Not a single deterministic repro — load-induced. Rough recipe:
|
||||
|
||||
- Configure two `HEARTBEAT.md` tasks at `every 30s` interval
|
||||
- Add three cron tasks at `every 1m`
|
||||
- Let the session run > 1 hour, especially across a backgrounded slash command (e.g. KAIROS `/sleep`-style detached fork)
|
||||
- Watch `.claude/autonomy/runs.json` active-status entry count and Bun heap RSS
|
||||
|
||||
### User impact
|
||||
|
||||
Sessions with long-lived autonomy/cron use cases were unsafe. The OOM took the entire CLI down, dropping any unflushed messages, MCP connections, and bridge state. Because `.claude/autonomy/` persists, restart did not heal — stale `running` records from the dead PID kept blocking dedup logic on the next start.
|
||||
|
||||
---
|
||||
|
||||
## 2. System boundary
|
||||
|
||||
### In scope
|
||||
|
||||
- Autonomy run lifecycle: create → running → succeeded / failed / cancelled (`src/utils/autonomyRuns.ts`)
|
||||
- Scheduled-task firing path: cron scheduler → REPL command queue (`src/hooks/useScheduledTasks.ts`)
|
||||
- Headless streaming variant of the same path (`src/cli/print.ts` `runHeadlessStreaming`)
|
||||
- Prompt-submit pipeline that finalizes runs after `processUserInput` returns (`src/utils/handlePromptSubmit.ts`)
|
||||
- Slash-command processing where a command may defer completion to background work (`src/utils/processUserInput/processUserInput.ts`, `processSlashCommand.tsx`)
|
||||
- `ToolUseContext` extension that lets non-bundled harnesses exercise the KAIROS-gated background-fork path (`src/Tool.ts`)
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The cron scheduler itself (`src/utils/cronScheduler.ts`) — its tick semantics are not changing
|
||||
- `autonomyFlows.ts` flow state machine — separate from per-run tracking
|
||||
- HEARTBEAT.md scheduling semantics — unchanged. `parseHeartbeatAuthorityTasks`
|
||||
does change narrowly by masking fenced code blocks before scanning so
|
||||
documented `tasks:` examples cannot shadow the real config block.
|
||||
- `prepareAutonomyTurnPrompt` content shape — only its call ordering relative to run creation changes
|
||||
- Any provider-level behaviour (`services/api/**`) — not touched
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `process.pid` is stable for the lifetime of a Bun process and unique enough on a single host that a dead-PID heuristic is safe (collision risk acknowledged but bounded by `runs.json` retention).
|
||||
- `isProcessRunning(pid)` (from `genericProcessUtils.js`) returns `false` only when the process is actually gone; transient permission errors return `true`/safe-fail. Verified in step 6.
|
||||
- `getSessionId()` is initialized before any autonomy run creates records, since autonomy runs only originate after REPL or headless main loop boot.
|
||||
|
||||
---
|
||||
|
||||
## 3. Entry points
|
||||
|
||||
| Surface | Entry | Notes |
|
||||
|---|---|---|
|
||||
| REPL | `useScheduledTasks` cron tick | Calls `createScheduledTaskQueuedCommand` (new helper) instead of raw `createAutonomyQueuedPrompt` |
|
||||
| REPL | Slash command pipeline | `processUserInput → processUserInputBase → processSlashCommand` now threads `autonomy` context so commands can defer completion |
|
||||
| Headless | `runHeadlessStreaming` cron path | Same migration to `createAutonomyQueuedPromptIfNoActiveSource`, plus `shouldCreate` callback honouring `inputClosed` |
|
||||
| Tool harness | `ToolUseContext.options.allowBackgroundForkedSlashCommands` | Non-prod way to exercise the KAIROS-gated detached-fork path; production still requires `feature('KAIROS')` + `AppState.kairosEnabled` |
|
||||
| Persistence | `.claude/autonomy/runs.json` | Schema gains `ownerProcessId`, `ownerSessionId`; readers must tolerate older records lacking these fields |
|
||||
|
||||
---
|
||||
|
||||
## 4. Key files
|
||||
|
||||
| File | Lines changed | Why it matters |
|
||||
|---|---|---|
|
||||
| `src/utils/autonomyRuns.ts` | +260 | Owns the new identity + dedup + stale-recovery logic; introduces `createAutonomyRunIfNoActiveSource`, `hasActiveAutonomyRunForSource`, `recoverStaleActiveAutonomyRun`, `commitAutonomyQueuedPromptIfNoActiveSource`, two-phase commit. The structural heart of the fix. |
|
||||
| `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | Rewrites slash-command dispatch so detached background work signals `deferAutonomyCompletion`; refactor changes shape but not the public command set. |
|
||||
| `src/hooks/useScheduledTasks.ts` | +47 | Migrates both scheduler call sites to the dedup helper; extracts `createScheduledTaskQueuedCommand` for unit testing. |
|
||||
| `src/cli/print.ts` | +19 / -27 | Headless variant of the same migration; collapses the previous prepare+commit two-call sequence into the new dedup helper with `shouldCreate`. |
|
||||
| `src/utils/handlePromptSubmit.ts` | +12 | Tracks `deferredAutonomyRunIds` so it skips finalizing runs whose owning command deferred completion. |
|
||||
| `src/utils/processUserInput/processUserInput.ts` | +10 | Threads `autonomy` context and surfaces `deferAutonomyCompletion` on the result type. |
|
||||
| `src/Tool.ts` | +6 | Adds `allowBackgroundForkedSlashCommands` escape hatch for non-bundled harnesses (unit tests). |
|
||||
| `src/utils/__tests__/autonomyRuns.test.ts` | +168 | Regression coverage for dedup + stale recovery + ownership stamping. |
|
||||
| `src/hooks/__tests__/useScheduledTasks.test.ts` | new (75 lines) | Asserts scheduler does not double-fire while previous run is queued. |
|
||||
| `src/utils/processUserInput/__tests__/processSlashCommand.test.ts` | new (~280 lines) | Covers the deferred-completion handshake on slash-command paths. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Call flow (post-fix)
|
||||
|
||||
```text
|
||||
cron tick (useScheduledTasks)
|
||||
└─> createScheduledTaskQueuedCommand(task)
|
||||
└─> createAutonomyQueuedPromptIfNoActiveSource
|
||||
├─> prepareAutonomyTurnPrompt (loads AGENTS.md + HEARTBEAT.md)
|
||||
├─> shouldCreate? ──► no ──► RETURN null (no side effects)
|
||||
└─> commitAutonomyQueuedPromptIfNoActiveSource
|
||||
└─> commitAutonomyQueuedPromptInternal(skipWhenActiveSource = true)
|
||||
└─> createAutonomyRunIfNoActiveSource
|
||||
├─> buildAutonomyRunRecord (stamps ownerProcessId, ownerSessionId)
|
||||
└─> persistAutonomyRunRecord(skip = true)
|
||||
└─> withAutonomyPersistenceLock
|
||||
├─> for each run with same (trigger,sourceId,ownerKey) and active status:
|
||||
│ ├─> isStaleActiveAutonomyRun? ──► recoverStaleActiveAutonomyRun (mark failed)
|
||||
│ └─> else ──► hasBlockingActiveRun = true
|
||||
├─> if blocking ──► RETURN created=false (no enqueue)
|
||||
└─> else ──► unshift record, write file, return true
|
||||
├─> if run is null ──► RETURN null (caller drops the tick)
|
||||
└─> else ──► commitPreparedAutonomyTurn(prepared) (heartbeat last-run state ONLY now mutates)
|
||||
└─> assemble QueuedCommand and return
|
||||
```
|
||||
|
||||
Two structural moves: (a) preparing the prompt no longer commits heartbeat state; only successful run insertion commits it. (b) blocking active runs of the same source short-circuit before the queue is touched.
|
||||
|
||||
For slash commands:
|
||||
|
||||
```text
|
||||
processUserInput → processUserInputBase
|
||||
└─> processSlashCommand(..., autonomy = cmd.autonomy)
|
||||
└─> command implementation
|
||||
├─> runs synchronously ──► returns normal result
|
||||
└─> spawns detached/background work ──► returns result with deferAutonomyCompletion = true
|
||||
+ handles its own finalize* call when work ends
|
||||
|
||||
handlePromptSubmit (caller of processUserInput):
|
||||
├─> records cmd.autonomy.runId in autonomyRunIds
|
||||
├─> on result with deferAutonomyCompletion=true: adds runId to deferredAutonomyRunIds
|
||||
└─> finalize loop: skips deferred ids in BOTH success and error branches
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data flow
|
||||
|
||||
### `runs.json` record schema (delta)
|
||||
|
||||
```ts
|
||||
type AutonomyRunRecord = {
|
||||
// existing
|
||||
runId: string
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
trigger: AutonomyTriggerKind
|
||||
sourceId?: string
|
||||
ownerKey?: string
|
||||
// new
|
||||
ownerProcessId?: number // process.pid at create time and at markRunning time
|
||||
ownerSessionId?: string // getSessionId() at the same points
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Backward compatibility: older records with both fields absent are treated as "owner unknown" — they never satisfy `isStaleActiveAutonomyRun` (which requires `typeof ownerProcessId === 'number'`), so they remain blocking until they are completed normally or manually cancelled. This is intentional: we cannot prove they are stale.
|
||||
|
||||
### Stale-recovery rule
|
||||
|
||||
```text
|
||||
isStaleActiveAutonomyRun(run) ⇔
|
||||
run.status ∈ {queued, running}
|
||||
∧ typeof run.ownerProcessId === 'number'
|
||||
∧ !isProcessRunning(run.ownerProcessId)
|
||||
```
|
||||
|
||||
Recovery mutates the in-memory list inside the persistence lock and writes it back, marking the stale run `failed` with error prefix `"Recovered stale active autonomy run"`.
|
||||
|
||||
### Heartbeat last-run state mutation point
|
||||
|
||||
Before fix: `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn(prepared)` *first*, then created the run. A skipped duplicate already advanced heartbeat last-run timestamps.
|
||||
|
||||
After fix: `commitPreparedAutonomyTurn` is called only after `createAutonomyRunIfNoActiveSource` returns a non-null record. Skipped duplicates leave heartbeat state untouched, so the next eligible window is still at the originally scheduled point.
|
||||
|
||||
---
|
||||
|
||||
## 7. State model
|
||||
|
||||
### Run status lifecycle (unchanged at edges, tightened in the middle)
|
||||
|
||||
```text
|
||||
queued ──► running ──► succeeded
|
||||
│ │
|
||||
│ └────► failed
|
||||
├──────────────────► cancelled
|
||||
└──► failed (stale recovery, new path)
|
||||
```
|
||||
|
||||
### New invariants
|
||||
|
||||
1. **Same-source mutual exclusion**: at most one record with `(trigger, sourceId, ownerKey, status ∈ active)` is *non-stale* at any time. Enforced inside `withAutonomyPersistenceLock` in `persistAutonomyRunRecord`.
|
||||
|
||||
2. **Owner stamping at active transitions**: any path that sets a run to `queued` or `running` must stamp `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()`. `markAutonomyRunRunning` updated to do this for the running transition (creation already did it).
|
||||
|
||||
3. **Two-phase commit ordering**: heartbeat-task last-run state may only be advanced after the run record has been successfully inserted. Equivalent to "prompt commit ⇒ run row exists".
|
||||
|
||||
4. **Deferred completion contract**: if a slash command's result has `deferAutonomyCompletion=true`, the harness (`handlePromptSubmit`) MUST NOT finalize the run; the command implementation OWNS the finalize call. Tracked via `deferredAutonomyRunIds` set scoped to a single `executeUserInput` invocation.
|
||||
|
||||
### Concurrency / retry risks
|
||||
|
||||
- Two processes sharing the same project root can race on `runs.json`. Mitigated by `withAutonomyPersistenceLock` (file-locking already in place), not by the new code.
|
||||
- Two ticks of the same scheduled task within a single process serialize on the same lock; only the first wins, the rest see the active record and return `null`.
|
||||
- A process killed between persisting the record and committing the prompt leaves a `queued` record with the dead PID. Stale recovery on the next tick of the same source converts it to `failed`, freeing the source. This is the new safety net.
|
||||
|
||||
### Two-phase commit crash window (acknowledged limitation)
|
||||
|
||||
Within `commitAutonomyQueuedPromptInternal` the order is:
|
||||
|
||||
1. `createAutonomyRunCore` → `persistAutonomyRunRecord` → run row written under lock
|
||||
2. `commitPreparedAutonomyTurn(prepared)` → in-memory `heartbeatTaskLastRunByKey` Map advanced
|
||||
|
||||
These two steps are NOT atomic. If the process is killed between (1) and (2):
|
||||
|
||||
- `runs.json` has a fresh `queued` record stamped with the now-dead PID.
|
||||
- `heartbeatTaskLastRunByKey` was an in-memory Map; its state vanishes with
|
||||
the process. On restart the Map is empty.
|
||||
- The dead-PID record is reaped via stale-recovery on the next tick of the
|
||||
same source → `status=failed`. New record can be created.
|
||||
- Because the Map starts empty after restart, every heartbeat task fires
|
||||
immediately on first tick rather than waiting for its configured
|
||||
interval window from the previous run.
|
||||
|
||||
**Severity**: low. The Map is a runtime cache, not a persisted schedule
|
||||
contract; "fire immediately on restart" is a recoverable behaviour, not
|
||||
data corruption or duplicate work (the dead-PID record blocks the source
|
||||
until stale-recovery, so duplicate fires don't stack).
|
||||
|
||||
**Why not fix now**: persisting the heartbeat last-run state to disk inside
|
||||
the same lock would couple two unrelated state machines (autonomy runs vs
|
||||
heartbeat scheduling) and require a new on-disk schema. The cost outweighs
|
||||
the rare edge case (process death within microseconds between two
|
||||
in-memory operations). Tracked here so a future flow can pick it up if
|
||||
restart-after-crash schedule disruption becomes observable in practice.
|
||||
|
||||
---
|
||||
|
||||
## 8. Existing tests
|
||||
|
||||
### Pre-fix
|
||||
|
||||
- `src/utils/__tests__/autonomyRuns.test.ts` covered create / list / mark transitions for the basic happy path.
|
||||
- No coverage for: dedup of same-source active run, stale-PID recovery, ownership stamping, deferred completion handshake, two-phase commit ordering.
|
||||
- `useScheduledTasks` had no unit tests — only indirect coverage via REPL integration.
|
||||
- `processSlashCommand` had no autonomy-context coverage.
|
||||
|
||||
### Added in this branch
|
||||
|
||||
- `src/utils/__tests__/autonomyRuns.test.ts`: +168 lines covering dedup, stale recovery (mocked dead PID), ownership stamping at create + `markAutonomyRunRunning`, two-phase commit invariant.
|
||||
- `src/hooks/__tests__/useScheduledTasks.test.ts`: new file, 75 lines. Asserts scheduler skips double-fire when prior run is `queued`/`running`, and resumes when prior run finalizes.
|
||||
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`: new file, ~280 lines. Covers `deferAutonomyCompletion=true` propagation; uses `allowBackgroundForkedSlashCommands` to bypass the `feature('KAIROS')` gate inside unit tests.
|
||||
|
||||
### Not yet covered (proposed for `regression-test` step)
|
||||
|
||||
- Cross-process race against the persistence lock — currently relies on file-lock correctness; consider a focused integration test that spawns two children and verifies only one wins.
|
||||
- Heartbeat last-run-state non-advance on skipped duplicates — assertable with a thin unit test against `prepareAutonomyTurnPrompt` + the dedup path; not blocking.
|
||||
|
||||
---
|
||||
|
||||
## 9. Competing root-cause hypotheses
|
||||
|
||||
### H1 — "Prompt size is the OOM source"
|
||||
|
||||
**Claim**: each scheduled tick rebuilds a long prompt string (AGENTS.md + HEARTBEAT.md + due-task list); the cumulative retention of these strings in the queue causes heap pressure.
|
||||
|
||||
**Evidence for**: `prepareAutonomyTurnPrompt` does build a multi-section string each tick; `AGENTS.md` in this repo is now 220 lines.
|
||||
|
||||
**Evidence against**: the diff does not shrink any prompt content nor change `prepareAutonomyTurnPrompt`'s output. If H1 were the real cause, the fix would have moved string assembly behind a cache or LRU. The fix instead targets the *number* of in-flight runs.
|
||||
|
||||
**Verdict**: contributing factor at most. Rejected as primary root cause.
|
||||
|
||||
### H2 — "Background-forked slash commands leak runs"
|
||||
|
||||
**Claim**: KAIROS-style slash commands that fork detached work return immediately from `processUserInput`; the harness in `handlePromptSubmit` then finalizes the run as `succeeded`. Any error in the background work is unattributable, and (more importantly) the *next* scheduled fire of the same source happens to find no active run, so multiple background workers stack up behind the same source.
|
||||
|
||||
**Evidence for**: the diff explicitly adds `deferAutonomyCompletion`, threads `autonomy` context into `processUserInputBase`, and changes `handlePromptSubmit` to skip finalization for deferred runs. New test file `processSlashCommand.test.ts` is dedicated to this exact handshake.
|
||||
|
||||
**Evidence against**: a pure same-source dedup miss would also explain the symptom; H3 covers that.
|
||||
|
||||
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
|
||||
|
||||
### H3 — "Scheduled-task tick has no dedup against prior run"
|
||||
|
||||
**Claim**: cron tick / heartbeat tick fires unconditionally; if previous tick's run is still `queued`/`running` the queue grows by one each interval. Compounded across multiple sources, queue + `runs.json` active subset never shrink.
|
||||
|
||||
**Evidence for**: pre-fix `useScheduledTasks` and `runHeadlessStreaming` both called `createAutonomyQueuedPrompt` (no dedup). Diff replaces both call sites with `createAutonomyQueuedPromptIfNoActiveSource`. Persistence-side dedup added in the same change.
|
||||
|
||||
**Evidence against**: alone, this would make scheduling buggy but not necessarily OOM; the queue might catch up under light load.
|
||||
|
||||
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
|
||||
|
||||
### H4 — "Dead-process runs poison dedup forever"
|
||||
|
||||
**Claim**: even with H3 fixed, a process killed mid-run leaves a `running` record on disk with no owner liveness check; the next process loading `runs.json` would treat it as blocking and never schedule that source again.
|
||||
|
||||
**Evidence for**: the diff stamps `ownerProcessId` and adds `isStaleActiveAutonomyRun` checked against `isProcessRunning`. Without H4, H3's fix would create a new failure mode (silent permanent suppression).
|
||||
|
||||
**Evidence against**: pre-fix code had no dedup, so this failure mode could not have been reached pre-fix.
|
||||
|
||||
**Verdict**: real, but secondary. It exists because H3's fix introduces it. Required to ship together.
|
||||
|
||||
---
|
||||
|
||||
## 10. Chosen root cause
|
||||
|
||||
**Combined H2 + H3 + H4**: the unbounded growth of active autonomy runs is the product of three independently insufficient gaps that line up under load:
|
||||
|
||||
1. Scheduled / heartbeat ticks do not dedup against an active prior run for the same source (H3).
|
||||
2. Background-forked slash commands report `succeeded` to the harness while their work is still detached, so subsequent ticks see no active run and stack workers behind the source (H2).
|
||||
3. Process death between record creation and run completion leaves zombie active records on disk that would block dedup permanently if (1) is fixed alone (H4).
|
||||
|
||||
Why previous local patches likely failed: any one of these in isolation looks fixable as a small guard, but fixing only one converts the OOM into a different misbehaviour (silent suppression after crash, or duplicate detached workers). The minimal correct fix needs all three primitives: **same-source dedup**, **owner stamping + stale recovery**, **deferred-completion handshake**, plus the **two-phase commit ordering** that ensures heartbeat state never advances on a skipped duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 11. Fix plan
|
||||
|
||||
### Minimal fix surface
|
||||
|
||||
| Module | Change | Reason |
|
||||
|---|---|---|
|
||||
| `autonomyRuns.ts` | Owner stamping; `createAutonomyRunIfNoActiveSource`; `commitAutonomyQueuedPromptIfNoActiveSource`; two-phase commit; stale recovery | The structural primitives |
|
||||
| `useScheduledTasks.ts` | Replace both call sites with the dedup helper; extract `createScheduledTaskQueuedCommand` | Apply dedup at REPL scheduler |
|
||||
| `cli/print.ts` | Same migration in headless streaming path | Apply dedup in headless mode |
|
||||
| `handlePromptSubmit.ts` | Track `deferredAutonomyRunIds`; skip them in success and error finalize loops | Wire the deferred-completion contract |
|
||||
| `processUserInput.ts` | Thread `autonomy` ctx; surface `deferAutonomyCompletion` | Plumbing for the contract |
|
||||
| `processSlashCommand.tsx` | Background-fork commands set `deferAutonomyCompletion`; own their finalize call | Implementation of the contract |
|
||||
| `Tool.ts` | `allowBackgroundForkedSlashCommands` flag on `ToolUseContext.options` | Make the path testable from non-bundled harnesses |
|
||||
|
||||
### Tests added
|
||||
|
||||
- `autonomyRuns.test.ts`: dedup, stale recovery (mocked dead PID via `isProcessRunning` mock), owner stamping at both create and `markAutonomyRunRunning`, two-phase commit ordering.
|
||||
- `useScheduledTasks.test.ts`: scheduler skips double-fire, resumes after finalize.
|
||||
- `processSlashCommand.test.ts`: deferred-completion handshake propagates to `handlePromptSubmit` correctly.
|
||||
|
||||
### Compatibility / migration risk
|
||||
|
||||
- Older `runs.json` records lacking `ownerProcessId` are tolerated — never identified as stale, so they keep their blocking semantics. Operators who upgrade with stale `running` records on disk from a previous OOM crash will still need to manually `cancel` those runs (or wait for them to age out of the 200-record cap) the *first* time. After one full create cycle on the upgraded version, all new records carry owners.
|
||||
- **Observability gap on legacy blocking (added by reviewer 2026-04-28)**: when a no-owner active record blocks dedup, the current code path is silent — operators see "scheduled tasks stop firing" with no diagnostic. `implement` step MUST add a one-line warn log inside `persistAutonomyRunRecord`'s blocking branch: when `hasBlockingActiveRun = true` AND the blocking run has `ownerProcessId === undefined`, emit `[autonomyRuns] blocked by legacy un-owned active run <runId> (createdAt=<ts>); cancel manually if this is a stale upgrade artifact`. ≤ 10 lines of code, converts silent hang into a diagnosable signal. Do **not** change behavior — just observability.
|
||||
- `ToolUseContext.options.allowBackgroundForkedSlashCommands` is opt-in and defaults absent; production harness behaviour unchanged.
|
||||
- No on-disk schema version bump required.
|
||||
|
||||
### Rollback plan
|
||||
|
||||
- Revert the working tree to `main`'s versions of all 8 files. The `runs.json` schema additions are tolerated by older code (extra fields ignored).
|
||||
- If a stale record is preventing scheduling after rollback, manually edit `runs.json` (status → `cancelled`) or run `/autonomy flow cancel` for affected flows.
|
||||
- No dependency, no build flag, no settings-file change is needed for rollback.
|
||||
|
||||
### Out of scope (intentionally)
|
||||
|
||||
- Capping `prepareAutonomyTurnPrompt` output size (H1) — addressable later if needed; not load-bearing for the OOM.
|
||||
- Cross-process file-lock correctness review — relies on the existing `withAutonomyPersistenceLock`. Out of scope for this flow.
|
||||
- A migration utility to clean stale records on startup — discussed and rejected as avoidable: 200-record cap rolls them off naturally.
|
||||
|
||||
---
|
||||
|
||||
## 12. Verification
|
||||
|
||||
### Commands (binding per `.claude/autonomy/AGENTS.md` §4)
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/utils/__tests__/autonomyRuns.test.ts
|
||||
bun test src/hooks/__tests__/useScheduledTasks.test.ts
|
||||
bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts
|
||||
bun test # full unit suite
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Manual checks (proposed for `implement` step)
|
||||
|
||||
- Start a session with two `HEARTBEAT.md` 30s tasks for ≥ 30 minutes; observe `runs.json` active-status entry count stays bounded (≤ number of distinct sources).
|
||||
- Force-kill the Bun process during a `running` record. Restart. Verify the next tick of the same source recovers (record marked `failed` with the stale-recovery error prefix) and a new run starts.
|
||||
- Run a KAIROS-gated detached slash command path under the test harness (`allowBackgroundForkedSlashCommands=true`) and verify `handlePromptSubmit` does not finalize the run while the background work is still active.
|
||||
|
||||
### Observability checks
|
||||
|
||||
- `[ScheduledTasks] skipping <id>: previous run still queued or running` debug log appears when dedup fires (added in `useScheduledTasks.ts`). Use it to confirm dedup is reached in real sessions.
|
||||
- `runs.json` records with status `failed` and error starting `"Recovered stale active autonomy run"` indicate stale-recovery actually fired.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. ~~Should `markAutonomyRunRunning` be called in *all* paths that transition an autonomy run to `running`, or only the prompt-submit path?~~ **Closed (verified 2026-04-28).**
|
||||
`markAutonomyRunRunning` (`autonomyRuns.ts:554-579`) is the **only** function that transitions `AutonomyRunRecord.status → 'running'`. It stamps `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()` unconditionally, then internally calls `markManagedAutonomyFlowStepRunning` to mirror to flow state. `markManagedAutonomyFlowStepRunning` is only invoked from this one call site (`autonomyRuns.ts:571`); no caller bypasses the stamp. All four real callers (`cli/print.ts:2177`, `screens/REPL.tsx:4859`, `utils/handlePromptSubmit.ts:492`, `utils/swarm/inProcessRunner.ts:741`) go through the stamping path. Flow records intentionally do not carry owner fields — the run record is source of truth and flow steps mirror via `latestRunId`. Stale-recovery operates on runs, so flow-step runs are covered.
|
||||
2. ~~`getSessionId()` import was added to `autonomyRuns.ts`. Confirm no circular import is introduced...~~ **Closed (verified 2026-04-28).**
|
||||
No risk on three counts: (a) `autonomyRuns.ts:4` already imported `getProjectRoot` from `bootstrap/state.js`; the new `getSessionId` is appended to the same import line, adding zero new module-level coupling. (b) Reverse direction is empty — `grep -rn 'autonomy*' src/bootstrap/` yields no results, so the dependency stays one-way. (c) `getSessionId()` (`bootstrap/state.ts:425-427`) returns `STATE.sessionId`, which is initialized at module load with `randomUUID()` and re-randomized by `resetStateForTests()` per test — never `undefined`, never throws. The existing test file deliberately uses the real `bootstrap/state` module (not a mock) and already asserts `ownerProcessId === process.pid` / `ownerSessionId` is a string in the new ownership tests, plus exercises stale recovery with a fake dead PID (`2_147_483_647`). No mock updates needed.
|
||||
3. Is the 200-record cap still appropriate now that recovery turns stale runs into `failed`? Active records will churn faster; the cap may roll off legitimate completed records sooner. Not a correctness issue, but worth noting.
|
||||
|
||||
---
|
||||
|
||||
## 14. Approval gate
|
||||
|
||||
This SUR satisfies `AGENTS.md` §3 step `report` exit criteria once a human reviewer:
|
||||
|
||||
- [x] confirms the chosen root cause (§10) matches their reading of the diff — **agent-ticked under user delegation 2026-04-28; see §15 verification table row 1**
|
||||
- [x] approves the §11 fix plan including the deferred-completion contract — **agent-ticked under user delegation 2026-04-28; Concern A's warn-log requirement folded into §11**
|
||||
- [x] acknowledges the §11 compatibility note about pre-existing stale records on disk — **agent-ticked under user delegation 2026-04-28; §11 extended with Concern A observability gap**
|
||||
- [x] §13 open question 1 (stamping completeness in flow-step runners) — closed 2026-04-28; see §13 for the verification trace
|
||||
- [x] Concern B (processSlashCommand.tsx >50% diff) — **resolved 2026-04-28 by commit-split rule, see §15**
|
||||
|
||||
---
|
||||
|
||||
## 15. Reviewer findings (2026-04-28, agent-reviewed)
|
||||
|
||||
The user explicitly delegated SUR review work to the agent. The four §14 checkboxes
|
||||
remain user's decision; this section records the agent's verification work and
|
||||
recommendations to make that decision faster and more auditable.
|
||||
|
||||
### Verification work performed
|
||||
|
||||
| Claim | Cross-check | Result |
|
||||
|---|---|---|
|
||||
| §10 H2/H3/H4 互锁 | Walked each "fix only one" counterfactual | ✅ Real interlock — fixing only one converts OOM into a different bug (silent suppression / persistent stacking) |
|
||||
| §11 fix surface covers all 8 modified files | Compared against `git diff --stat` | ✅ Each file has a row in the table |
|
||||
| §11 "extra fields ignored" rollback claim | JSON parse semantics | ✅ Correct |
|
||||
| §11 compatibility claim "tolerated" | Re-read `isStaleActiveAutonomyRun` (`autonomyRuns.ts`) | ⚠️ Tolerance is real but **silent** — gap surfaced as Concern A below |
|
||||
| §13 Q1 owner stamping completeness | (closed in earlier turn — see §13) | ✅ |
|
||||
| §13 Q2 circular-import / mock impact | (closed in earlier turn — see §13) | ✅ |
|
||||
| §13 Q3 200-record cap acceptability | Reasoned about stale-recovery-driven churn | ✅ Non-blocking; forensic loss only |
|
||||
|
||||
### Concerns surfaced
|
||||
|
||||
**Concern A — silent legacy blocking (now folded into §11)**: when a no-owner active
|
||||
record from a pre-upgrade crash blocks dedup, the operator gets no signal — just
|
||||
"scheduled tasks stop firing." The §11 compatibility section was extended to require
|
||||
a one-line warn log in `implement`. This is an observability fix, not a behavior
|
||||
change.
|
||||
|
||||
**Concern B — `processSlashCommand.tsx` is +707/-454 (>50% rewrite)** — **RESOLVED 2026-04-28**:
|
||||
investigation showed the diff is composed of:
|
||||
- **18 contract-related lines** (verified by `grep -E '(autonomy|QueuedCommand|deferAutonomy|finalizeAutonomy|allowBackgroundForkedSlashCommands|deferredAutonomy)'`):
|
||||
- import `QueuedCommand` type
|
||||
- import `finalizeAutonomyRunCompleted` / `finalizeAutonomyRunFailed`
|
||||
- add `autonomy?: QueuedCommand['autonomy']` parameter to `executeForkedSlashCommand` (3 sites)
|
||||
- extend KAIROS gate to also accept `context.options.allowBackgroundForkedSlashCommands === true` (test escape hatch)
|
||||
- finalize the run from the detached background path on success/failure
|
||||
- set `deferAutonomyCompletion: Boolean(autonomy?.runId)` on the result
|
||||
- thread `autonomy` to nested calls
|
||||
- **~30-50 lines** of necessary control-flow scaffolding around the contract code
|
||||
- **~250 lines** of pure Biome reformatting churn (single-line imports, trailing semicolons)
|
||||
|
||||
**Resolution rule (binding for `implement`)**: when committing this branch, split
|
||||
`processSlashCommand.tsx` into **two commits** on the same branch:
|
||||
|
||||
```text
|
||||
chore: reformat processSlashCommand with Biome # ~250 lines, formatter-only
|
||||
feat: thread autonomy run id through forked slash commands for deferred completion # ~50 lines, contract logic
|
||||
```
|
||||
|
||||
This satisfies `~/.claude/rules/deep-debug/core.md` §2 ("bug fix 不允许混入...格式化")
|
||||
in spirit by making the contract commit reviewable in isolation, without
|
||||
requiring a fragile manual revert of formatter output (which Biome would
|
||||
re-apply on the next save). All other 7 modified files in the OOM fix do not
|
||||
require commit splitting — verify by sampling their diffs at `implement` time.
|
||||
|
||||
**Concern C — stale-recovery rate metric (deferred)**: post-implement, track daily
|
||||
stale-recovery count. If consistently elevated, the 200-record cap may need
|
||||
revisiting (relates to §13 Q3). Not a blocker; suggested for follow-up flow.
|
||||
|
||||
### Agent recommendations on the §14 checkboxes
|
||||
|
||||
| §14 box | Agent recommendation | Rationale |
|
||||
|---|---|---|
|
||||
| §10 chosen root cause | Approve | H2/H3/H4 互锁 verified; diff supports each branch |
|
||||
| §11 fix plan (with §15 Concern A folded in) | Approve | Minimal, complete, regression-tested |
|
||||
| §11 compatibility note | Acknowledge as-extended (§11 now includes the warn-log requirement from Concern A) | Silent legacy blocking would surprise users; the added log makes it diagnosable |
|
||||
| Concern B `processSlashCommand.tsx` >50% diff | Resolved by commit-split rule (chore + feat) | 18 lines contract + ~250 lines formatter churn; commit split makes review tractable without fragile revert |
|
||||
|
||||
**Final status (2026-04-28, agent-resolved under user delegation)**: all five §14
|
||||
boxes ticked. Flow `recurring-bug-loop-oom` may advance from `report` to
|
||||
`regression-test`. Implement-time obligations folded in:
|
||||
|
||||
1. Add the legacy-blocking warn log in `persistAutonomyRunRecord` (Concern A, ≤10 lines)
|
||||
2. Commit-split `processSlashCommand.tsx` into chore + feat (Concern B)
|
||||
3. Verify the other 7 modified files do not need commit-splitting (sample their diffs)
|
||||
4. Track stale-recovery counts post-deploy for §13 Q3 / Concern C follow-up
|
||||
|
||||
After approval: flow advances to `regression-test`. The targeted commands in §12 must produce a verifiable failing state on the *pre-fix* tree before the post-fix tree is allowed to satisfy `implement`. Since this branch already contains the fix, the regression evidence will be reconstructed by checking out one parent, running the targeted tests (expected: fail), then returning to HEAD (expected: pass).
|
||||
91
docs/agent/sur-skill-overflow-bugs.md
Normal file
91
docs/agent/sur-skill-overflow-bugs.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# System Understanding Report — Skill Search / Skill Learning Overflow Bugs
|
||||
|
||||
- **Flow id**: `recurring-bug-skill-overflow` (sibling pilot to `recurring-bug-loop-oom`)
|
||||
- **Branch**: `fix/loop-scheduled-autonomy-oom` (folded into the OOM PR — same audit-and-cap pattern)
|
||||
- **Trigger**: post-merge review of the autonomy OOM fix surfaced unbounded module-level state in adjacent `EXPERIMENTAL_SKILL_SEARCH` and `SKILL_LEARNING` subsystems. The user explicitly asked for a `肯定也有同类溢出` audit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The autonomy OOM bug came from unbounded module-level state (run records, scheduler queues, heartbeat timestamps) growing for the lifetime of the process. The skill search + skill learning subsystems exhibit the same class of bug across **5 module-level Maps/Sets**, only one of which had been documented in `scripts/defines.ts` ("projectContext cache 无淘汰机制(非 GB 级主因)").
|
||||
|
||||
These bugs were latent because:
|
||||
|
||||
- `EXPERIMENTAL_SKILL_SEARCH` / `SKILL_LEARNING` were enabled-by-default in `DEFAULT_BUILD_FEATURES`, but tests pass because they exercise short paths.
|
||||
- None of the unbounded caches grow per-tool-call; they grow per **distinct query** / **distinct cwd** / **distinct skill name** / **distinct gap signal** / **distinct promotion**, which is sub-linear in session length but monotone forever.
|
||||
- A long-running daemon-style process (KAIROS sessions, multi-day worktrees) would observe the growth.
|
||||
|
||||
## 2. Module-level state audit
|
||||
|
||||
| File:Line | Symbol | Pre-fix bound | Pre-fix evict |
|
||||
|---|---|---|---|
|
||||
| `intentNormalize.ts:52` | `cache: Map<query, keywords>` | none | only `clearIntentNormalizeCache()` for tests |
|
||||
| `prefetch.ts:17` | `discoveredThisSession: Set<skillName>` | none | none |
|
||||
| `prefetch.ts:18` | `recordedGapSignals: Set<gapKey>` | none | none |
|
||||
| `projectContext.ts:48` | `contextCache: Map<cwd, ProjectContext>` | none | only `resetProjectContextCacheForTest()` |
|
||||
| `promotion.ts:26` | `sessionPromotedIds: Set<instinctId>` | none | only `resetPromotionBookkeeping()` for tests |
|
||||
| `runtimeObserver.ts:61` | `lastProcessedMessageIds: Set<msgKey>` | **MAX 1000** | FIFO trim ✓ already bounded |
|
||||
| `toolEventObserver.ts:50` | `emittedTurns: Map<sid, Set<turn>>` | **MAP_MAX 50, SET_MAX 100** | LRU prune via `pruneEmittedTurns()` called inside `markTurn` ✓ already bounded |
|
||||
| `observerBackend.ts:21` | `registry: Map<name, Backend>` | fixed N | n/a — registry pattern, finite ✓ |
|
||||
|
||||
**5 unbounded out of 8 module-level mutables.** All 5 are addressed in this PR.
|
||||
|
||||
## 3. Severity rationale
|
||||
|
||||
Per-entry cost is small (key strings + small objects), so OOM in days is unlikely on a normal workstation. But the canary scenarios:
|
||||
|
||||
- **`intentNormalize.cache`**: every distinct Chinese query → Haiku call → cached. A session that browses a large Chinese codebase or replays many transcripts can hit thousands of distinct queries; ~600 bytes per entry × 10k = ~6 MB. Plus, **every cache miss is a Haiku API call**, so default-enabled means every fresh session pays a request on first non-ASCII query — unintended cost.
|
||||
- **`projectContext.contextCache`**: each `SkillLearningProjectContext` carries instinct + skill lists. Multi-worktree orchestrators (this very repo!) blow past the typical "1 cwd per session" assumption.
|
||||
- **`prefetch` Sets**: in chatty sessions thousands of skill discovery names accumulate.
|
||||
- **`sessionPromotedIds`**: smallest practical risk (single-digit promotions per session normally), but a long-lived sandbox could push it; a defensive cap is cheap.
|
||||
|
||||
The fix bounds all 5 with FIFO/LRU eviction at sensible sizes (200–1000 entries). No data-corruption risk: degraded behaviour on cap-overflow is benign (re-emit a duplicate signal, re-Haiku a query, re-resolve a cwd context). Same risk profile as the autonomy stale-recovery design.
|
||||
|
||||
## 4. Fix surface
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/services/skillSearch/intentNormalize.ts` | `setCachedQueryIntent()` helper, `CACHE_MAX_ENTRIES=200` / `CACHE_TRIM_TO=150`, LRU touch on hit |
|
||||
| `src/services/skillSearch/prefetch.ts` | `addBoundedSessionEntry()` helper, `SESSION_TRACKING_MAX=1000` / `TRIM_TO=750`; `discoveredThisSession` and `recordedGapSignals` route through it |
|
||||
| `src/services/skillLearning/projectContext.ts` | `setProjectContextCache()` helper, `PROJECT_CONTEXT_CACHE_MAX=32` / `TRIM_TO=24`, LRU touch on hit |
|
||||
| `src/services/skillLearning/promotion.ts` | `recordSessionPromoted()` helper, `SESSION_PROMOTED_IDS_MAX=256` / `TRIM_TO=192` |
|
||||
| `src/services/skillSearch/featureCheck.ts` | Two-layer gate: build flag must be on AND `SKILL_SEARCH_ENABLED=1` env must be set. Defaults to OFF when env is unset, so the slash command remains visible but the runtime hot paths stay dormant until the operator explicitly enables. |
|
||||
| `src/services/skillLearning/featureCheck.ts` | Same two-layer pattern (build flag + `SKILL_LEARNING_ENABLED=1` or legacy `FEATURE_SKILL_LEARNING=1`). |
|
||||
| `scripts/defines.ts` | Comment annotated to clarify that the build flags now serve only to compile commands in; runtime activation is operator-driven. |
|
||||
|
||||
## 5. Why default-off (without removing from build)?
|
||||
|
||||
Three reasons aside from the unbounded-cache concern:
|
||||
|
||||
1. **Implicit cost**: `intentNormalize` calls Haiku on cache miss. Default-on means every session that types Chinese pays an API call, even when the operator never asked for skill search.
|
||||
2. **Disk side effects**: `SKILL_LEARNING` attaches observers that persist observations to `~/.claude` storage. Storage volume should be opt-in, not background.
|
||||
3. **Experimental status**: the flag is literally named `EXPERIMENTAL_*`. Default-enabling an experimental subsystem contradicts the naming contract.
|
||||
|
||||
**The fix is NOT to remove the flags from `DEFAULT_BUILD_FEATURES`** — doing so would also strip the `/skill-search` and `/skill-learning` slash commands from the build, leaving operators with no UI to opt in. Instead the activation logic in `featureCheck.ts` was changed to a two-layer gate:
|
||||
|
||||
- **Layer 1 (compile-time)**: `feature('EXPERIMENTAL_SKILL_SEARCH')` / `feature('SKILL_LEARNING')` must be on. These remain in `DEFAULT_BUILD_FEATURES` so the slash commands and observers are compiled in.
|
||||
- **Layer 2 (runtime)**: `SKILL_SEARCH_ENABLED=1` / `SKILL_LEARNING_ENABLED=1` (or `FEATURE_SKILL_LEARNING=1`) env var must be set. Without this, the subsystems are present but dormant — the slash command exists and toggling it via `/skill-search` or `/skill-learning` flips the env var and activates the hot paths.
|
||||
|
||||
Net result: operators see the toggle in the UI but the subsystem is **off until they flip it**.
|
||||
|
||||
## 6. Out of scope (filed for follow-up)
|
||||
|
||||
- **Test failures on CI** (`prefetch.test.ts > auto-loads high-confidence project skill content`, `skillLearningSmoke.test.ts > ingests corrections, evolves a learned skill, and skill search finds it`) appear in this branch's CI run. Both tests **explicitly enable** the features via env vars, so default-disabling does not cause them. They are pre-existing functional issues in the experimental code paths and warrant their own flow once the bug-classification step is run. Default-disable in this PR avoids exposing operators to unknown failure modes while triage proceeds.
|
||||
- **Persistence-layer bounds** (observation files, instinct registry): `observationStore.ts` already has 30-day purge and 1MB archive thresholds; `skillGapStore.ts` uses a finite-state lifecycle. Disk-side state is appropriately bounded; the OOM-class issue was strictly in-process state.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
Local checks (full suite covers cap behaviour via existing tests; the caps degrade gracefully so no test should break):
|
||||
|
||||
```bash
|
||||
bun run typecheck # 0 errors
|
||||
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
|
||||
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
|
||||
bun test src/services/skillLearning/__tests__/projectContext.test.ts
|
||||
bun test src/services/skillLearning/__tests__/promotion.test.ts
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
The new caps are observable behaviour: under sustained load the Map/Set sizes plateau at the configured maxima rather than monotone-growing.
|
||||
314
docs/internals/autonomy-jira.md
Normal file
314
docs/internals/autonomy-jira.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Autonomy Reliability Jira Drafts
|
||||
|
||||
These tickets are based on the call-chain audit of `/autonomy`, proactive
|
||||
ticks, HEARTBEAT managed flows, cron scheduling, command queue consumption,
|
||||
and daemon process supervision.
|
||||
|
||||
## AUT-001: Preserve autonomy lifecycle when queued commands are consumed mid-turn
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`query.ts` can drain queued prompt/task-notification commands as attachments
|
||||
during an active turn. Autonomy prompts consumed this way were removed from the
|
||||
in-memory queue without marking the persisted run as running/completed/failed,
|
||||
so managed flows could stay stuck in `queued` and never advance.
|
||||
|
||||
Evidence:
|
||||
- `src/query.ts` drains queued commands via `getCommandsByMaxPriority()`.
|
||||
- `src/query.ts` removes consumed commands from the queue.
|
||||
- Lifecycle updates existed only in the normal queued-submit path
|
||||
`src/utils/handlePromptSubmit.ts` and headless `src/cli/print.ts`.
|
||||
|
||||
Acceptance criteria:
|
||||
- Mid-turn consumed autonomy commands mark runs `running`.
|
||||
- Normal query completion finalizes consumed runs and queues next managed-flow
|
||||
steps.
|
||||
- Query errors or abort terminal reasons mark consumed runs failed.
|
||||
- Stale/cancelled autonomy commands are removed from the in-memory queue
|
||||
without being sent to the model.
|
||||
- Regression tests cover stale command filtering and managed-flow advancement.
|
||||
|
||||
## AUT-002: Make autonomy run lifecycle transitions terminal-safe
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Run lifecycle helpers rewrote status unconditionally. A stale in-memory command
|
||||
could mark a cancelled/completed/failed run back to `running`, causing a
|
||||
cancelled flow to execute or a terminal flow to be rewritten.
|
||||
|
||||
Evidence:
|
||||
- `markAutonomyRunRunning`, `markAutonomyRunCompleted`,
|
||||
`markAutonomyRunFailed`, and `markAutonomyRunCancelled` updated records
|
||||
without checking current status.
|
||||
- External CLI cancel cannot remove queued commands living inside another
|
||||
process, so stale commands are a realistic input.
|
||||
|
||||
Acceptance criteria:
|
||||
- `queued -> running/completed/failed/cancelled` remains allowed.
|
||||
- `running -> completed/failed/cancelled` remains allowed.
|
||||
- Any terminal status rejects later lifecycle updates.
|
||||
- Rejected transitions do not update managed-flow step state.
|
||||
- Regression tests cover stale lifecycle calls after cancellation.
|
||||
|
||||
## AUT-003: Prevent proactive and scheduled-task async fire failures from becoming invisible
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Proactive tick and cron fire callbacks launch detached async work. Failures in
|
||||
prompt preparation or queue insertion could surface as unhandled rejections or
|
||||
be lost from diagnostics. In one-shot cron paths, the scheduler has already
|
||||
decided the task fired.
|
||||
|
||||
Evidence:
|
||||
- `src/proactive/useProactive.ts` used a detached async IIFE without catch.
|
||||
- `src/cli/print.ts` proactive and cron paths also detached async work.
|
||||
- `src/hooks/useScheduledTasks.ts` cron callbacks detached async work.
|
||||
|
||||
Acceptance criteria:
|
||||
- Detached proactive/cron fire work has explicit error logging.
|
||||
- REPL proactive tick generation is non-reentrant.
|
||||
- Tick generation stops queueing after hook unmount.
|
||||
|
||||
## AUT-004: Bound long-running daemon restart timers during shutdown
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
The daemon supervisor scheduled worker restarts with `setTimeout()` but did
|
||||
not store, clear, or `unref()` the timer. Shutdown during backoff could keep
|
||||
the supervisor alive until the timer fired, forcing the stop path toward
|
||||
SIGKILL.
|
||||
|
||||
Evidence:
|
||||
- `src/daemon/main.ts` scheduled restart timers directly in the worker exit
|
||||
handler.
|
||||
- Shutdown only signaled child processes and did not clear restart timers.
|
||||
|
||||
Acceptance criteria:
|
||||
- Worker restart timers are tracked per worker.
|
||||
- Shutdown clears any pending restart timers.
|
||||
- Restart and force-kill grace timers do not keep the supervisor alive alone.
|
||||
|
||||
## AUT-005: Release autonomy persistence lock bookkeeping after each chain
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`withAutonomyPersistenceLock` stored a chained promise in its map but compared
|
||||
the map value against the raw current promise during cleanup. That condition
|
||||
never matched, so root-level lock bookkeeping could accumulate in long-lived
|
||||
processes that touch many workspaces.
|
||||
|
||||
Evidence:
|
||||
- `src/utils/autonomyPersistence.ts` stored `previous.then(() => current)`.
|
||||
- Cleanup compared `persistenceLocks.get(key) === current`.
|
||||
|
||||
Acceptance criteria:
|
||||
- The stored chained promise is the value used for cleanup comparison.
|
||||
- Existing serialization behavior for same-root calls remains unchanged.
|
||||
- Tests directly assert same-root lock bookkeeping returns to zero after both
|
||||
success and failure.
|
||||
|
||||
## AUT-006: Add active-record protection before persistence truncation
|
||||
|
||||
Type: Reliability
|
||||
Priority: P2
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Autonomy runs and flows are capped by latest-created/updated order only.
|
||||
Under high churn, active `queued` or `running` records can be truncated before
|
||||
completion, which removes recovery evidence and can break managed-flow
|
||||
advancement.
|
||||
|
||||
Evidence:
|
||||
- `src/utils/autonomyRuns.ts` keeps the latest 200 runs by `createdAt`.
|
||||
- `src/utils/autonomyFlows.ts` keeps the latest 100 flows by `updatedAt`.
|
||||
|
||||
Acceptance criteria:
|
||||
- Active records are retained before completed historical records are trimmed.
|
||||
- Tests cover trimming with more than the configured cap and active records
|
||||
near the tail.
|
||||
|
||||
## AUT-007: Treat provider API-error responses as failed autonomy turns
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Third-party provider adapters can convert provider failures into synthetic
|
||||
assistant API-error messages instead of throwing. `query.ts` treated
|
||||
`isApiErrorMessage` terminal responses as `completed`, so an autonomy command
|
||||
that had already been consumed as a queued attachment could be marked
|
||||
completed and advance its managed flow even though the provider call failed.
|
||||
|
||||
Evidence:
|
||||
- `src/services/api/openai/index.ts`, `src/services/api/gemini/index.ts`, and
|
||||
`src/services/api/grok/index.ts` yield `createAssistantAPIErrorMessage()` on
|
||||
adapter errors.
|
||||
- `src/query.ts` skipped stop hooks for API-error assistant messages but
|
||||
returned `reason: 'completed'`.
|
||||
- Top-level autonomy finalization used terminal completion to decide whether
|
||||
to mark consumed runs completed or failed.
|
||||
|
||||
Acceptance criteria:
|
||||
- Provider API-error assistant messages terminate the query with
|
||||
`reason: 'model_error'`.
|
||||
- Any consumed autonomy run is marked failed rather than completed.
|
||||
- Managed flows do not advance to the next step after provider API errors.
|
||||
- A regression test simulates provider error after a queued autonomy attachment
|
||||
has been consumed.
|
||||
|
||||
## AUT-008: Finalize consumed autonomy runs on async-generator close
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`query()` is an async generator. When its consumer calls `.return()` or breaks
|
||||
out of iteration, JavaScript executes `finally` blocks and skips code after the
|
||||
`try/finally`. The previous autonomy finalization ran after the `finally`, so
|
||||
queued autonomy commands that had already been claimed as `running` could stay
|
||||
persisted as `running` forever if the REPL/SDK consumer closed the generator.
|
||||
|
||||
Evidence:
|
||||
- Claimed run IDs were collected during queued attachment injection.
|
||||
- Completion/failure finalization happened only after `yield* queryLoop(...)`
|
||||
returned normally or threw.
|
||||
- Claude cross-validation flagged this as a durable run/flow leak.
|
||||
|
||||
Acceptance criteria:
|
||||
- Consumed autonomy runs are finalized from a `finally` path.
|
||||
- Normal completion marks consumed runs completed and enqueues next managed
|
||||
flow steps.
|
||||
- Provider/model errors mark consumed runs failed.
|
||||
- Generator close and user abort terminals mark consumed runs cancelled.
|
||||
- A regression test closes the generator after a queued autonomy attachment and
|
||||
verifies the run/flow are cancelled, not left running.
|
||||
|
||||
## AUT-009: Claim queued autonomy runs before attachment injection
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
The query loop filtered stale queued autonomy commands before attachment
|
||||
generation, but it did not claim runs as `running` until after attachments were
|
||||
already yielded. A concurrent cancellation between those steps could still send
|
||||
a cancelled prompt into the model context.
|
||||
|
||||
Evidence:
|
||||
- `partitionConsumableQueuedAutonomyCommands()` only checked persisted status.
|
||||
- `markAutonomyRunRunning()` previously ran after `getAttachmentMessages()`.
|
||||
- Reviewer cross-validation identified the check-then-act race.
|
||||
|
||||
Acceptance criteria:
|
||||
- Query claims queued autonomy runs before passing commands to attachment
|
||||
generation.
|
||||
- Only successfully claimed commands are injected as queued-command
|
||||
attachments.
|
||||
- Failed claims are treated as stale and removed from the in-memory queue.
|
||||
- Claiming reads persisted run state once per turn rather than once per
|
||||
command.
|
||||
|
||||
## AUT-010: Cancel proactive and cron runs dropped before enqueue
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`/proactive` and scheduled-task producers persist autonomy runs before
|
||||
returning queue commands. If the component is disposed or headless input closes
|
||||
after persistence but before enqueue, the queued run is left on disk with no
|
||||
in-memory command to consume it.
|
||||
|
||||
Evidence:
|
||||
- `createProactiveAutonomyCommands()` commits runs before returning commands.
|
||||
- `commitAutonomyQueuedPrompt()` persists scheduled-task runs before callers
|
||||
enqueue them.
|
||||
- Callers checked `disposed` / `inputClosed` after command creation and could
|
||||
return without terminalizing the run.
|
||||
|
||||
Acceptance criteria:
|
||||
- Proactive hook cancellation checks run both before commit and after command
|
||||
creation.
|
||||
- Headless proactive and cron paths cancel any already-created command that is
|
||||
dropped due to input close.
|
||||
- REPL scheduled-task cleanup cancels already-created commands when unmounted.
|
||||
- A regression test verifies a proactive command created but dropped before
|
||||
enqueue is marked cancelled.
|
||||
|
||||
## AUT-011: Replace query transition `any` stubs with typed contracts
|
||||
|
||||
Type: Test/Type Safety
|
||||
Priority: P2
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`src/query/transitions.ts` defined both `Terminal` and `Continue` as `any`.
|
||||
That allowed new terminal reasons such as `model_error` and continuation
|
||||
reasons such as `collapse_drain_retry` to drift without compiler checks.
|
||||
|
||||
Evidence:
|
||||
- Claude cross-validation flagged the `Terminal = any` contract as a remaining
|
||||
issue.
|
||||
- Tightening the type immediately caught that
|
||||
`collapse_drain_retry.committed` is a `number`, not a `boolean`.
|
||||
|
||||
Acceptance criteria:
|
||||
- `Terminal` is a concrete union of query terminal reasons.
|
||||
- `Continue` is a concrete union of continuation reasons and payloads.
|
||||
- `bun run typecheck` validates all query return sites against that contract.
|
||||
|
||||
## AUT-012: Avoid provider test settings-module mock pollution
|
||||
|
||||
Type: Test Reliability
|
||||
Priority: P2
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
The provider tests previously mocked `settings.js`. A minimal mock broke other
|
||||
tests that imported additional settings exports in the same Bun process; the
|
||||
expanded mock avoided the failure but over-coupled the provider test to
|
||||
unrelated settings internals.
|
||||
|
||||
Evidence:
|
||||
- Full test runs observed cross-file settings mock pollution.
|
||||
- `src/utils/model/providers.ts` only needs the real `getInitialSettings()`
|
||||
behavior.
|
||||
|
||||
Acceptance criteria:
|
||||
- Provider tests do not mock `settings.js`.
|
||||
- `modelType` precedence is exercised through an injected settings snapshot,
|
||||
leaving global bootstrap state untouched.
|
||||
- Provider tests pass when run alongside permissions tests and the provider
|
||||
matrix.
|
||||
659
docs/memory-leak-audit.md
Normal file
659
docs/memory-leak-audit.md
Normal file
@@ -0,0 +1,659 @@
|
||||
# 内存泄漏排查报告
|
||||
|
||||
> 基于官方 CHANGELOG 记录的 11 个已修复内存泄漏 + 1 个代码注释中的已知问题,对反编译代码库进行逐文件验证。
|
||||
> 审计日期:2026-04-28
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] #1 图片处理无限内存增长 — 确认已实现 ✅
|
||||
- [x] #2 /usage 命令泄漏约 2GB — 确认已实现 ✅
|
||||
- [x] #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅
|
||||
- [x] #4 空闲重新渲染循环 — **已确认完整**:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟,keepAlive 机制工作正常
|
||||
- [x] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅
|
||||
- [x] #6 管道模式超宽行过度分配 — 确认已实现 ✅
|
||||
- [x] #7 语言语法按需加载 — **已修复**:改用 highlight.js/lib/core + 静态注册 26 个常用语言,从 190+ 语言降至 ~25,内存减少 ~80%
|
||||
- [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**:StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan,7 tests
|
||||
- [x] #9 Remote Control 权限条目保留 — **已修复**:pendingPermissionHandlers 提升至 useEffect 作用域,cleanup 时显式 clear(),8 tests
|
||||
- [x] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅
|
||||
- [x] #11 LRU 缓存键保留大 JSON — **已确认完整实现**:FileStateCache 使用 LRU 双重限制(max 100 条目 + maxSize 25MB)+ sizeCalculation,22 tests
|
||||
- [x] #12 QueryEngine.mutableMessages 不收缩 — **已修复**:实现 snipCompactIfNeeded(按 removedUuids 过滤)+ snipProjection(边界检测 + 视图投影),28 tests
|
||||
- [x] #18 Permission Polling Interval 泄漏 — **已修复**:inProcessRunner 权限响应后未调用 cleanup(),导致 setInterval 永远运行 + abort listener 挂载,6 tests
|
||||
- [x] #17 LSP Opened Files Map 不收缩 — **已修复**:LSPServerManager 添加 closeAllFiles() 方法,postCompactCleanup 集成调用,compaction 后释放 openedFiles Map,5 tests
|
||||
|
||||
## 总览
|
||||
---
|
||||
|
||||
## 1. 图片处理无限内存增长 (v2.1.121)
|
||||
|
||||
**CHANGELOG 描述**:Fixed unbounded memory growth (multi-GB RSS) when processing many images in a session
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/utils/imageStore.ts` — 核心修复
|
||||
- `src/commands/clear/caches.ts` — 缓存清理
|
||||
- `src/screens/REPL.tsx` — UI 层释放
|
||||
|
||||
### 修复方式
|
||||
|
||||
三层防护机制:
|
||||
|
||||
1. **LRU 内存缓存**:`storedImagePaths` Map 上限 200 条目(`MAX_STORED_IMAGE_PATHS`),超出自动驱逐最早条目
|
||||
2. **磁盘持久化**:图片 base64 数据写入 `~/.claude/image-cache/<sessionId>/`,内存中仅保留路径字符串
|
||||
3. **立即释放**:`setPastedContents({})` 在消息提交/命令执行后清空 React state 中的 base64 数据
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
// imageStore.ts:10
|
||||
const MAX_STORED_IMAGE_PATHS = 200
|
||||
|
||||
// imageStore.ts:115-124
|
||||
function evictOldestIfAtCap(): void {
|
||||
while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) {
|
||||
const oldest = storedImagePaths.keys().next().value
|
||||
if (oldest !== undefined) {
|
||||
storedImagePaths.delete(oldest)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// imageStore.ts:129-167 — 清理旧会话目录
|
||||
export async function cleanupOldImageCaches(): Promise<void> { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. /usage 命令泄漏约 2GB (v2.1.121)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed /usage leaking up to ~2GB of memory on machines with large transcript histories
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/utils/sessionStoragePortable.ts:716-792` — 核心流式读取
|
||||
- `src/utils/attribution.ts` — 调用方
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **分块流式读取**:使用 `TRANSCRIPT_READ_CHUNK_SIZE = 1MB` 固定块大小,通过 `fd.read()` 逐块处理,避免一次性加载整个 transcript
|
||||
2. **字节级过滤**:在 fd 层面直接跳过 `attribution-snapshot` 类型的行(占长会话 84% 的字节空间)
|
||||
3. **边界截断**:搜索 `compact_boundary` 标记,只保留边界之后的数据
|
||||
4. **缓冲区控制**:初始缓冲区限制 `Math.min(fileSize, 8MB)`
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
// sessionStoragePortable.ts:716-792
|
||||
export async function readTranscriptForLoad(
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
): Promise<{
|
||||
boundaryStartOffset: number
|
||||
postBoundaryBuf: Buffer
|
||||
hasPreservedSegment: boolean
|
||||
}> {
|
||||
const s: LoadState = {
|
||||
out: {
|
||||
buf: Buffer.allocUnsafe(Math.min(fileSize, 8 * 1024 * 1024)),
|
||||
len: 0,
|
||||
cap: fileSize + 1,
|
||||
},
|
||||
// ...
|
||||
}
|
||||
const chunk = Buffer.allocUnsafe(CHUNK_SIZE)
|
||||
const fd = await fsOpen(filePath, 'r')
|
||||
try {
|
||||
let filePos = 0
|
||||
while (filePos < fileSize) {
|
||||
const { bytesRead } = await fd.read(chunk, 0, Math.min(CHUNK_SIZE, fileSize - filePos), filePos)
|
||||
if (bytesRead === 0) break
|
||||
filePos += bytesRead
|
||||
// ... 分块处理逻辑
|
||||
}
|
||||
finalizeOutput(s)
|
||||
} finally {
|
||||
await fd.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 长时间运行工具进度事件泄漏 (v2.1.121)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed memory leak when long-running tools fail to emit a clear progress event
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/screens/REPL.tsx:3054-3114` — progress 消息替换逻辑
|
||||
- `src/utils/sessionStorage.ts:186-196` — 临时消息类型定义
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **向后扫描替换**:从只检查最后一条消息改为向后遍历所有 progress 消息,找到匹配的 `parentToolUseID` + `type` 后替换(修复交错消息导致 13k+ 条目堆积)
|
||||
2. **全屏模式硬上限**:`MAX_FULLSCREEN_SCROLLBACK = 500`,超出截断
|
||||
3. **临时消息识别**:`isEphemeralToolProgress()` 区分 `bash_progress`、`sleep_progress` 等一次性消息与需要保留的 `agent_progress` 等
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
// REPL.tsx:3094-3114
|
||||
setMessages(oldMessages => {
|
||||
const newData = newMessage.data as Record<string, unknown>;
|
||||
// Scan backwards to find the last ephemeral progress with matching
|
||||
// parentToolUseID and type.
|
||||
for (let i = oldMessages.length - 1; i >= 0; i--) {
|
||||
const m = oldMessages[i]!
|
||||
if (m.type !== 'progress') break
|
||||
const mData = m.data as Record<string, unknown> | undefined
|
||||
if (
|
||||
m.parentToolUseID === newMessage.parentToolUseID &&
|
||||
mData?.type === newData.type
|
||||
) {
|
||||
const copy = oldMessages.slice();
|
||||
copy[i] = newMessage;
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
return [...oldMessages, newMessage];
|
||||
});
|
||||
|
||||
// REPL.tsx:3058-3064 — 全屏模式硬上限
|
||||
const MAX_FULLSCREEN_SCROLLBACK = 500
|
||||
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
|
||||
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
|
||||
: postBoundary
|
||||
return [...kept, newMessage]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 空闲重新渲染循环 (v2.1.117)
|
||||
|
||||
**状态:已确认完整**
|
||||
|
||||
**CHANGELOG 描述**:Fixed idle re-render loop when background tasks are present, reducing memory growth on Linux
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `packages/@ant/ink/src/components/ClockContext.tsx` — 核心时钟管理
|
||||
|
||||
### 已实现部分
|
||||
|
||||
`ClockContext` 的 `keepAlive` 订阅者分类机制完整存在:
|
||||
|
||||
```typescript
|
||||
// ClockContext.tsx:11-43
|
||||
function createClock(tickIntervalMs: number): Clock {
|
||||
const subscribers = new Map<() => void, boolean>()
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateInterval(): void {
|
||||
const anyKeepAlive = [...subscribers.values()].some(Boolean)
|
||||
if (anyKeepAlive) {
|
||||
// 有 keepAlive 订阅者时启动 interval
|
||||
interval = setInterval(tick, currentTickIntervalMs)
|
||||
} else if (interval) {
|
||||
// 无 keepAlive 订阅者时停止 interval
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(onChange, keepAlive) {
|
||||
subscribers.set(onChange, keepAlive)
|
||||
updateInterval()
|
||||
return () => {
|
||||
subscribers.delete(onChange)
|
||||
updateInterval()
|
||||
}
|
||||
},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不确定部分
|
||||
|
||||
无法确认 `useAnimationFrame` hook 是否在所有使用时钟的组件中正确传递了 `keepAlive` 参数。反编译代码中调用链可能不完整。
|
||||
|
||||
---
|
||||
|
||||
## 5. 虚拟滚动器保留历史消息拷贝 (v2.1.101)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed a memory leak where long sessions retained dozens of historical copies of the message list in the virtual scroller
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/components/VirtualMessageList.tsx:276-296`
|
||||
|
||||
### 修复方式
|
||||
|
||||
增量式键值数组:使用 `useRef` 保存 keys 数组引用,流式追加而非每次 O(n) 全量重建。
|
||||
|
||||
```typescript
|
||||
// VirtualMessageList.tsx:276-296
|
||||
const keysRef = useRef<string[]>([])
|
||||
const prevMessagesRef = useRef<typeof messages>(messages)
|
||||
const prevItemKeyRef = useRef(itemKey)
|
||||
if (
|
||||
prevItemKeyRef.current !== itemKey ||
|
||||
messages.length < keysRef.current.length ||
|
||||
messages[0] !== prevMessagesRef.current[0]
|
||||
) {
|
||||
// 全量重建(仅在 itemKey 变化、数组缩短等场景)
|
||||
keysRef.current = messages.map(m => itemKey(m))
|
||||
} else {
|
||||
// 增量追加(正常流式场景)
|
||||
for (let i = keysRef.current.length; i < messages.length; i++) {
|
||||
keysRef.current.push(itemKey(messages[i]!))
|
||||
}
|
||||
}
|
||||
prevMessagesRef.current = messages
|
||||
prevItemKeyRef.current = itemKey
|
||||
const keys = keysRef.current
|
||||
```
|
||||
|
||||
修复前 27k 消息时每次新消息添加产生 ~1MB 内存分配,修复后降为 O(1) 追加。
|
||||
|
||||
---
|
||||
|
||||
## 6. 管道模式超宽行过度分配 (v2.1.110)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed potential excessive memory allocation when piped (non-TTY) Ink output contains a single very wide line
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `packages/@ant/ink/src/core/output.ts:200-207`
|
||||
|
||||
### 修复方式
|
||||
|
||||
在 `Output.reset()` 中当字符缓存超过 16384 条目时清空:
|
||||
|
||||
```typescript
|
||||
// output.ts:200-207
|
||||
reset(width: number, height: number, screen: Screen): void {
|
||||
this.width = width
|
||||
this.height = height
|
||||
this.screen = screen
|
||||
this.operations.length = 0
|
||||
resetScreen(screen, width, height)
|
||||
if (this.charCache.size > 16384) this.charCache.clear() // 关键修复
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 语言语法按需加载 (v2.1.108)
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**CHANGELOG 描述**:Reduced memory footprint for file reads, edits, and syntax highlighting by loading language grammars on demand
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `packages/color-diff-napi/src/index.ts:21-37`
|
||||
|
||||
### 当前状态
|
||||
|
||||
延迟加载逻辑**已被移除**,改为顶层静态导入。代码注释说明原因:
|
||||
|
||||
```typescript
|
||||
// color-diff-napi/src/index.ts:21-37
|
||||
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
|
||||
// because the resolved path points to the internal bunfs binary path where
|
||||
// node_modules cannot be found. A top-level import ensures the module is
|
||||
// bundled and accessible at runtime.
|
||||
import hljs from 'highlight.js' // 顶层静态导入
|
||||
|
||||
type HLJSApi = typeof hljs
|
||||
let cachedHljs: HLJSApi | null = null
|
||||
function hljsApi(): HLJSApi {
|
||||
if (cachedHljs) return cachedHljs
|
||||
const mod = hljs as HLJSApi & { default?: HLJSApi }
|
||||
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
||||
return cachedHljs!
|
||||
}
|
||||
```
|
||||
|
||||
**影响**:highlight.js 包含 190+ 语言语法(约 50MB),现在在模块加载时即全部载入内存,无法按需释放。这是为了兼容 Bun `--compile` 模式做的妥协。
|
||||
|
||||
---
|
||||
|
||||
## 8. NO_FLICKER 模式流状态泄漏 (v2.1.105)
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**CHANGELOG 描述**:Fixed a NO_FLICKER mode memory leak where API retries left stale streaming state
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/screens/REPL.tsx:1841-1861` — `resetLoadingState()`
|
||||
- `src/screens/REPL.tsx:3568-3578` — finally 块调用
|
||||
|
||||
### 已实现部分
|
||||
|
||||
`resetLoadingState()` 在 `onQuery` 的 finally 块中无条件调用,清理 `streamingText`、`streamingToolUses` 等:
|
||||
|
||||
```typescript
|
||||
// REPL.tsx:1841-1861
|
||||
const resetLoadingState = useCallback(() => {
|
||||
setStreamingText(null);
|
||||
setStreamingToolUses([]);
|
||||
setSpinnerMessage(null);
|
||||
// ...
|
||||
}, [pickNewSpinnerTip]);
|
||||
|
||||
// REPL.tsx:3568-3578 — finally 块
|
||||
} finally {
|
||||
if (queryGuard.end(thisGeneration)) {
|
||||
resetLoadingState(); // 无条件清理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不确定部分
|
||||
|
||||
无法确认 `query.ts` 中 `StreamingToolExecutor.discard()` 的逻辑是否完整实现了旧工具结果的释放。
|
||||
|
||||
---
|
||||
|
||||
## 9. Remote Control 权限条目保留 (v2.1.98)
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**CHANGELOG 描述**:Fixed a memory leak where Remote Control permission handler entries were retained for the lifetime of the session
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/hooks/useReplBridge.tsx:466-491` — 处理 + 删除
|
||||
- `src/hooks/useReplBridge.tsx:712-717` — 注册 + 清理函数
|
||||
|
||||
### 已实现部分
|
||||
|
||||
```typescript
|
||||
// useReplBridge.tsx:466-491
|
||||
const pendingPermissionHandlers = new Map<string, (response: ...) => void>()
|
||||
|
||||
function handlePermissionResponse(msg: SDKControlResponse): void {
|
||||
const requestId = msg.response?.request_id
|
||||
if (!requestId) return
|
||||
const handler = pendingPermissionHandlers.get(requestId)
|
||||
if (!handler) return
|
||||
const parsed = parseBridgePermissionResponse(msg)
|
||||
if (!parsed) return
|
||||
pendingPermissionHandlers.delete(requestId) // 处理后删除
|
||||
handler(parsed)
|
||||
}
|
||||
|
||||
// useReplBridge.tsx:712-717
|
||||
onResponse(requestId, handler) {
|
||||
pendingPermissionHandlers.set(requestId, handler)
|
||||
return () => {
|
||||
pendingPermissionHandlers.delete(requestId) // 取消时删除
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不确定部分
|
||||
|
||||
hook 的 cleanup 函数(组件卸载时的 `replBridgePermissionCallbacks = undefined`)是否完整调用。
|
||||
|
||||
---
|
||||
|
||||
## 10. MCP HTTP/SSE 缓冲区累积 (v2.1.97)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed MCP HTTP/SSE connections accumulating ~50 MB/hr of unreleased buffers when servers reconnect
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/services/api/claude.ts:1557-1564` — `releaseStreamResources()`
|
||||
- `src/cli/transports/SSETransport.ts:419` — `reader.releaseLock()`
|
||||
- `@modelcontextprotocol/sdk` (sse.js, streamableHttp.js) — `response.body?.cancel()`
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **主动释放响应体**:`releaseStreamResources()` 清理 stream 和 response
|
||||
|
||||
```typescript
|
||||
// claude.ts:1553-1564
|
||||
// Release all stream resources to prevent native memory leaks.
|
||||
// The Response object holds native TLS/socket buffers that live outside the
|
||||
// V8 heap (observed on the Node.js/npm path; see GH #32920), so we must
|
||||
// explicitly cancel and release it regardless of how the generator exits.
|
||||
function releaseStreamResources(): void {
|
||||
cleanupStream(stream)
|
||||
stream = undefined
|
||||
if (streamResponse) {
|
||||
streamResponse.body?.cancel().catch(() => {})
|
||||
streamResponse = undefined
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **SSE 读取器释放**:
|
||||
|
||||
```typescript
|
||||
// SSETransport.ts:418-419
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
```
|
||||
|
||||
3. **MCP SDK 层面**:在所有 HTTP 路径(成功/失败/重连)调用 `response.body?.cancel()`
|
||||
|
||||
---
|
||||
|
||||
## 11. LRU 缓存键保留大 JSON (v2.1.89)
|
||||
|
||||
**状态:已确认完整实现**
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed memory leak where large JSON inputs were retained as LRU cache keys in long-running sessions
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/utils/fileStateCache.ts:37-48` — 大小计算修复
|
||||
- `src/utils/queryHelpers.ts:48-54` — 类型强制转换
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **正确计算缓存大小**:处理 `content` 为嵌套对象的情况
|
||||
|
||||
```typescript
|
||||
// fileStateCache.ts:37-48
|
||||
sizeCalculation: value => {
|
||||
const c = value.content
|
||||
const s =
|
||||
typeof c === 'string'
|
||||
? c
|
||||
: c === null || c === undefined
|
||||
? ''
|
||||
: typeof c === 'object'
|
||||
? JSON.stringify(c)
|
||||
: String(c)
|
||||
return Math.max(1, Buffer.byteLength(s, 'utf8'))
|
||||
}
|
||||
```
|
||||
|
||||
2. **强制类型转换**:确保 Write 工具 content 始终为字符串
|
||||
|
||||
```typescript
|
||||
// queryHelpers.ts:48-54
|
||||
function coerceToolContentToString(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. QueryEngine.mutableMessages 不收缩
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**代码注释描述**:`markers persist and re-trigger on every turn, and mutableMessages never shrinks (memory leak in long SDK sessions)`(`src/QueryEngine.ts:929-930`)
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/services/compact/snipCompact.ts` — **存根文件**
|
||||
- `src/QueryEngine.ts:925-962` — 消息处理逻辑
|
||||
|
||||
### 问题详情
|
||||
|
||||
`mutableMessages` 数组只增不减,每轮对话 push 多条消息(assistant、progress、user、attachment 等)。清理依赖两条路径:
|
||||
|
||||
**路径 1:API 返回 compact_boundary**(已实现)
|
||||
|
||||
```typescript
|
||||
// QueryEngine.ts:946-962
|
||||
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) {
|
||||
const mutableBoundaryIdx = this.mutableMessages.length - 1
|
||||
if (mutableBoundaryIdx > 0) {
|
||||
this.mutableMessages.splice(0, mutableBoundaryIdx) // 清理旧消息
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**路径 2:本地 snip 压缩**(存根 — 永不执行)
|
||||
|
||||
```typescript
|
||||
// snipCompact.ts — 完整文件
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
import type { Message } from 'src/types/message';
|
||||
|
||||
export const isSnipMarkerMessage: (message: Message) => boolean = () => false;
|
||||
export const snipCompactIfNeeded: (
|
||||
messages: Message[],
|
||||
options?: { force?: boolean },
|
||||
) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({
|
||||
messages,
|
||||
executed: false, // 永远 false — 清理从不执行
|
||||
tokensFreed: 0,
|
||||
});
|
||||
export const isSnipRuntimeEnabled: () => boolean = () => false;
|
||||
export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false;
|
||||
export const SNIP_NUDGE_TEXT: string = '';
|
||||
```
|
||||
|
||||
`snipReplay` 回调依赖 `HISTORY_SNIP` feature flag,且调用的 `snipCompactIfNeeded` 永远返回 `executed: false`。
|
||||
|
||||
```typescript
|
||||
// QueryEngine.ts:933-942
|
||||
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
|
||||
if (snipResult !== undefined) {
|
||||
if (snipResult.executed) { // 永远是 false
|
||||
this.mutableMessages.length = 0
|
||||
this.mutableMessages.push(...snipResult.messages)
|
||||
}
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
|
||||
- 在长时间 SDK 会话中,如果 API 不频繁返回 `compact_boundary`,`mutableMessages` 会持续增长
|
||||
- 每条消息可能包含大量内容(工具输出、文件内容等),长时间运行可能导致 GB 级内存占用
|
||||
- 这是当前代码库中**最明确的未实现内存泄漏点**
|
||||
|
||||
---
|
||||
|
||||
## 17. LSP Opened Files Map 不收缩
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**代码注释描述**:`closeFile()` 存在但未与 compact 流程集成(`LSPServerManager.ts:373-375` 显式标注为 TODO)
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/services/lsp/LSPServerManager.ts:414-428` — `closeAllFiles()` 方法
|
||||
- `src/services/compact/postCompactCleanup.ts:81-88` — 集成调用
|
||||
|
||||
### 问题详情
|
||||
|
||||
`LSPServerManager` 中的 `openedFiles: Map<string, string>` 追踪所有通过 `didOpen` 打开的文件。`closeFile()` 方法存在可以发送 `didClose` 通知并清理 Map 条目,但代码注释明确标注:
|
||||
|
||||
```
|
||||
NOTE: Currently available but not yet integrated with compact flow.
|
||||
TODO: Integrate with compact - call closeFile() when compact removes files from context
|
||||
```
|
||||
|
||||
长时间会话中,每次读取/编辑文件都会通过 `openFile()` 添加条目,但 compaction 不会清理这些条目,导致 Map 无限增长。
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **添加 `closeAllFiles()` 方法**:遍历 `openedFiles` Map,对每个文件发送 `didClose` 通知,然后清空 Map。Best-effort 错误处理。
|
||||
|
||||
```typescript
|
||||
async function closeAllFiles(): Promise<void> {
|
||||
const entries = [...openedFiles.entries()]
|
||||
openedFiles.clear()
|
||||
for (const [fileUri, serverName] of entries) {
|
||||
const server = servers.get(serverName)
|
||||
if (!server || server.state !== 'running') continue
|
||||
try {
|
||||
await server.sendNotification('textDocument/didClose', {
|
||||
textDocument: { uri: fileUri },
|
||||
})
|
||||
} catch {
|
||||
// Best-effort — server may have stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **集成到 `postCompactCleanup`**:在 compaction 后自动调用 `closeAllFiles()`,释放所有 LSP 服务器端的文件状态。
|
||||
|
||||
```typescript
|
||||
// postCompactCleanup.ts
|
||||
try {
|
||||
const lspManager = getLspServerManager()
|
||||
if (lspManager) {
|
||||
await lspManager.closeAllFiles()
|
||||
}
|
||||
} catch {
|
||||
// LSP module may not be available in all environments
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
```
|
||||
确认已实现 (12): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #10 MCP缓冲区
|
||||
已修复 (7): #7 语法加载 #8 NO_FLICKER #9 RC权限 #11 LRU缓存键 #12 snipCompact #17 LSP文件追踪 #18 Permission Polling
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
| 修复项 | 测试文件 | 测试数 |
|
||||
|--------|----------|--------|
|
||||
| #12 snipCompact | `src/services/compact/__tests__/snipCompact.test.ts` | 17 |
|
||||
| #12 snipProjection | `src/services/compact/__tests__/snipProjection.test.ts` | 11 |
|
||||
| #8 StreamingToolExecutor | `src/services/tools/__tests__/StreamingToolExecutor.test.ts` | 7 |
|
||||
| #9 RC 权限 | `src/hooks/__tests__/replBridgePermissionHandlers.test.ts` | 8 |
|
||||
| #11 FileStateCache | `src/utils/__tests__/fileStateCache.test.ts` | 22 |
|
||||
| #7 语言注册 | `packages/color-diff-napi/src/__tests__/language-registration.test.ts` | 7 |
|
||||
| #18 Permission Polling | `src/hooks/__tests__/swarmPermissionPoller.test.ts` | 6 |
|
||||
| #17 LSP Opened Files | `src/services/lsp/__tests__/closeAllFiles.test.ts` | 5 |
|
||||
| **总计** | **8 个测试文件** | **83** |
|
||||
```
|
||||
|
||||
### 需要关注的优先级
|
||||
|
||||
1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复**
|
||||
2. ~~**P1 — 语法按需加载回退**~~ **已修复**
|
||||
3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复**
|
||||
4. ~~**P2 — 空闲渲染循环**~~ **已确认完整**
|
||||
5. ~~**P2 — Permission Polling Interval**~~ **已修复**
|
||||
6. ~~**P2 — LSP Opened Files Map**~~ **已修复**:closeAllFiles() 集成到 postCompactCleanup
|
||||
103
docs/memory-peak-analysis.md
Normal file
103
docs/memory-peak-analysis.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 内存与性能峰值分析报告
|
||||
|
||||
> 进程 bun,RSS 基线 **682 MB**,最差 **1.8 GB** | 2026-05-02 | **调研完成**(12 轮迭代)
|
||||
> 修复 commit:`ef10ad28` + `ab0bbbc4`(降 100-300 MB)| 架构限制:Bun mimalloc/JSC 不归还内存页(~150-250 MB 永久占用)
|
||||
|
||||
## 已修复(10 项)
|
||||
|
||||
| 问题 | 原峰值 | 修复 | 位置 |
|
||||
|------|--------|------|------|
|
||||
| 流式字符串拼接 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 处理代码 |
|
||||
|
||||
## P0 — 核心瓶颈(6 项)
|
||||
|
||||
| # | 问题 | 峰值 | 位置 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 消息数组 7-8x spread 拷贝(turn 尾部 3-4 份同时驻留) | 120-320 MB | `query.ts` 7 处(:477,:491,:897,:1135,:1745,:1857,:1878) | 去掉 spread / 传引用 / 改 push |
|
||||
| 2 | AutoCompact 时序缺陷(检查在 API 前,增长在 API 后) | API 超限 | `query.ts:575` | 加入预测式阈值检查 |
|
||||
| 3 | reactiveCompact 空存根(API 413 时无紧急压缩) | 无降级 | `reactiveCompact.ts` 全文 | 实现真实逻辑 |
|
||||
| 4 | buildMessageLookups 8 Map/Set 重建(流式每个 delta 触发) | GC STW 100-173ms | `Messages.tsx:519` | 增量更新 / 拆分 useMemo 链 |
|
||||
| 5 | useDeferredValue 双缓冲 | 100-200 MB | `REPL.tsx:1569` | React 调度机制固有,优化空间有限 |
|
||||
| 6 | Compact 峰值窗口(preCompactReadFileState + summary + attachments) | 20-80 MB | `compact.ts:524-644` | 提前释放 preCompactReadFileState/summaryResponse |
|
||||
|
||||
## P1 — 重要瓶颈(14 项)
|
||||
|
||||
| # | 问题 | 峰值 | 位置 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| 7 | OpenAI/Gemini/Grok 兼容层 O(n²) 拼接 | 25-75 MB | 3 文件 9 处(`openai/index.ts:386`, `gemini/index.ts:148`, `grok/index.ts:163`) | 改数组累积(同 claude.ts 模式) |
|
||||
| 8 | messages.ts O(n²) 拼接 | 10-25 MB | `messages.ts:3252,3268` | 改数组累积 |
|
||||
| 9 | highlight.js 全量 192 语言(仅需 26 种) | 8-12 MB | `color-diff-napi/index.ts:21` | 自定义构建 |
|
||||
| 10 | hlLineCache 模块级单例 2048 条目 | ~4 MB | `color-diff-napi/index.ts:508` | 改 LRU + size 上限 |
|
||||
| 11 | colorFileCache 3x 代码存储 | 2-5 MB | `HighlightedCode.tsx:14` | 移除 value 中 code 字段 |
|
||||
| 12 | 虚拟滚动 200 组件常驻 | 50 MB | `useVirtualScroll.ts` | 降低 OVERSCAN_ROWS / MAX_MOUNTED_ITEMS |
|
||||
| 13 | FileReadTool 大文件(输出上限 100K 字符,但读取期间完整加载) | 临时数 MB | `FileReadTool.ts:342` | 读取前检测大小,流式截断 |
|
||||
| 14 | Session 恢复全量加载(磁盘→JSON→REPL 三阶段) | 200-300 MB | `sessionStorage.ts:3482` | 流式 JSONL / 增量恢复 |
|
||||
| 15 | Session 写入 100MB 累积 | ~100 MB | `sessionStorage.ts:652` | 流式写入 |
|
||||
| 16 | Forked Agent FileStateCache 完整克隆 | 50N MB | `forkedAgent.ts:382` | 共享/分层缓存(agent 用 10MB) |
|
||||
| 17 | GC 阈值 350MB < 基线(每秒无意义强制 GC) | CPU 浪费 | `cli/print.ts:554` | 提高到 800MB+ |
|
||||
| 18 | PDF 100 页处理 | ~100 MB | `apiLimits.ts:54` | 分页流式处理 |
|
||||
| 19 | 图片单张处理(base64→解码→resize) | ~16 MB/张 | `apiLimits.ts:22` | 流式 resize |
|
||||
| 20 | token 估算 ±25-50% 误差放大时序问题 | 阈值不准 | `tokenEstimation.ts:215` | 内容类型感知估算 |
|
||||
|
||||
## P2 — 次要问题(10 项)
|
||||
|
||||
| # | 问题 | 峰值 | 位置 |
|
||||
|---|------|------|------|
|
||||
| 21 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` |
|
||||
| 22 | MCP Tool Schema 双重存储 | ~40 MB | `manager.ts:73` + `AppStateStore.ts:175` |
|
||||
| 23 | ContentReplacementState 单调增长 | 0.5-2 MB | `toolResultStorage.ts:390` |
|
||||
| 24 | Perfetto 100K 事件 | ~30 MB | `perfettoTracing.ts:106` |
|
||||
| 25 | StreamingMarkdown 双渲染 | 临时 | `Markdown.tsx:185` |
|
||||
| 26 | MarkdownTable 3 次遍历 | CPU 峰值 | `MarkdownTable.tsx:99` |
|
||||
| 27 | 搜索索引 WeakMap | 5-10 MB | `transcriptSearch.ts:17` |
|
||||
| 28 | ACP FileStateCache/会话 | 50 MB | `acp/agent.ts:554` |
|
||||
| 29 | Agent initialMessages 浅拷贝 | 1-5 MB/agent | `runAgent.ts:382` |
|
||||
| 30 | Hook 结果累积 | ~1 MB+ | `toolExecution.ts:1474` |
|
||||
|
||||
## CPU / 渲染热点
|
||||
|
||||
| # | 问题 | 影响 | 位置 |
|
||||
|---|------|------|------|
|
||||
| C2 | Ink 每次 React commit 触发 Yoga 布局 | ~1-3ms/commit | `reconciler.ts:279` → `ink.tsx:323` |
|
||||
| C3 | MessageRow 挂载 ~1.5ms(React/Yoga/Ink 管线开销) | 批量挂载 ~290ms 卡顿 | `useVirtualScroll.ts` |
|
||||
| C4 | 布局偏移触发全屏 damage | O(rows×cols) | `ink.tsx:655-661` |
|
||||
| C9 | 同步 fs 操作阻塞主线程 | 间歇卡顿 | `projectOnboardingState.ts:20` 等 |
|
||||
|
||||
已有缓解:React ConcurrentRoot 批处理、帧率限制 16ms、虚拟滚动 overscan 80 + SLIDE_STEP=25 + useDeferredValue、Markdown tokenCache LRU-500 + hasMarkdownSyntax 快速路径、Yoga 增量缓存。
|
||||
|
||||
## 已否认(12 轮汇总)
|
||||
|
||||
VSZ 516 GB 是虚拟映射 | Zod ~650KB | Markdown LRU-500 已优化 | useSkillsChange/useSettingsChange 正确 cleanup | useInboxPoller 收敛设计(非循环)| React Compiler `_c(N)` 未使用 | File watchers ~5KB | React reconciler WeakMap + freeRecursive | Ink 屏幕缓冲 ~86KB | CharPool/HyperlinkPool ~1-5MB 5min 重置 | AWS/Google/Azure SDK 均懒加载 | Sentry 空实现 | useCallback 闭包通过 messagesRef 规避(无泄漏)| MCP stderrHandler 有 64MB cap + cleanup | useRef 有 clearConversation/compact 清理 | apiMetricsRef turn 结束重置 | useEffect 有 cleanup 函数 | lodash-es tree-shakable | AppState useSyncExternalStore 仅相关切片更新 | SDK 无全局重试队列 | Ink unmount 有清理
|
||||
|
||||
## 结论
|
||||
|
||||
**内存根因排序**:
|
||||
1. 消息数组 7-8x spread 拷贝(120-320 MB)— 核心瓶颈
|
||||
2. useDeferredValue 双缓冲 + React useMemo 链全量重算(100-200 MB + GC STW)
|
||||
3. Session 恢复/写入峰值(200-300 MB)
|
||||
4. AutoCompact 时序缺陷 + reactiveCompact 空存根(API 超限风险)
|
||||
5. Forked Agent FileStateCache 克隆(50N MB)
|
||||
6. 虚拟滚动 200 组件 ~50MB 常驻
|
||||
7. Bun/JSC 不归还内存页(架构级)
|
||||
|
||||
**CPU 根因**:useInboxPoller 每秒轮询 → React commit → Yoga 布局 → 全屏 Ink diff 完整管线。Markdown 渲染批量挂载时 ~290ms 卡顿。
|
||||
|
||||
**预估优化空间**:
|
||||
|
||||
| 优先级 | 措施数 | 预估降低 |
|
||||
|--------|--------|----------|
|
||||
| P0 | 6 | 240-600 MB |
|
||||
| P1 | 14 | 300-600 MB |
|
||||
| P2 | 10 | 80-200 MB |
|
||||
| **合计** | **30 项** | **620-1400 MB** |
|
||||
|
||||
理论可从 400-700 MB 降至 **200-350 MB**(受 mimalloc/JSC 架构限制约束)。
|
||||
@@ -12,12 +12,12 @@ Claude Code 将文件操作拆分为三个独立工具——这不是功能划
|
||||
|
||||
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|
||||
|------|---------|---------|---------|
|
||||
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` |
|
||||
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: 100,000` |
|
||||
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||
|
||||
<Tip>
|
||||
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
|
||||
Read 的 `maxResultSizeChars` 为 100,000(100KB)。超出此阈值的结果会被持久化到磁盘,减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。
|
||||
</Tip>
|
||||
|
||||
## FileRead:多模态文件读取引擎
|
||||
|
||||
40
knip.json
40
knip.json
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/entrypoints/cli.tsx"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": ["src/types/**", "src/**/*.d.ts"],
|
||||
"ignoreDependencies": [
|
||||
"@ant/*",
|
||||
"react-compiler-runtime",
|
||||
"@anthropic-ai/mcpb",
|
||||
"@anthropic-ai/sandbox-runtime"
|
||||
],
|
||||
"ignoreBinaries": ["bun"],
|
||||
"workspaces": {
|
||||
"packages/*": {
|
||||
"entry": ["src/index.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/@ant/*": {
|
||||
"ignore": ["**"]
|
||||
}
|
||||
}
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/entrypoints/cli.tsx"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": ["src/types/**", "src/**/*.d.ts"],
|
||||
"ignoreDependencies": [
|
||||
"@ant/*",
|
||||
"react-compiler-runtime",
|
||||
"@anthropic-ai/mcpb",
|
||||
"@anthropic-ai/sandbox-runtime"
|
||||
],
|
||||
"ignoreBinaries": ["bun"],
|
||||
"workspaces": {
|
||||
"packages/*": {
|
||||
"entry": ["src/index.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/@ant/*": {
|
||||
"ignore": ["**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.10.4",
|
||||
"version": "2.0.5",
|
||||
"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,7 +22,7 @@
|
||||
"repl"
|
||||
],
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"ccb": "dist/cli-node.js",
|
||||
@@ -48,9 +48,12 @@
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build:vite",
|
||||
"lint": "biome lint src/",
|
||||
"lint:fix": "biome lint --fix src/",
|
||||
"format": "biome format --write src/",
|
||||
"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",
|
||||
@@ -62,7 +65,7 @@
|
||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:all": "bun run typecheck && bun test",
|
||||
"precheck": "bun run typecheck && bun run check:fix && bun test",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -73,11 +76,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@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",
|
||||
@@ -164,11 +167,13 @@
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
"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.4.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"marked": "^17.0.6",
|
||||
@@ -216,5 +221,13 @@
|
||||
"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"
|
||||
],
|
||||
"*.{json,jsonc}": [
|
||||
"biome format --write --no-errors-on-unmatched"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@ant/claude-for-chrome-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
"name": "@ant/claude-for-chrome-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,546 +1,546 @@
|
||||
export const BROWSER_TOOLS = [
|
||||
{
|
||||
name: "javascript_tool",
|
||||
name: 'javascript_tool',
|
||||
description:
|
||||
"Execute JavaScript code in the context of the current page. The code runs in the page's context and can interact with the DOM, window object, and page variables. Returns the result of the last expression or any thrown errors. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description: "Must be set to 'javascript_exec'",
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"The JavaScript code to execute. The code will be evaluated in the page context. The result of the last expression will be returned automatically. Do NOT use 'return' statements - just write the expression you want to evaluate (e.g., 'window.myData.value' not 'return window.myData.value'). You can access and modify the DOM, call page functions, and interact with page variables.",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to execute the code in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["action", "text", "tabId"],
|
||||
required: ['action', 'text', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_page",
|
||||
name: 'read_page',
|
||||
description:
|
||||
"Get an accessibility tree representation of elements on the page. By default returns all elements including non-visible ones. Output is limited to 50000 characters by default. If the output exceeds this limit, you will receive an error asking you to specify a smaller depth or focus on a specific element using ref_id. Optionally filter for only interactive elements. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
filter: {
|
||||
type: "string",
|
||||
enum: ["interactive", "all"],
|
||||
type: 'string',
|
||||
enum: ['interactive', 'all'],
|
||||
description:
|
||||
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to read from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
depth: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.",
|
||||
'Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.',
|
||||
},
|
||||
ref_id: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.",
|
||||
'Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.',
|
||||
},
|
||||
max_chars: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.",
|
||||
'Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.',
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find",
|
||||
name: 'find',
|
||||
description:
|
||||
'Find elements on the page using natural language. Can search for elements by their purpose (e.g., "search bar", "login button") or by text content (e.g., "organic mango product"). Returns up to 20 matching elements with references that can be used with other tools. If more than 20 matches exist, you\'ll be notified to use a more specific query. If you don\'t have a valid tab ID, use tabs_context_mcp first to get available tabs.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to search in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["query", "tabId"],
|
||||
required: ['query', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form_input",
|
||||
name: 'form_input',
|
||||
description:
|
||||
"Set values in form elements using element reference ID from the read_page tool. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
ref: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
|
||||
},
|
||||
value: {
|
||||
type: ["string", "boolean", "number"],
|
||||
type: ['string', 'boolean', 'number'],
|
||||
description:
|
||||
"The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number",
|
||||
'The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to set form value in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["ref", "value", "tabId"],
|
||||
required: ['ref', 'value', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "computer",
|
||||
name: 'computer',
|
||||
description: `Use a mouse and keyboard to interact with a web browser, and take screenshots. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.\n* Whenever you intend to click on an element like an icon, you should consult a screenshot to determine the coordinates of the element before moving the cursor.\n* If you tried clicking on a program or link but it failed to load, even after waiting, try adjusting your click location so that the tip of the cursor visually falls on the element that you want to click.\n* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
enum: [
|
||||
"left_click",
|
||||
"right_click",
|
||||
"type",
|
||||
"screenshot",
|
||||
"wait",
|
||||
"scroll",
|
||||
"key",
|
||||
"left_click_drag",
|
||||
"double_click",
|
||||
"triple_click",
|
||||
"zoom",
|
||||
"scroll_to",
|
||||
"hover",
|
||||
'left_click',
|
||||
'right_click',
|
||||
'type',
|
||||
'screenshot',
|
||||
'wait',
|
||||
'scroll',
|
||||
'key',
|
||||
'left_click_drag',
|
||||
'double_click',
|
||||
'triple_click',
|
||||
'zoom',
|
||||
'scroll_to',
|
||||
'hover',
|
||||
],
|
||||
description:
|
||||
"The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.",
|
||||
'The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.',
|
||||
},
|
||||
coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.",
|
||||
'(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.',
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'The text to type (for `type` action) or the key(s) to press (for `key` action). For `key` action: Provide space-separated keys (e.g., "Backspace Backspace Delete"). Supports keyboard shortcuts using the platform\'s modifier key (use "cmd" on Mac, "ctrl" on Windows/Linux, e.g., "cmd+a" or "ctrl+a" for select all).',
|
||||
},
|
||||
duration: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 30,
|
||||
description:
|
||||
"The number of seconds to wait. Required for `wait`. Maximum 30 seconds.",
|
||||
'The number of seconds to wait. Required for `wait`. Maximum 30 seconds.',
|
||||
},
|
||||
scroll_direction: {
|
||||
type: "string",
|
||||
enum: ["up", "down", "left", "right"],
|
||||
description: "The direction to scroll. Required for `scroll`.",
|
||||
type: 'string',
|
||||
enum: ['up', 'down', 'left', 'right'],
|
||||
description: 'The direction to scroll. Required for `scroll`.',
|
||||
},
|
||||
scroll_amount: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
description:
|
||||
"The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.",
|
||||
'The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.',
|
||||
},
|
||||
start_coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The starting coordinates for `left_click_drag`.",
|
||||
'(x, y): The starting coordinates for `left_click_drag`.',
|
||||
},
|
||||
region: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 4,
|
||||
maxItems: 4,
|
||||
description:
|
||||
"(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.",
|
||||
'(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.',
|
||||
},
|
||||
repeat: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description:
|
||||
"Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.",
|
||||
'Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.',
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Required for `scroll_to` action. Can be used as alternative to `coordinate` for click actions.',
|
||||
},
|
||||
modifiers: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Modifier keys for click actions. Supports: "ctrl", "shift", "alt", "cmd" (or "meta"), "win" (or "windows"). Can be combined with "+" (e.g., "ctrl+shift", "cmd+alt"). Optional.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to execute the action on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
required: ['action', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "navigate",
|
||||
name: 'navigate',
|
||||
description:
|
||||
"Navigate to a URL, or go forward/back in browser history. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'The URL to navigate to. Can be provided with or without protocol (defaults to https://). Use "forward" to go forward in history or "back" to go back in history.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to navigate. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["url", "tabId"],
|
||||
required: ['url', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resize_window",
|
||||
name: 'resize_window',
|
||||
description:
|
||||
"Resize the current browser window to specified dimensions. Useful for testing responsive designs or setting up specific screen sizes. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
width: {
|
||||
type: "number",
|
||||
description: "Target window width in pixels",
|
||||
type: 'number',
|
||||
description: 'Target window width in pixels',
|
||||
},
|
||||
height: {
|
||||
type: "number",
|
||||
description: "Target window height in pixels",
|
||||
type: 'number',
|
||||
description: 'Target window height in pixels',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to get the window for. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["width", "height", "tabId"],
|
||||
required: ['width', 'height', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gif_creator",
|
||||
name: 'gif_creator',
|
||||
description:
|
||||
"Manage GIF recording and export for browser automation sessions. Control when to start/stop recording browser actions (clicks, scrolls, navigation), then export as an animated GIF with visual overlays (click indicators, action labels, progress bar, watermark). All operations are scoped to the tab's group. When starting recording, take a screenshot immediately after to capture the initial state as the first frame. When stopping recording, take a screenshot immediately before to capture the final state as the last frame. For export, either provide 'coordinate' to drag/drop upload to a page element, or set 'download: true' to download the GIF.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["start_recording", "stop_recording", "export", "clear"],
|
||||
type: 'string',
|
||||
enum: ['start_recording', 'stop_recording', 'export', 'clear'],
|
||||
description:
|
||||
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to identify which tab group this operation applies to",
|
||||
'Tab ID to identify which tab group this operation applies to',
|
||||
},
|
||||
download: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
|
||||
},
|
||||
options: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
description:
|
||||
"Optional GIF enhancement options for 'export' action. Properties: showClickIndicators (bool), showDragPaths (bool), showActionLabels (bool), showProgressBar (bool), showWatermark (bool), quality (number 1-30). All default to true except quality (default: 10).",
|
||||
properties: {
|
||||
showClickIndicators: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Show orange circles at click locations (default: true)",
|
||||
'Show orange circles at click locations (default: true)',
|
||||
},
|
||||
showDragPaths: {
|
||||
type: "boolean",
|
||||
description: "Show red arrows for drag actions (default: true)",
|
||||
type: 'boolean',
|
||||
description: 'Show red arrows for drag actions (default: true)',
|
||||
},
|
||||
showActionLabels: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Show black labels describing actions (default: true)",
|
||||
'Show black labels describing actions (default: true)',
|
||||
},
|
||||
showProgressBar: {
|
||||
type: "boolean",
|
||||
description: "Show orange progress bar at bottom (default: true)",
|
||||
type: 'boolean',
|
||||
description: 'Show orange progress bar at bottom (default: true)',
|
||||
},
|
||||
showWatermark: {
|
||||
type: "boolean",
|
||||
description: "Show Claude logo watermark (default: true)",
|
||||
type: 'boolean',
|
||||
description: 'Show Claude logo watermark (default: true)',
|
||||
},
|
||||
quality: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10",
|
||||
'GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
required: ['action', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload_image",
|
||||
name: 'upload_image',
|
||||
description:
|
||||
"Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.",
|
||||
'Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
imageId: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Use this for file inputs (especially hidden ones) or specific elements. Provide either ref or coordinate, not both.',
|
||||
},
|
||||
coordinate: {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
items: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
},
|
||||
description:
|
||||
"Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.",
|
||||
'Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID where the target element is located. This is where the image will be uploaded to.",
|
||||
'Tab ID where the target element is located. This is where the image will be uploaded to.',
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional filename for the uploaded file (default: "image.png")',
|
||||
},
|
||||
},
|
||||
required: ["imageId", "tabId"],
|
||||
required: ['imageId', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_page_text",
|
||||
name: 'get_page_text',
|
||||
description:
|
||||
"Extract raw text content from the page, prioritizing article content. Ideal for reading articles, blog posts, or other text-heavy pages. Returns plain text without HTML formatting. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to extract text from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tabs_context_mcp",
|
||||
title: "Tabs Context",
|
||||
name: 'tabs_context_mcp',
|
||||
title: 'Tabs Context',
|
||||
description:
|
||||
"Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.",
|
||||
'Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
createIfEmpty: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.",
|
||||
'Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tabs_create_mcp",
|
||||
title: "Tabs Create",
|
||||
name: 'tabs_create_mcp',
|
||||
title: 'Tabs Create',
|
||||
description:
|
||||
"Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.",
|
||||
'Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_plan",
|
||||
name: 'update_plan',
|
||||
description:
|
||||
"Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.",
|
||||
'Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.',
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
domains: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
type: 'array' as const,
|
||||
items: { type: 'string' as const },
|
||||
description:
|
||||
"List of domains you will visit (e.g., ['github.com', 'stackoverflow.com']). These domains will be approved for the session when the user accepts the plan.",
|
||||
},
|
||||
approach: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
type: 'array' as const,
|
||||
items: { type: 'string' as const },
|
||||
description:
|
||||
"High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.",
|
||||
'High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.',
|
||||
},
|
||||
},
|
||||
required: ["domains", "approach"],
|
||||
required: ['domains', 'approach'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_console_messages",
|
||||
name: 'read_console_messages',
|
||||
description:
|
||||
"Read browser console messages (console.log, console.error, console.warn, etc.) from a specific tab. Useful for debugging JavaScript errors, viewing application logs, or understanding what's happening in the browser console. Returns console messages from the current domain only. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs. IMPORTANT: Always provide a pattern to filter messages - without a pattern, you may get too many irrelevant messages.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to read console messages from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
onlyErrors: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"If true, only return error and exception messages. Default is false (return all message types).",
|
||||
'If true, only return error and exception messages. Default is false (return all message types).',
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
'If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.',
|
||||
},
|
||||
pattern: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Regex pattern to filter console messages. Only messages matching this pattern will be returned (e.g., 'error|warning' to find errors and warnings, 'MyApp' to filter app-specific logs). You should always provide a pattern to avoid getting too many irrelevant messages.",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum number of messages to return. Defaults to 100. Increase only if you need more results.",
|
||||
'Maximum number of messages to return. Defaults to 100. Increase only if you need more results.',
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_network_requests",
|
||||
name: 'read_network_requests',
|
||||
description:
|
||||
"Read HTTP network requests (XHR, Fetch, documents, images, etc.) from a specific tab. Useful for debugging API calls, monitoring network activity, or understanding what requests a page is making. Returns all network requests made by the current page, including cross-origin requests. Requests are automatically cleared when the page navigates to a different domain. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to read network requests from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
urlPattern: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Optional URL pattern to filter requests. Only requests whose URL contains this string will be returned (e.g., '/api/' to filter API calls, 'example.com' to filter by domain).",
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
'If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.',
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum number of requests to return. Defaults to 100. Increase only if you need more results.",
|
||||
'Maximum number of requests to return. Defaults to 100. Increase only if you need more results.',
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shortcuts_list",
|
||||
name: 'shortcuts_list',
|
||||
description:
|
||||
"List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.",
|
||||
'List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to list shortcuts from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shortcuts_execute",
|
||||
name: 'shortcuts_execute',
|
||||
description:
|
||||
"Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.",
|
||||
'Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to execute the shortcut on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
shortcutId: {
|
||||
type: "string",
|
||||
description: "The ID of the shortcut to execute",
|
||||
type: 'string',
|
||||
description: 'The ID of the shortcut to execute',
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "switch_browser",
|
||||
name: 'switch_browser',
|
||||
description:
|
||||
"Switch which Chrome browser is used for browser automation. Call this when the user wants to connect to a different Chrome browser. Broadcasts a connection request to all Chrome browsers with the extension installed — the user clicks 'Connect' in the desired browser.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
|
||||
export { BROWSER_TOOLS } from "./browserTools.js";
|
||||
export { BridgeClient, createBridgeClient } from './bridgeClient.js'
|
||||
export { BROWSER_TOOLS } from './browserTools.js'
|
||||
export {
|
||||
createChromeSocketClient,
|
||||
createClaudeForChromeMcpServer,
|
||||
} from "./mcpServer.js";
|
||||
export { localPlatformLabel } from "./types.js";
|
||||
} from './mcpServer.js'
|
||||
export { localPlatformLabel } from './types.js'
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
@@ -12,4 +12,4 @@ export type {
|
||||
Logger,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import { createBridgeClient } from "./bridgeClient.js";
|
||||
import { BROWSER_TOOLS } from "./browserTools.js";
|
||||
import { createMcpSocketClient } from "./mcpSocketClient.js";
|
||||
import { createMcpSocketPool } from "./mcpSocketPool.js";
|
||||
import { handleToolCall } from "./toolCalls.js";
|
||||
import type { ClaudeForChromeContext, SocketClient } from "./types.js";
|
||||
import { createBridgeClient } from './bridgeClient.js'
|
||||
import { BROWSER_TOOLS } from './browserTools.js'
|
||||
import { createMcpSocketClient } from './mcpSocketClient.js'
|
||||
import { createMcpSocketPool } from './mcpSocketPool.js'
|
||||
import { handleToolCall } from './toolCalls.js'
|
||||
import type { ClaudeForChromeContext, SocketClient } from './types.js'
|
||||
|
||||
/**
|
||||
* Create the socket/bridge client for the Chrome extension MCP server.
|
||||
@@ -24,23 +24,22 @@ export function createChromeSocketClient(
|
||||
? createBridgeClient(context)
|
||||
: context.getSocketPaths
|
||||
? createMcpSocketPool(context)
|
||||
: createMcpSocketClient(context);
|
||||
: createMcpSocketClient(context)
|
||||
}
|
||||
|
||||
export function createClaudeForChromeMcpServer(
|
||||
context: ClaudeForChromeContext,
|
||||
existingSocketClient?: SocketClient,
|
||||
): Server {
|
||||
const { serverName, logger } = context;
|
||||
const { serverName, logger } = context
|
||||
|
||||
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
|
||||
const socketClient =
|
||||
existingSocketClient ?? createChromeSocketClient(context);
|
||||
const socketClient = existingSocketClient ?? createChromeSocketClient(context)
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: serverName,
|
||||
version: "1.0.0",
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -48,49 +47,49 @@ export function createClaudeForChromeMcpServer(
|
||||
logging: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (context.isDisabled?.()) {
|
||||
return { tools: [] };
|
||||
return { tools: [] }
|
||||
}
|
||||
return {
|
||||
tools: context.bridgeConfig
|
||||
? BROWSER_TOOLS
|
||||
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
|
||||
};
|
||||
});
|
||||
: BROWSER_TOOLS.filter(t => t.name !== 'switch_browser'),
|
||||
}
|
||||
})
|
||||
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
|
||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`)
|
||||
|
||||
return handleToolCall(
|
||||
context,
|
||||
socketClient,
|
||||
request.params.name,
|
||||
request.params.arguments || {},
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
socketClient.setNotificationHandler((notification) => {
|
||||
socketClient.setNotificationHandler(notification => {
|
||||
logger.info(
|
||||
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
|
||||
);
|
||||
)
|
||||
server
|
||||
.notification({
|
||||
method: notification.method,
|
||||
params: notification.params,
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
// Server may not be connected yet (e.g., during startup or after disconnect)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return server;
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -1,327 +1,324 @@
|
||||
import { promises as fsPromises } from "fs";
|
||||
import { createConnection } from "net";
|
||||
import type { Socket } from "net";
|
||||
import { platform } from "os";
|
||||
import { dirname } from "path";
|
||||
import { promises as fsPromises } from 'fs'
|
||||
import { createConnection } from 'net'
|
||||
import type { Socket } from 'net'
|
||||
import { platform } from 'os'
|
||||
import { dirname } from 'path'
|
||||
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SocketConnectionError";
|
||||
super(message)
|
||||
this.name = 'SocketConnectionError'
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolRequest {
|
||||
method: string; // "execute_tool"
|
||||
method: string // "execute_tool"
|
||||
params?: {
|
||||
client_id?: string; // "desktop" | "claude-code"
|
||||
tool?: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
client_id?: string // "desktop" | "claude-code"
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
result?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type SocketMessage = ToolResponse | Notification;
|
||||
type SocketMessage = ToolResponse | Notification
|
||||
|
||||
function isToolResponse(message: SocketMessage): message is ToolResponse {
|
||||
return "result" in message || "error" in message;
|
||||
return 'result' in message || 'error' in message
|
||||
}
|
||||
|
||||
function isNotification(message: SocketMessage): message is Notification {
|
||||
return "method" in message && typeof message.method === "string";
|
||||
return 'method' in message && typeof message.method === 'string'
|
||||
}
|
||||
|
||||
class McpSocketClient {
|
||||
private socket: Socket | null = null;
|
||||
private connected = false;
|
||||
private connecting = false;
|
||||
private responseCallback: ((response: ToolResponse) => void) | null = null;
|
||||
private socket: Socket | null = null
|
||||
private connected = false
|
||||
private connecting = false
|
||||
private responseCallback: ((response: ToolResponse) => void) | null = null
|
||||
private notificationHandler: ((notification: Notification) => void) | null =
|
||||
null;
|
||||
private responseBuffer = Buffer.alloc(0);
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private reconnectDelay = 1000;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private context: ClaudeForChromeContext;
|
||||
null
|
||||
private responseBuffer = Buffer.alloc(0)
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
private context: ClaudeForChromeContext
|
||||
// When true, disables automatic reconnection. Used by McpSocketPool which
|
||||
// manages reconnection externally by rescanning available sockets.
|
||||
public disableAutoReconnect = false;
|
||||
public disableAutoReconnect = false
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
this.context = context
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
const { serverName, logger } = this.context
|
||||
|
||||
if (this.connecting) {
|
||||
logger.info(
|
||||
`[${serverName}] Already connecting, skipping duplicate attempt`,
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.connecting = true;
|
||||
this.closeSocket()
|
||||
this.connecting = true
|
||||
|
||||
const socketPath =
|
||||
this.context.getSocketPath?.() ?? this.context.socketPath;
|
||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
|
||||
const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath
|
||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`)
|
||||
|
||||
try {
|
||||
await this.validateSocketSecurity(socketPath);
|
||||
await this.validateSocketSecurity(socketPath)
|
||||
} catch (error) {
|
||||
this.connecting = false;
|
||||
logger.info(`[${serverName}] Security validation failed:`, error);
|
||||
this.connecting = false
|
||||
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
this.socket = createConnection(socketPath);
|
||||
this.socket = createConnection(socketPath)
|
||||
|
||||
// Timeout the initial connection attempt - if socket file exists but native
|
||||
// host is dead, the connect can hang indefinitely
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
logger.info(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
);
|
||||
this.closeSocket();
|
||||
this.scheduleReconnect();
|
||||
logger.info(`[${serverName}] Connection attempt timed out after 5000ms`)
|
||||
this.closeSocket()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, 5000);
|
||||
}, 5000)
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
logger.info(`[${serverName}] Successfully connected to bridge server`);
|
||||
});
|
||||
this.socket.on('connect', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.reconnectAttempts = 0
|
||||
logger.info(`[${serverName}] Successfully connected to bridge server`)
|
||||
})
|
||||
|
||||
this.socket.on("data", (data: Buffer) => {
|
||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data])
|
||||
|
||||
while (this.responseBuffer.length >= 4) {
|
||||
const length = this.responseBuffer.readUInt32LE(0);
|
||||
const length = this.responseBuffer.readUInt32LE(0)
|
||||
|
||||
if (this.responseBuffer.length < 4 + length) {
|
||||
break;
|
||||
break
|
||||
}
|
||||
|
||||
const messageBytes = this.responseBuffer.slice(4, 4 + length);
|
||||
this.responseBuffer = this.responseBuffer.slice(4 + length);
|
||||
const messageBytes = this.responseBuffer.slice(4, 4 + length)
|
||||
this.responseBuffer = this.responseBuffer.slice(4 + length)
|
||||
|
||||
try {
|
||||
const message = JSON.parse(
|
||||
messageBytes.toString("utf-8"),
|
||||
) as SocketMessage;
|
||||
messageBytes.toString('utf-8'),
|
||||
) as SocketMessage
|
||||
|
||||
if (isNotification(message)) {
|
||||
logger.info(
|
||||
`[${serverName}] Received notification: ${message.method}`,
|
||||
);
|
||||
)
|
||||
if (this.notificationHandler) {
|
||||
this.notificationHandler(message);
|
||||
this.notificationHandler(message)
|
||||
}
|
||||
} else if (isToolResponse(message)) {
|
||||
logger.info(`[${serverName}] Received tool response: ${message}`);
|
||||
this.handleResponse(message);
|
||||
logger.info(`[${serverName}] Received tool response: ${message}`)
|
||||
this.handleResponse(message)
|
||||
} else {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`);
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error);
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error)
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
this.socket.on("error", (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout);
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
if (
|
||||
error.code &&
|
||||
[
|
||||
"ECONNREFUSED", // Native host not listening (stale socket)
|
||||
"ECONNRESET", // Connection reset by peer
|
||||
"EPIPE", // Broken pipe (native host died mid-write)
|
||||
"ENOENT", // Socket file was deleted
|
||||
"EOPNOTSUPP", // Socket file exists but is not a valid socket
|
||||
"ECONNABORTED", // Connection aborted
|
||||
'ECONNREFUSED', // Native host not listening (stale socket)
|
||||
'ECONNRESET', // Connection reset by peer
|
||||
'EPIPE', // Broken pipe (native host died mid-write)
|
||||
'ENOENT', // Socket file was deleted
|
||||
'EOPNOTSUPP', // Socket file exists but is not a valid socket
|
||||
'ECONNABORTED', // Connection aborted
|
||||
].includes(error.code)
|
||||
) {
|
||||
this.scheduleReconnect();
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
this.socket.on("close", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
this.socket.on('close', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const { serverName, logger } = this.context;
|
||||
const { serverName, logger } = this.context
|
||||
|
||||
if (this.disableAutoReconnect) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
|
||||
return;
|
||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectAttempts++
|
||||
|
||||
// Give up after extended polling (~50 min). A new ensureConnected() call
|
||||
// from a tool request will restart the cycle if needed.
|
||||
const maxTotalAttempts = 100;
|
||||
const maxTotalAttempts = 100
|
||||
if (this.reconnectAttempts > maxTotalAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
|
||||
);
|
||||
this.reconnectAttempts = 0;
|
||||
return;
|
||||
)
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1),
|
||||
30000,
|
||||
);
|
||||
)
|
||||
|
||||
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
|
||||
this.reconnectAttempts
|
||||
})`,
|
||||
);
|
||||
)
|
||||
} else if (this.reconnectAttempts % 10 === 0) {
|
||||
// Log every 10th slow-poll attempt to avoid log spam
|
||||
logger.info(
|
||||
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.connect();
|
||||
}, delay);
|
||||
this.reconnectTimer = null
|
||||
void this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private handleResponse(response: ToolResponse): void {
|
||||
if (this.responseCallback) {
|
||||
const callback = this.responseCallback;
|
||||
this.responseCallback = null;
|
||||
callback(response);
|
||||
const callback = this.responseCallback
|
||||
this.responseCallback = null
|
||||
callback(response)
|
||||
}
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: Notification) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
this.notificationHandler = handler
|
||||
}
|
||||
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { serverName } = this.context;
|
||||
const { serverName } = this.context
|
||||
|
||||
if (this.connected && this.socket) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
if (!this.socket && !this.connecting) {
|
||||
await this.connect();
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
// Wait for connection with timeout
|
||||
return new Promise((resolve, reject) => {
|
||||
let checkTimeoutId: NodeJS.Timeout | null = null;
|
||||
let checkTimeoutId: NodeJS.Timeout | null = null
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (checkTimeoutId) {
|
||||
clearTimeout(checkTimeoutId);
|
||||
clearTimeout(checkTimeoutId)
|
||||
}
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
),
|
||||
);
|
||||
}, 5000);
|
||||
)
|
||||
}, 5000)
|
||||
|
||||
const checkConnection = () => {
|
||||
if (this.connected) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
} else {
|
||||
checkTimeoutId = setTimeout(checkConnection, 500);
|
||||
checkTimeoutId = setTimeout(checkConnection, 500)
|
||||
}
|
||||
};
|
||||
checkConnection();
|
||||
});
|
||||
}
|
||||
checkConnection()
|
||||
})
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
request: ToolRequest,
|
||||
timeoutMs = 30000,
|
||||
): Promise<ToolResponse> {
|
||||
const { serverName } = this.context;
|
||||
const { serverName } = this.context
|
||||
|
||||
if (!this.socket) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] Cannot send request: not connected`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
const socket = this.socket
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.responseCallback = null;
|
||||
this.responseCallback = null
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
)
|
||||
}, timeoutMs)
|
||||
|
||||
this.responseCallback = (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
};
|
||||
this.responseCallback = response => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
}
|
||||
|
||||
const requestJson = JSON.stringify(request);
|
||||
const requestBytes = Buffer.from(requestJson, "utf-8");
|
||||
const requestJson = JSON.stringify(request)
|
||||
const requestBytes = Buffer.from(requestJson, 'utf-8')
|
||||
|
||||
const lengthPrefix = Buffer.allocUnsafe(4);
|
||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
|
||||
const lengthPrefix = Buffer.allocUnsafe(4)
|
||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0)
|
||||
|
||||
const message = Buffer.concat([lengthPrefix, requestBytes]);
|
||||
socket.write(message);
|
||||
});
|
||||
const message = Buffer.concat([lengthPrefix, requestBytes])
|
||||
socket.write(message)
|
||||
})
|
||||
}
|
||||
|
||||
public async callTool(
|
||||
@@ -330,15 +327,15 @@ class McpSocketClient {
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
const request: ToolRequest = {
|
||||
method: "execute_tool",
|
||||
method: 'execute_tool',
|
||||
params: {
|
||||
client_id: this.context.clientTypeId,
|
||||
tool: name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return this.sendRequestWithRetry(request);
|
||||
return this.sendRequestWithRetry(request)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,23 +346,23 @@ class McpSocketClient {
|
||||
* and retry once.
|
||||
*/
|
||||
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
|
||||
const { serverName, logger } = this.context;
|
||||
const { serverName, logger } = this.context
|
||||
|
||||
try {
|
||||
return await this.sendRequest(request);
|
||||
return await this.sendRequest(request)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SocketConnectionError)) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
|
||||
);
|
||||
)
|
||||
|
||||
this.closeSocket();
|
||||
await this.ensureConnected();
|
||||
this.closeSocket()
|
||||
await this.ensureConnected()
|
||||
|
||||
return await this.sendRequest(request);
|
||||
return await this.sendRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,109 +374,109 @@ class McpSocketClient {
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connected;
|
||||
return this.connected
|
||||
}
|
||||
|
||||
private closeSocket(): void {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.end();
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
this.socket.removeAllListeners()
|
||||
this.socket.end()
|
||||
this.socket.destroy()
|
||||
this.socket = null
|
||||
}
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.reconnectAttempts = 0;
|
||||
this.responseBuffer = Buffer.alloc(0);
|
||||
this.responseCallback = null;
|
||||
this.closeSocket()
|
||||
this.reconnectAttempts = 0
|
||||
this.responseBuffer = Buffer.alloc(0)
|
||||
this.responseCallback = null
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.cleanup();
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
private async validateSocketSecurity(socketPath: string): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
if (platform() === "win32") {
|
||||
return;
|
||||
const { serverName, logger } = this.context
|
||||
if (platform() === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Validate the parent directory permissions if it's the socket directory
|
||||
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
|
||||
const dirPath = dirname(socketPath);
|
||||
const dirBasename = dirPath.split("/").pop() || "";
|
||||
const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-");
|
||||
const dirPath = dirname(socketPath)
|
||||
const dirBasename = dirPath.split('/').pop() || ''
|
||||
const isSocketDir = dirBasename.startsWith('claude-mcp-browser-bridge-')
|
||||
if (isSocketDir) {
|
||||
try {
|
||||
const dirStats = await fsPromises.stat(dirPath);
|
||||
const dirStats = await fsPromises.stat(dirPath)
|
||||
if (dirStats.isDirectory()) {
|
||||
const dirMode = dirStats.mode & 0o777;
|
||||
const dirMode = dirStats.mode & 0o777
|
||||
if (dirMode !== 0o700) {
|
||||
throw new Error(
|
||||
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
|
||||
8,
|
||||
)} (expected 0700). Directory may have been tampered with.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
const currentUid = process.getuid?.();
|
||||
const currentUid = process.getuid?.()
|
||||
if (currentUid !== undefined && dirStats.uid !== currentUid) {
|
||||
throw new Error(
|
||||
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
|
||||
`Potential security risk.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw dirError;
|
||||
if ((dirError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw dirError
|
||||
}
|
||||
// Directory doesn't exist yet - native host will create it
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(socketPath);
|
||||
const stats = await fsPromises.stat(socketPath)
|
||||
|
||||
if (!stats.isSocket()) {
|
||||
throw new Error(
|
||||
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const mode = stats.mode & 0o777;
|
||||
const mode = stats.mode & 0o777
|
||||
if (mode !== 0o600) {
|
||||
throw new Error(
|
||||
`[${serverName}] Insecure socket permissions: ${mode.toString(
|
||||
8,
|
||||
)} (expected 0600). Socket may have been tampered with.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const currentUid = process.getuid?.();
|
||||
const currentUid = process.getuid?.()
|
||||
if (currentUid !== undefined && stats.uid !== currentUid) {
|
||||
throw new Error(
|
||||
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
|
||||
`Potential security risk.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket security validation passed`);
|
||||
logger.info(`[${serverName}] Socket security validation passed`)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(
|
||||
`[${serverName}] Socket not found, will be created by server`,
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,7 +484,7 @@ class McpSocketClient {
|
||||
export function createMcpSocketClient(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketClient {
|
||||
return new McpSocketClient(context);
|
||||
return new McpSocketClient(context)
|
||||
}
|
||||
|
||||
export type { McpSocketClient };
|
||||
export type { McpSocketClient }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
createMcpSocketClient,
|
||||
SocketConnectionError,
|
||||
} from "./mcpSocketClient.js";
|
||||
import type { McpSocketClient } from "./mcpSocketClient.js";
|
||||
} from './mcpSocketClient.js'
|
||||
import type { McpSocketClient } from './mcpSocketClient.js'
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
|
||||
@@ -18,26 +18,29 @@ import type {
|
||||
* built from tabs_context_mcp responses.
|
||||
*/
|
||||
export class McpSocketPool {
|
||||
private clients: Map<string, McpSocketClient> = new Map();
|
||||
private tabRoutes: Map<number, string> = new Map();
|
||||
private context: ClaudeForChromeContext;
|
||||
private clients: Map<string, McpSocketClient> = new Map()
|
||||
private tabRoutes: Map<number, string> = new Map()
|
||||
private context: ClaudeForChromeContext
|
||||
private notificationHandler:
|
||||
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
| ((notification: {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}) => void)
|
||||
| null = null
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
this.context = context
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
this.notificationHandler = handler
|
||||
for (const client of this.clients.values()) {
|
||||
client.setNotificationHandler(handler);
|
||||
client.setNotificationHandler(handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,32 +48,30 @@ export class McpSocketPool {
|
||||
* Discover available sockets and ensure at least one is connected.
|
||||
*/
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { logger, serverName } = this.context;
|
||||
const { logger, serverName } = this.context
|
||||
|
||||
this.refreshClients();
|
||||
this.refreshClients()
|
||||
|
||||
// Try to connect any disconnected clients
|
||||
const connectPromises: Promise<boolean>[] = [];
|
||||
const connectPromises: Promise<boolean>[] = []
|
||||
for (const client of this.clients.values()) {
|
||||
if (!client.isConnected()) {
|
||||
connectPromises.push(
|
||||
client.ensureConnected().catch(() => false),
|
||||
);
|
||||
connectPromises.push(client.ensureConnected().catch(() => false))
|
||||
}
|
||||
}
|
||||
|
||||
if (connectPromises.length > 0) {
|
||||
await Promise.all(connectPromises);
|
||||
await Promise.all(connectPromises)
|
||||
}
|
||||
|
||||
const connectedCount = this.getConnectedClients().length;
|
||||
const connectedCount = this.getConnectedClients().length
|
||||
if (connectedCount === 0) {
|
||||
logger.info(`[${serverName}] No connected sockets in pool`);
|
||||
return false;
|
||||
logger.info(`[${serverName}] No connected sockets in pool`)
|
||||
return false
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
|
||||
return true;
|
||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,57 +83,57 @@ export class McpSocketPool {
|
||||
args: Record<string, unknown>,
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
if (name === "tabs_context_mcp") {
|
||||
return this.callTabsContext(args);
|
||||
if (name === 'tabs_context_mcp') {
|
||||
return this.callTabsContext(args)
|
||||
}
|
||||
|
||||
// Route by tabId if present
|
||||
const tabId = args.tabId as number | undefined;
|
||||
const tabId = args.tabId as number | undefined
|
||||
if (tabId !== undefined) {
|
||||
const socketPath = this.tabRoutes.get(tabId);
|
||||
const socketPath = this.tabRoutes.get(tabId)
|
||||
if (socketPath) {
|
||||
const client = this.clients.get(socketPath);
|
||||
const client = this.clients.get(socketPath)
|
||||
if (client?.isConnected()) {
|
||||
return client.callTool(name, args);
|
||||
return client.callTool(name, args)
|
||||
}
|
||||
}
|
||||
// Tab route not found or client disconnected — fall through to any connected
|
||||
}
|
||||
|
||||
// Fallback: use first connected client
|
||||
const connected = this.getConnectedClients();
|
||||
const connected = this.getConnectedClients()
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${this.context.serverName}] No connected sockets available`,
|
||||
);
|
||||
)
|
||||
}
|
||||
return connected[0]!.callTool(name, args);
|
||||
return connected[0]!.callTool(name, args)
|
||||
}
|
||||
|
||||
public async setPermissionMode(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void> {
|
||||
const connected = this.getConnectedClients();
|
||||
const connected = this.getConnectedClients()
|
||||
await Promise.all(
|
||||
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
|
||||
);
|
||||
connected.map(client => client.setPermissionMode(mode, allowedDomains)),
|
||||
)
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.getConnectedClients().length > 0;
|
||||
return this.getConnectedClients().length > 0
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.disconnect();
|
||||
client.disconnect()
|
||||
}
|
||||
this.clients.clear();
|
||||
this.tabRoutes.clear();
|
||||
this.clients.clear()
|
||||
this.tabRoutes.clear()
|
||||
}
|
||||
|
||||
private getConnectedClients(): McpSocketClient[] {
|
||||
return [...this.clients.values()].filter((c) => c.isConnected());
|
||||
return [...this.clients.values()].filter(c => c.isConnected())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,173 +143,173 @@ export class McpSocketPool {
|
||||
private async callTabsContext(
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const { logger, serverName } = this.context;
|
||||
const connected = this.getConnectedClients();
|
||||
const { logger, serverName } = this.context
|
||||
const connected = this.getConnectedClients()
|
||||
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] No connected sockets available`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// If only one client, skip merging overhead
|
||||
if (connected.length === 1) {
|
||||
const result = await connected[0]!.callTool("tabs_context_mcp", args);
|
||||
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!));
|
||||
return result;
|
||||
const result = await connected[0]!.callTool('tabs_context_mcp', args)
|
||||
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!))
|
||||
return result
|
||||
}
|
||||
|
||||
// Query all connected clients in parallel
|
||||
const results = await Promise.allSettled(
|
||||
connected.map(async (client) => {
|
||||
const result = await client.callTool("tabs_context_mcp", args);
|
||||
const socketPath = this.getSocketPathForClient(client);
|
||||
return { result, socketPath };
|
||||
connected.map(async client => {
|
||||
const result = await client.callTool('tabs_context_mcp', args)
|
||||
const socketPath = this.getSocketPathForClient(client)
|
||||
return { result, socketPath }
|
||||
}),
|
||||
);
|
||||
)
|
||||
|
||||
// Merge tab results
|
||||
const mergedTabs: unknown[] = [];
|
||||
this.tabRoutes.clear();
|
||||
const mergedTabs: unknown[] = []
|
||||
this.tabRoutes.clear()
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
if (settledResult.status !== 'fulfilled') {
|
||||
logger.info(
|
||||
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
|
||||
);
|
||||
continue;
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const { result, socketPath } = settledResult.value;
|
||||
this.updateTabRoutes(result, socketPath);
|
||||
const { result, socketPath } = settledResult.value
|
||||
this.updateTabRoutes(result, socketPath)
|
||||
|
||||
const tabs = this.extractTabs(result);
|
||||
const tabs = this.extractTabs(result)
|
||||
if (tabs) {
|
||||
mergedTabs.push(...tabs);
|
||||
mergedTabs.push(...tabs)
|
||||
}
|
||||
}
|
||||
|
||||
// Return merged result in the same format as the extension response
|
||||
if (mergedTabs.length > 0) {
|
||||
const tabListText = mergedTabs
|
||||
.map((t) => {
|
||||
const tab = t as { tabId: number; title: string; url: string };
|
||||
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
|
||||
.map(t => {
|
||||
const tab = t as { tabId: number; title: string; url: string }
|
||||
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`
|
||||
})
|
||||
.join("\n");
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: 'text',
|
||||
text: JSON.stringify({ availableTabs: mergedTabs }),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
type: 'text',
|
||||
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return first successful result as-is
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === "fulfilled") {
|
||||
return settledResult.value.result;
|
||||
if (settledResult.status === 'fulfilled') {
|
||||
return settledResult.value.result
|
||||
}
|
||||
}
|
||||
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] All sockets failed for tabs_context_mcp`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tab objects from a tool response to update routing table.
|
||||
*/
|
||||
private updateTabRoutes(result: unknown, socketPath: string): void {
|
||||
const tabs = this.extractTabs(result);
|
||||
if (!tabs) return;
|
||||
const tabs = this.extractTabs(result)
|
||||
if (!tabs) return
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab === "object" && tab !== null && "tabId" in tab) {
|
||||
const tabId = (tab as { tabId: number }).tabId;
|
||||
this.tabRoutes.set(tabId, socketPath);
|
||||
if (typeof tab === 'object' && tab !== null && 'tabId' in tab) {
|
||||
const tabId = (tab as { tabId: number }).tabId
|
||||
this.tabRoutes.set(tabId, socketPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractTabs(result: unknown): unknown[] | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
if (!result || typeof result !== 'object') return null
|
||||
|
||||
// Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } }
|
||||
const asResponse = result as {
|
||||
result?: { content?: Array<{ type: string; text?: string }> };
|
||||
};
|
||||
const content = asResponse.result?.content;
|
||||
if (!content || !Array.isArray(content)) return null;
|
||||
result?: { content?: Array<{ type: string; text?: string }> }
|
||||
}
|
||||
const content = asResponse.result?.content
|
||||
if (!content || !Array.isArray(content)) return null
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === "text" && item.text) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
const parsed = JSON.parse(item.text)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
// Handle { availableTabs: [...] } format
|
||||
if (parsed && Array.isArray(parsed.availableTabs)) {
|
||||
return parsed.availableTabs;
|
||||
return parsed.availableTabs
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
private getSocketPathForClient(client: McpSocketClient): string {
|
||||
for (const [path, c] of this.clients.entries()) {
|
||||
if (c === client) return path;
|
||||
if (c === client) return path
|
||||
}
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for available sockets and create/remove clients as needed.
|
||||
*/
|
||||
private refreshClients(): void {
|
||||
const socketPaths = this.getAvailableSocketPaths();
|
||||
const { logger, serverName } = this.context;
|
||||
const socketPaths = this.getAvailableSocketPaths()
|
||||
const { logger, serverName } = this.context
|
||||
|
||||
// Add new clients for newly discovered sockets
|
||||
for (const path of socketPaths) {
|
||||
if (!this.clients.has(path)) {
|
||||
logger.info(`[${serverName}] Adding socket to pool: ${path}`);
|
||||
logger.info(`[${serverName}] Adding socket to pool: ${path}`)
|
||||
const clientContext: ClaudeForChromeContext = {
|
||||
...this.context,
|
||||
socketPath: path,
|
||||
getSocketPath: undefined,
|
||||
getSocketPaths: undefined,
|
||||
};
|
||||
const client = createMcpSocketClient(clientContext);
|
||||
client.disableAutoReconnect = true;
|
||||
if (this.notificationHandler) {
|
||||
client.setNotificationHandler(this.notificationHandler);
|
||||
}
|
||||
this.clients.set(path, client);
|
||||
const client = createMcpSocketClient(clientContext)
|
||||
client.disableAutoReconnect = true
|
||||
if (this.notificationHandler) {
|
||||
client.setNotificationHandler(this.notificationHandler)
|
||||
}
|
||||
this.clients.set(path, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove clients for sockets that no longer exist
|
||||
for (const [path, client] of this.clients.entries()) {
|
||||
if (!socketPaths.includes(path)) {
|
||||
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`);
|
||||
client.disconnect();
|
||||
this.clients.delete(path);
|
||||
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`)
|
||||
client.disconnect()
|
||||
this.clients.delete(path)
|
||||
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
|
||||
if (socketPath === path) {
|
||||
this.tabRoutes.delete(tabId);
|
||||
this.tabRoutes.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,12 +317,12 @@ export class McpSocketPool {
|
||||
}
|
||||
|
||||
private getAvailableSocketPaths(): string[] {
|
||||
return this.context.getSocketPaths?.() ?? [];
|
||||
return this.context.getSocketPaths?.() ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSocketPool(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketPool {
|
||||
return new McpSocketPool(context);
|
||||
return new McpSocketPool(context)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import { SocketConnectionError } from "./mcpSocketClient.js";
|
||||
import { SocketConnectionError } from './mcpSocketClient.js'
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -16,21 +16,21 @@ export const handleToolCall = async (
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> => {
|
||||
// Handle permission mode changes locally (not forwarded to extension)
|
||||
if (name === "set_permission_mode") {
|
||||
return handleSetPermissionMode(socketClient, args);
|
||||
if (name === 'set_permission_mode') {
|
||||
return handleSetPermissionMode(socketClient, args)
|
||||
}
|
||||
|
||||
// Handle switch_browser outside the normal tool call flow (manages its own connection)
|
||||
if (name === "switch_browser") {
|
||||
return handleSwitchBrowser(context, socketClient);
|
||||
if (name === 'switch_browser') {
|
||||
return handleSwitchBrowser(context, socketClient)
|
||||
}
|
||||
|
||||
try {
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
const isConnected = await socketClient.ensureConnected()
|
||||
|
||||
context.logger.silly(
|
||||
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
|
||||
);
|
||||
)
|
||||
|
||||
if (isConnected) {
|
||||
return await handleToolCallConnected(
|
||||
@@ -39,28 +39,28 @@ export const handleToolCall = async (
|
||||
name,
|
||||
args,
|
||||
permissionOverrides,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return handleToolCallDisconnected(context);
|
||||
return handleToolCallDisconnected(context)
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context);
|
||||
return handleToolCallDisconnected(context)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: 'text',
|
||||
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleToolCallConnected(
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -69,119 +69,119 @@ async function handleToolCallConnected(
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> {
|
||||
const response = await socketClient.callTool(name, args, permissionOverrides);
|
||||
const response = await socketClient.callTool(name, args, permissionOverrides)
|
||||
|
||||
context.logger.silly(
|
||||
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
|
||||
);
|
||||
)
|
||||
|
||||
if (response === null || response === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
content: [{ type: 'text', text: 'Tool execution completed' }],
|
||||
}
|
||||
}
|
||||
|
||||
// Response will have either result or error field
|
||||
const { result, error } = response as {
|
||||
result?: { content: unknown[] | string };
|
||||
error?: { content: unknown[] | string };
|
||||
};
|
||||
result?: { content: unknown[] | string }
|
||||
error?: { content: unknown[] | string }
|
||||
}
|
||||
|
||||
// Determine which field has the content and whether it's an error
|
||||
const contentData = error || result;
|
||||
const isError = !!error;
|
||||
const contentData = error || result
|
||||
const isError = !!error
|
||||
|
||||
if (!contentData) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
content: [{ type: 'text', text: 'Tool execution completed' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (isError && isAuthenticationError(contentData.content)) {
|
||||
context.onAuthenticationError();
|
||||
context.onAuthenticationError()
|
||||
}
|
||||
|
||||
const { content } = contentData;
|
||||
const { content } = contentData
|
||||
|
||||
if (content && Array.isArray(content)) {
|
||||
if (isError) {
|
||||
return {
|
||||
content: content.map((item: unknown) => {
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||
return item
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
return { type: 'text', text: String(item) }
|
||||
}),
|
||||
isError: true,
|
||||
} as CallToolResult;
|
||||
} as CallToolResult
|
||||
}
|
||||
|
||||
const convertedContent = content.map((item: unknown) => {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
"type" in item &&
|
||||
"source" in item
|
||||
'type' in item &&
|
||||
'source' in item
|
||||
) {
|
||||
const typedItem = item;
|
||||
const typedItem = item
|
||||
if (
|
||||
typedItem.type === "image" &&
|
||||
typeof typedItem.source === "object" &&
|
||||
typedItem.type === 'image' &&
|
||||
typeof typedItem.source === 'object' &&
|
||||
typedItem.source !== null &&
|
||||
"data" in typedItem.source
|
||||
'data' in typedItem.source
|
||||
) {
|
||||
return {
|
||||
type: "image",
|
||||
type: 'image',
|
||||
data: typedItem.source.data,
|
||||
mimeType:
|
||||
"media_type" in typedItem.source
|
||||
? typedItem.source.media_type || "image/png"
|
||||
: "image/png",
|
||||
};
|
||||
'media_type' in typedItem.source
|
||||
? typedItem.source.media_type || 'image/png'
|
||||
: 'image/png',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||
return item
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
});
|
||||
return { type: 'text', text: String(item) }
|
||||
})
|
||||
|
||||
return {
|
||||
content: convertedContent,
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
} as CallToolResult
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof content === "string") {
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
content: [{ type: "text", text: content }],
|
||||
content: [{ type: 'text', text: content }],
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
} as CallToolResult
|
||||
}
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
);
|
||||
)
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(response) }],
|
||||
content: [{ type: 'text', text: JSON.stringify(response) }],
|
||||
isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolCallDisconnected(
|
||||
context: ClaudeForChromeContext,
|
||||
): CallToolResult {
|
||||
const text = context.onToolCallDisconnected();
|
||||
const text = context.onToolCallDisconnected()
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
content: [{ type: 'text', text }],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,28 +194,28 @@ async function handleSetPermissionMode(
|
||||
): Promise<CallToolResult> {
|
||||
// Validate permission mode at runtime
|
||||
const validModes = [
|
||||
"ask",
|
||||
"skip_all_permission_checks",
|
||||
"follow_a_plan",
|
||||
] as const;
|
||||
const mode = args.mode as string | undefined;
|
||||
'ask',
|
||||
'skip_all_permission_checks',
|
||||
'follow_a_plan',
|
||||
] as const
|
||||
const mode = args.mode as string | undefined
|
||||
const permissionMode: PermissionMode =
|
||||
mode && validModes.includes(mode as PermissionMode)
|
||||
? (mode as PermissionMode)
|
||||
: "ask";
|
||||
: 'ask'
|
||||
|
||||
if (socketClient.setPermissionMode) {
|
||||
await socketClient.setPermissionMode(
|
||||
permissionMode,
|
||||
args.allowed_domains as string[] | undefined,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Permission mode set to: ${permissionMode}` },
|
||||
{ type: 'text', text: `Permission mode set to: ${permissionMode}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,50 +230,50 @@ async function handleSwitchBrowser(
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Browser switching is only available with bridge connections.",
|
||||
type: 'text',
|
||||
text: 'Browser switching is only available with bridge connections.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
const isConnected = await socketClient.ensureConnected()
|
||||
if (!isConnected) {
|
||||
return handleToolCallDisconnected(context);
|
||||
return handleToolCallDisconnected(context)
|
||||
}
|
||||
|
||||
const result = (await socketClient.switchBrowser?.()) ?? null;
|
||||
const result = (await socketClient.switchBrowser?.()) ?? null
|
||||
|
||||
if (result === "no_other_browsers") {
|
||||
if (result === 'no_other_browsers') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.",
|
||||
type: 'text',
|
||||
text: 'No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Connected to browser "${result.name}".` },
|
||||
{ type: 'text', text: `Connected to browser "${result.name}".` },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.",
|
||||
type: 'text',
|
||||
text: 'No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,20 +282,20 @@ async function handleSwitchBrowser(
|
||||
function isAuthenticationError(content: unknown[] | string): boolean {
|
||||
const errorText = Array.isArray(content)
|
||||
? content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
.map(item => {
|
||||
if (typeof item === 'string') return item
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
"text" in item &&
|
||||
typeof item.text === "string"
|
||||
'text' in item &&
|
||||
typeof item.text === 'string'
|
||||
) {
|
||||
return item.text;
|
||||
return item.text
|
||||
}
|
||||
return "";
|
||||
return ''
|
||||
})
|
||||
.join(" ")
|
||||
: String(content);
|
||||
.join(' ')
|
||||
: String(content)
|
||||
|
||||
return errorText.toLowerCase().includes("re-authenticated");
|
||||
return errorText.toLowerCase().includes('re-authenticated')
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
silly: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export type PermissionMode =
|
||||
| "ask"
|
||||
| "skip_all_permission_checks"
|
||||
| "follow_a_plan";
|
||||
| 'ask'
|
||||
| 'skip_all_permission_checks'
|
||||
| 'follow_a_plan'
|
||||
|
||||
export interface BridgeConfig {
|
||||
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
|
||||
url: string;
|
||||
url: string
|
||||
/** Returns the user's account UUID for the connection path */
|
||||
getUserId: () => Promise<string | undefined>;
|
||||
getUserId: () => Promise<string | undefined>
|
||||
/** Returns a valid OAuth token for bridge authentication */
|
||||
getOAuthToken: () => Promise<string | undefined>;
|
||||
getOAuthToken: () => Promise<string | undefined>
|
||||
/** Optional dev user ID for local development (bypasses OAuth) */
|
||||
devUserId?: string;
|
||||
devUserId?: string
|
||||
}
|
||||
|
||||
/** Metadata about a connected Chrome extension instance. */
|
||||
export interface ChromeExtensionInfo {
|
||||
deviceId: string;
|
||||
osPlatform?: string;
|
||||
connectedAt: number;
|
||||
name?: string;
|
||||
deviceId: string
|
||||
osPlatform?: string
|
||||
connectedAt: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ClaudeForChromeContext {
|
||||
serverName: string;
|
||||
logger: Logger;
|
||||
socketPath: string;
|
||||
serverName: string
|
||||
logger: Logger
|
||||
socketPath: string
|
||||
// Optional dynamic resolver for socket path. When provided, called on each
|
||||
// connection attempt to handle runtime conditions (e.g., TMPDIR mismatch).
|
||||
getSocketPath?: () => string;
|
||||
getSocketPath?: () => string
|
||||
// Optional resolver returning all available socket paths (for multi-profile support).
|
||||
// When provided, a socket pool connects to all sockets and routes by tab ID.
|
||||
getSocketPaths?: () => string[];
|
||||
clientTypeId: string; // "desktop" | "claude-code"
|
||||
onToolCallDisconnected: () => string;
|
||||
onAuthenticationError: () => void;
|
||||
isDisabled?: () => boolean;
|
||||
getSocketPaths?: () => string[]
|
||||
clientTypeId: string // "desktop" | "claude-code"
|
||||
onToolCallDisconnected: () => string
|
||||
onAuthenticationError: () => void
|
||||
isDisabled?: () => boolean
|
||||
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
|
||||
bridgeConfig?: BridgeConfig;
|
||||
bridgeConfig?: BridgeConfig
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode;
|
||||
initialPermissionMode?: PermissionMode
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
) => void;
|
||||
) => void
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void;
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void
|
||||
/** Returns the previously paired deviceId, if any. */
|
||||
getPersistedDeviceId?: () => string | undefined;
|
||||
getPersistedDeviceId?: () => string | undefined
|
||||
/** Called when a remote extension is auto-selected (only option available). */
|
||||
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void;
|
||||
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,69 +66,69 @@ export interface ClaudeForChromeContext {
|
||||
* via navigator.userAgentData.platform.
|
||||
*/
|
||||
export function localPlatformLabel(): string {
|
||||
return process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: "Linux";
|
||||
return process.platform === 'darwin'
|
||||
? 'macOS'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows'
|
||||
: 'Linux'
|
||||
}
|
||||
|
||||
/** Permission request forwarded from the extension to the desktop for user approval. */
|
||||
export interface BridgePermissionRequest {
|
||||
/** Links to the pending tool_call */
|
||||
toolUseId: string;
|
||||
toolUseId: string
|
||||
/** Unique ID for this permission request */
|
||||
requestId: string;
|
||||
requestId: string
|
||||
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
|
||||
toolType: string;
|
||||
toolType: string
|
||||
/** The URL/domain context */
|
||||
url: string;
|
||||
url: string
|
||||
/** Additional action data (click coordinates, text, etc.) */
|
||||
actionData?: Record<string, unknown>;
|
||||
actionData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Desktop response to a bridge permission request. */
|
||||
export interface BridgePermissionResponse {
|
||||
requestId: string;
|
||||
allowed: boolean;
|
||||
requestId: string
|
||||
allowed: boolean
|
||||
}
|
||||
|
||||
/** Per-call permission overrides, allowing each session to use its own permission state. */
|
||||
export interface PermissionOverrides {
|
||||
permissionMode: PermissionMode;
|
||||
allowedDomains?: string[];
|
||||
permissionMode: PermissionMode
|
||||
allowedDomains?: string[]
|
||||
/** Callback invoked when the extension requests user permission via the bridge. */
|
||||
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
|
||||
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** Shared interface for McpSocketClient and McpSocketPool */
|
||||
export interface SocketClient {
|
||||
ensureConnected(): Promise<boolean>;
|
||||
ensureConnected(): Promise<boolean>
|
||||
callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown>;
|
||||
isConnected(): boolean;
|
||||
disconnect(): void;
|
||||
): Promise<unknown>
|
||||
isConnected(): boolean
|
||||
disconnect(): void
|
||||
setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}) => void,
|
||||
): void;
|
||||
): void
|
||||
/** Set permission mode for the current session. Only effective on BridgeClient. */
|
||||
setPermissionMode?(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void>;
|
||||
): Promise<void>
|
||||
/** Switch to a different browser. Only available on BridgeClient. */
|
||||
switchBrowser?(): Promise<
|
||||
| {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
deviceId: string
|
||||
name: string
|
||||
}
|
||||
| "no_other_browsers"
|
||||
| 'no_other_browsers'
|
||||
| null
|
||||
>;
|
||||
>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@ant/computer-use-input",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
"name": "@ant/computer-use-input",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -12,19 +12,46 @@ import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const KEY_MAP: Record<string, number> = {
|
||||
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
||||
escape: 53, esc: 53,
|
||||
left: 123, right: 124, down: 125, up: 126,
|
||||
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
|
||||
f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
|
||||
home: 115, end: 119, pageup: 116, pagedown: 121,
|
||||
return: 36,
|
||||
enter: 36,
|
||||
tab: 48,
|
||||
space: 49,
|
||||
delete: 51,
|
||||
backspace: 51,
|
||||
escape: 53,
|
||||
esc: 53,
|
||||
left: 123,
|
||||
right: 124,
|
||||
down: 125,
|
||||
up: 126,
|
||||
f1: 122,
|
||||
f2: 120,
|
||||
f3: 99,
|
||||
f4: 118,
|
||||
f5: 96,
|
||||
f6: 97,
|
||||
f7: 98,
|
||||
f8: 100,
|
||||
f9: 101,
|
||||
f10: 109,
|
||||
f11: 103,
|
||||
f12: 111,
|
||||
home: 115,
|
||||
end: 119,
|
||||
pageup: 116,
|
||||
pagedown: 121,
|
||||
}
|
||||
|
||||
const MODIFIER_MAP: Record<string, string> = {
|
||||
command: 'command down', cmd: 'command down', meta: 'command down', super: 'command down',
|
||||
command: 'command down',
|
||||
cmd: 'command down',
|
||||
meta: 'command down',
|
||||
super: 'command down',
|
||||
shift: 'shift down',
|
||||
option: 'option down', alt: 'option down',
|
||||
control: 'control down', ctrl: 'control down',
|
||||
option: 'option down',
|
||||
alt: 'option down',
|
||||
control: 'control down',
|
||||
ctrl: 'control down',
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
@@ -35,13 +62,23 @@ async function osascript(script: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
const { stdout } = await execFileAsync(
|
||||
'osascript',
|
||||
['-l', 'JavaScript', '-e', script],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
)
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
|
||||
function buildMouseJxa(
|
||||
eventType: string,
|
||||
x: number,
|
||||
y: number,
|
||||
btn: number,
|
||||
clickState?: number,
|
||||
): string {
|
||||
let script = `ObjC.import("CoreGraphics"); var p = $.CGPointMake(${x},${y}); var e = $.CGEventCreateMouseEvent(null, $.${eventType}, p, ${btn});`
|
||||
if (clickState !== undefined) {
|
||||
script += ` $.CGEventSetIntegerValueField(e, $.kCGMouseEventClickState, ${clickState});`
|
||||
@@ -61,11 +98,13 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
if (keyCode !== undefined) {
|
||||
await osascript(`tell application "System Events" to key code ${keyCode}`)
|
||||
} else {
|
||||
await osascript(`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`)
|
||||
await osascript(
|
||||
`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
export const keys: InputBackend['keys'] = async parts => {
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
for (const part of parts) {
|
||||
@@ -78,23 +117,43 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
||||
const keyCode = KEY_MAP[lower]
|
||||
const modStr = modifiers.length > 0 ? ` using {${modifiers.join(', ')}}` : ''
|
||||
if (keyCode !== undefined) {
|
||||
await osascript(`tell application "System Events" to key code ${keyCode}${modStr}`)
|
||||
await osascript(
|
||||
`tell application "System Events" to key code ${keyCode}${modStr}`,
|
||||
)
|
||||
} else {
|
||||
await osascript(`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`)
|
||||
await osascript(
|
||||
`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
const result = await jxa('ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y')
|
||||
const result = await jxa(
|
||||
'ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y',
|
||||
)
|
||||
const [xStr, yStr] = result.split(',')
|
||||
return { x: Math.round(Number(xStr)), y: Math.round(Number(yStr)) }
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||
button,
|
||||
action,
|
||||
count,
|
||||
) => {
|
||||
const pos = await mouseLocation()
|
||||
const btn = button === 'left' ? 0 : button === 'right' ? 1 : 2
|
||||
const downType = btn === 0 ? 'kCGEventLeftMouseDown' : btn === 1 ? 'kCGEventRightMouseDown' : 'kCGEventOtherMouseDown'
|
||||
const upType = btn === 0 ? 'kCGEventLeftMouseUp' : btn === 1 ? 'kCGEventRightMouseUp' : 'kCGEventOtherMouseUp'
|
||||
const downType =
|
||||
btn === 0
|
||||
? 'kCGEventLeftMouseDown'
|
||||
: btn === 1
|
||||
? 'kCGEventRightMouseDown'
|
||||
: 'kCGEventOtherMouseDown'
|
||||
const upType =
|
||||
btn === 0
|
||||
? 'kCGEventLeftMouseUp'
|
||||
: btn === 1
|
||||
? 'kCGEventRightMouseUp'
|
||||
: 'kCGEventOtherMouseUp'
|
||||
|
||||
if (action === 'click') {
|
||||
for (let i = 0; i < (count ?? 1); i++) {
|
||||
@@ -108,28 +167,39 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
|
||||
const script = direction === 'vertical'
|
||||
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||
amount,
|
||||
direction,
|
||||
) => {
|
||||
const script =
|
||||
direction === 'vertical'
|
||||
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
await jxa(script)
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
export const typeText: InputBackend['typeText'] = async text => {
|
||||
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
try {
|
||||
const output = execFileSync('osascript', ['-e', `
|
||||
const output = execFileSync(
|
||||
'osascript',
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
|
||||
`,
|
||||
],
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
|
||||
).trim()
|
||||
if (!output || !output.includes('|')) return null
|
||||
const [bundleId, appName] = output.split('|', 2)
|
||||
return { bundleId: bundleId!, appName: appName! }
|
||||
|
||||
@@ -32,23 +32,75 @@ async function runAsync(cmd: string[]): Promise<string> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KEY_MAP: Record<string, string> = {
|
||||
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
|
||||
backspace: 'BackSpace', delete: 'Delete', escape: 'Escape', esc: 'Escape',
|
||||
left: 'Left', up: 'Up', right: 'Right', down: 'Down',
|
||||
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
||||
f1: 'F1', f2: 'F2', f3: 'F3', f4: 'F4', f5: 'F5', f6: 'F6',
|
||||
f7: 'F7', f8: 'F8', f9: 'F9', f10: 'F10', f11: 'F11', f12: 'F12',
|
||||
shift: 'shift', lshift: 'shift', rshift: 'shift',
|
||||
control: 'ctrl', ctrl: 'ctrl', lcontrol: 'ctrl', rcontrol: 'ctrl',
|
||||
alt: 'alt', option: 'alt', lalt: 'alt', ralt: 'alt',
|
||||
win: 'super', meta: 'super', command: 'super', cmd: 'super', super: 'super',
|
||||
insert: 'Insert', printscreen: 'Print', pause: 'Pause',
|
||||
numlock: 'Num_Lock', capslock: 'Caps_Lock', scrolllock: 'Scroll_Lock',
|
||||
return: 'Return',
|
||||
enter: 'Return',
|
||||
tab: 'Tab',
|
||||
space: 'space',
|
||||
backspace: 'BackSpace',
|
||||
delete: 'Delete',
|
||||
escape: 'Escape',
|
||||
esc: 'Escape',
|
||||
left: 'Left',
|
||||
up: 'Up',
|
||||
right: 'Right',
|
||||
down: 'Down',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageup: 'Prior',
|
||||
pagedown: 'Next',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12',
|
||||
shift: 'shift',
|
||||
lshift: 'shift',
|
||||
rshift: 'shift',
|
||||
control: 'ctrl',
|
||||
ctrl: 'ctrl',
|
||||
lcontrol: 'ctrl',
|
||||
rcontrol: 'ctrl',
|
||||
alt: 'alt',
|
||||
option: 'alt',
|
||||
lalt: 'alt',
|
||||
ralt: 'alt',
|
||||
win: 'super',
|
||||
meta: 'super',
|
||||
command: 'super',
|
||||
cmd: 'super',
|
||||
super: 'super',
|
||||
insert: 'Insert',
|
||||
printscreen: 'Print',
|
||||
pause: 'Pause',
|
||||
numlock: 'Num_Lock',
|
||||
capslock: 'Caps_Lock',
|
||||
scrolllock: 'Scroll_Lock',
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
|
||||
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
|
||||
'shift',
|
||||
'lshift',
|
||||
'rshift',
|
||||
'control',
|
||||
'ctrl',
|
||||
'lcontrol',
|
||||
'rcontrol',
|
||||
'alt',
|
||||
'option',
|
||||
'lalt',
|
||||
'ralt',
|
||||
'win',
|
||||
'meta',
|
||||
'command',
|
||||
'cmd',
|
||||
'super',
|
||||
])
|
||||
|
||||
function mapKey(name: string): string {
|
||||
@@ -68,7 +120,13 @@ function mouseButtonNum(button: 'left' | 'right' | 'middle'): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
||||
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
|
||||
run([
|
||||
'xdotool',
|
||||
'mousemove',
|
||||
'--sync',
|
||||
String(Math.round(x)),
|
||||
String(Math.round(y)),
|
||||
])
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
@@ -82,7 +140,11 @@ export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||
button,
|
||||
action,
|
||||
count,
|
||||
) => {
|
||||
const btn = mouseButtonNum(button)
|
||||
if (action === 'click') {
|
||||
const n = count ?? 1
|
||||
@@ -94,7 +156,10 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||
amount,
|
||||
direction,
|
||||
) => {
|
||||
// xdotool click 4=scroll up, 5=scroll down, 6=scroll left, 7=scroll right
|
||||
// Positive amount = down/right, negative = up/left
|
||||
if (direction === 'vertical') {
|
||||
@@ -121,7 +186,7 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
export const keys: InputBackend['keys'] = async parts => {
|
||||
// xdotool key accepts "modifier+modifier+key" format
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
@@ -139,7 +204,7 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
||||
run(['xdotool', 'key', combo])
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
export const typeText: InputBackend['typeText'] = async text => {
|
||||
run(['xdotool', 'type', '--delay', '12', text])
|
||||
}
|
||||
|
||||
@@ -157,16 +222,23 @@ export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
let exePath = ''
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Read the process name from /proc/comm
|
||||
let appName = ''
|
||||
try {
|
||||
appName = run(['cat', `/proc/${pid}/comm`])
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!exePath && !appName) return null
|
||||
return { bundleId: exePath || `/proc/${pid}/exe`, appName: appName || 'unknown' }
|
||||
return {
|
||||
bundleId: exePath || `/proc/${pid}/exe`,
|
||||
appName: appName || 'unknown',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -92,43 +92,112 @@ public class CuWin32 {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VK_MAP: Record<string, number> = {
|
||||
return: 0x0D, enter: 0x0D, tab: 0x09, space: 0x20,
|
||||
backspace: 0x08, delete: 0x2E, escape: 0x1B, esc: 0x1B,
|
||||
left: 0x25, up: 0x26, right: 0x27, down: 0x28,
|
||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7A, f12: 0x7B,
|
||||
shift: 0xA0, lshift: 0xA0, rshift: 0xA1,
|
||||
control: 0xA2, ctrl: 0xA2, lcontrol: 0xA2, rcontrol: 0xA3,
|
||||
alt: 0xA4, option: 0xA4, lalt: 0xA4, ralt: 0xA5,
|
||||
win: 0x5B, meta: 0x5B, command: 0x5B, cmd: 0x5B, super: 0x5B,
|
||||
insert: 0x2D, printscreen: 0x2C, pause: 0x13,
|
||||
numlock: 0x90, capslock: 0x14, scrolllock: 0x91,
|
||||
return: 0x0d,
|
||||
enter: 0x0d,
|
||||
tab: 0x09,
|
||||
space: 0x20,
|
||||
backspace: 0x08,
|
||||
delete: 0x2e,
|
||||
escape: 0x1b,
|
||||
esc: 0x1b,
|
||||
left: 0x25,
|
||||
up: 0x26,
|
||||
right: 0x27,
|
||||
down: 0x28,
|
||||
home: 0x24,
|
||||
end: 0x23,
|
||||
pageup: 0x21,
|
||||
pagedown: 0x22,
|
||||
f1: 0x70,
|
||||
f2: 0x71,
|
||||
f3: 0x72,
|
||||
f4: 0x73,
|
||||
f5: 0x74,
|
||||
f6: 0x75,
|
||||
f7: 0x76,
|
||||
f8: 0x77,
|
||||
f9: 0x78,
|
||||
f10: 0x79,
|
||||
f11: 0x7a,
|
||||
f12: 0x7b,
|
||||
shift: 0xa0,
|
||||
lshift: 0xa0,
|
||||
rshift: 0xa1,
|
||||
control: 0xa2,
|
||||
ctrl: 0xa2,
|
||||
lcontrol: 0xa2,
|
||||
rcontrol: 0xa3,
|
||||
alt: 0xa4,
|
||||
option: 0xa4,
|
||||
lalt: 0xa4,
|
||||
ralt: 0xa5,
|
||||
win: 0x5b,
|
||||
meta: 0x5b,
|
||||
command: 0x5b,
|
||||
cmd: 0x5b,
|
||||
super: 0x5b,
|
||||
insert: 0x2d,
|
||||
printscreen: 0x2c,
|
||||
pause: 0x13,
|
||||
numlock: 0x90,
|
||||
capslock: 0x14,
|
||||
scrolllock: 0x91,
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol', 'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super'])
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'shift',
|
||||
'lshift',
|
||||
'rshift',
|
||||
'control',
|
||||
'ctrl',
|
||||
'lcontrol',
|
||||
'rcontrol',
|
||||
'alt',
|
||||
'option',
|
||||
'lalt',
|
||||
'ralt',
|
||||
'win',
|
||||
'meta',
|
||||
'command',
|
||||
'cmd',
|
||||
'super',
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
||||
ps(`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`,
|
||||
)
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
const out = ps(`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`)
|
||||
const out = ps(
|
||||
`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`,
|
||||
)
|
||||
const [xStr, yStr] = out.split(',')
|
||||
return { x: Number(xStr), y: Number(yStr) }
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
const downFlag = button === 'left' ? 'MOUSEEVENTF_LEFTDOWN'
|
||||
: button === 'right' ? 'MOUSEEVENTF_RIGHTDOWN'
|
||||
: 'MOUSEEVENTF_MIDDLEDOWN'
|
||||
const upFlag = button === 'left' ? 'MOUSEEVENTF_LEFTUP'
|
||||
: button === 'right' ? 'MOUSEEVENTF_RIGHTUP'
|
||||
: 'MOUSEEVENTF_MIDDLEUP'
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||
button,
|
||||
action,
|
||||
count,
|
||||
) => {
|
||||
const downFlag =
|
||||
button === 'left'
|
||||
? 'MOUSEEVENTF_LEFTDOWN'
|
||||
: button === 'right'
|
||||
? 'MOUSEEVENTF_RIGHTDOWN'
|
||||
: 'MOUSEEVENTF_MIDDLEDOWN'
|
||||
const upFlag =
|
||||
button === 'left'
|
||||
? 'MOUSEEVENTF_LEFTUP'
|
||||
: button === 'right'
|
||||
? 'MOUSEEVENTF_RIGHTUP'
|
||||
: 'MOUSEEVENTF_MIDDLEUP'
|
||||
|
||||
if (action === 'click') {
|
||||
const n = count ?? 1
|
||||
@@ -136,17 +205,29 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
||||
for (let i = 0; i < n; i++) {
|
||||
clicks += `$i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; `
|
||||
}
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`,
|
||||
)
|
||||
} else if (action === 'press') {
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
|
||||
)
|
||||
} else {
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
|
||||
const flag = direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||
amount,
|
||||
direction,
|
||||
) => {
|
||||
const flag =
|
||||
direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
|
||||
)
|
||||
}
|
||||
|
||||
export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
@@ -154,15 +235,19 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
const vk = VK_MAP[lower]
|
||||
const flags = action === 'release' ? '2' : '0'
|
||||
if (vk !== undefined) {
|
||||
ps(`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`,
|
||||
)
|
||||
} else if (keyName.length === 1) {
|
||||
// Single character — use VkKeyScan to resolve
|
||||
const charCode = keyName.charCodeAt(0)
|
||||
ps(`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
export const keys: InputBackend['keys'] = async parts => {
|
||||
const modifiers: number[] = []
|
||||
let finalKey: string | null = null
|
||||
|
||||
@@ -196,9 +281,11 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
||||
ps(script)
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
export const typeText: InputBackend['typeText'] = async text => {
|
||||
const escaped = text.replace(/'/g, "''")
|
||||
ps(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`)
|
||||
ps(
|
||||
`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`,
|
||||
)
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
|
||||
@@ -15,8 +15,15 @@ export interface InputBackend {
|
||||
key(key: string, action: 'press' | 'release'): Promise<void>
|
||||
keys(parts: string[]): Promise<void>
|
||||
mouseLocation(): Promise<{ x: number; y: number }>
|
||||
mouseButton(button: 'left' | 'right' | 'middle', action: 'click' | 'press' | 'release', count?: number): Promise<void>
|
||||
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
||||
mouseButton(
|
||||
button: 'left' | 'right' | 'middle',
|
||||
action: 'click' | 'press' | 'release',
|
||||
count?: number,
|
||||
): Promise<void>
|
||||
mouseScroll(
|
||||
amount: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
): Promise<void>
|
||||
typeText(text: string): Promise<void>
|
||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||
}
|
||||
@@ -60,5 +67,7 @@ export class ComputerUseInputAPI {
|
||||
declare isSupported: true
|
||||
}
|
||||
|
||||
interface ComputerUseInputUnsupported { isSupported: false }
|
||||
interface ComputerUseInputUnsupported {
|
||||
isSupported: false
|
||||
}
|
||||
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface FrontmostAppInfo {
|
||||
bundleId: string // macOS: bundle ID, Windows: exe path
|
||||
bundleId: string // macOS: bundle ID, Windows: exe path
|
||||
appName: string
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface InputBackend {
|
||||
action: 'click' | 'press' | 'release',
|
||||
count?: number,
|
||||
): Promise<void>
|
||||
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
||||
mouseScroll(
|
||||
amount: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
): Promise<void>
|
||||
typeText(text: string): Promise<void>
|
||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@ant/computer-use-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./sentinelApps": "./src/sentinelApps.ts",
|
||||
"./types": "./src/types.ts"
|
||||
}
|
||||
"name": "@ant/computer-use-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./sentinelApps": "./src/sentinelApps.ts",
|
||||
"./types": "./src/types.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* duplicated as a string literal below rather than imported.
|
||||
*/
|
||||
|
||||
export type DeniedCategory = "browser" | "terminal" | "trading";
|
||||
export type DeniedCategory = 'browser' | 'terminal' | 'trading'
|
||||
|
||||
/**
|
||||
* Map a category to its hardcoded tier. Return-type is the string-literal
|
||||
@@ -44,54 +44,54 @@ export type DeniedCategory = "browser" | "terminal" | "trading";
|
||||
*/
|
||||
export function categoryToTier(
|
||||
category: DeniedCategory | null,
|
||||
): "read" | "click" | "full" {
|
||||
if (category === "browser" || category === "trading") return "read";
|
||||
if (category === "terminal") return "click";
|
||||
return "full";
|
||||
): 'read' | 'click' | 'full' {
|
||||
if (category === 'browser' || category === 'trading') return 'read'
|
||||
if (category === 'terminal') return 'click'
|
||||
return 'full'
|
||||
}
|
||||
|
||||
// ─── Bundle-ID deny sets (macOS) ─────────────────────────────────────────
|
||||
|
||||
const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Apple
|
||||
"com.apple.Safari",
|
||||
"com.apple.SafariTechnologyPreview",
|
||||
'com.apple.Safari',
|
||||
'com.apple.SafariTechnologyPreview',
|
||||
// Google
|
||||
"com.google.Chrome",
|
||||
"com.google.Chrome.beta",
|
||||
"com.google.Chrome.dev",
|
||||
"com.google.Chrome.canary",
|
||||
'com.google.Chrome',
|
||||
'com.google.Chrome.beta',
|
||||
'com.google.Chrome.dev',
|
||||
'com.google.Chrome.canary',
|
||||
// Microsoft
|
||||
"com.microsoft.edgemac",
|
||||
"com.microsoft.edgemac.Beta",
|
||||
"com.microsoft.edgemac.Dev",
|
||||
"com.microsoft.edgemac.Canary",
|
||||
'com.microsoft.edgemac',
|
||||
'com.microsoft.edgemac.Beta',
|
||||
'com.microsoft.edgemac.Dev',
|
||||
'com.microsoft.edgemac.Canary',
|
||||
// Mozilla
|
||||
"org.mozilla.firefox",
|
||||
"org.mozilla.firefoxdeveloperedition",
|
||||
"org.mozilla.nightly",
|
||||
'org.mozilla.firefox',
|
||||
'org.mozilla.firefoxdeveloperedition',
|
||||
'org.mozilla.nightly',
|
||||
// Chromium-based
|
||||
"org.chromium.Chromium",
|
||||
"com.brave.Browser",
|
||||
"com.brave.Browser.beta",
|
||||
"com.brave.Browser.nightly",
|
||||
"com.operasoftware.Opera",
|
||||
"com.operasoftware.OperaGX",
|
||||
"com.operasoftware.OperaDeveloper",
|
||||
"com.vivaldi.Vivaldi",
|
||||
'org.chromium.Chromium',
|
||||
'com.brave.Browser',
|
||||
'com.brave.Browser.beta',
|
||||
'com.brave.Browser.nightly',
|
||||
'com.operasoftware.Opera',
|
||||
'com.operasoftware.OperaGX',
|
||||
'com.operasoftware.OperaDeveloper',
|
||||
'com.vivaldi.Vivaldi',
|
||||
// The Browser Company
|
||||
"company.thebrowser.Browser", // Arc
|
||||
"company.thebrowser.dia", // Dia (agentic)
|
||||
'company.thebrowser.Browser', // Arc
|
||||
'company.thebrowser.dia', // Dia (agentic)
|
||||
// Privacy-focused
|
||||
"org.torproject.torbrowser",
|
||||
"com.duckduckgo.macos.browser",
|
||||
"ru.yandex.desktop.yandex-browser",
|
||||
'org.torproject.torbrowser',
|
||||
'com.duckduckgo.macos.browser',
|
||||
'ru.yandex.desktop.yandex-browser',
|
||||
// Agentic / AI browsers — newer entrants with LLM integrations
|
||||
"ai.perplexity.comet",
|
||||
"com.sigmaos.sigmaos.macos", // SigmaOS
|
||||
'ai.perplexity.comet',
|
||||
'com.sigmaos.sigmaos.macos', // SigmaOS
|
||||
// Webkit-based misc
|
||||
"com.kagi.kagimacOS", // Orion
|
||||
]);
|
||||
'com.kagi.kagimacOS', // Orion
|
||||
])
|
||||
|
||||
/**
|
||||
* Terminals + IDEs with integrated terminals. Supersets
|
||||
@@ -101,66 +101,66 @@ const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
*/
|
||||
const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Dedicated terminals
|
||||
"com.apple.Terminal",
|
||||
"com.googlecode.iterm2",
|
||||
"dev.warp.Warp-Stable",
|
||||
"dev.warp.Warp-Beta",
|
||||
"com.github.wez.wezterm",
|
||||
"org.alacritty",
|
||||
"io.alacritty", // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
|
||||
"net.kovidgoyal.kitty",
|
||||
"co.zeit.hyper",
|
||||
"com.mitchellh.ghostty",
|
||||
"org.tabby",
|
||||
"com.termius-dmg.mac", // Termius
|
||||
'com.apple.Terminal',
|
||||
'com.googlecode.iterm2',
|
||||
'dev.warp.Warp-Stable',
|
||||
'dev.warp.Warp-Beta',
|
||||
'com.github.wez.wezterm',
|
||||
'org.alacritty',
|
||||
'io.alacritty', // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
|
||||
'net.kovidgoyal.kitty',
|
||||
'co.zeit.hyper',
|
||||
'com.mitchellh.ghostty',
|
||||
'org.tabby',
|
||||
'com.termius-dmg.mac', // Termius
|
||||
// IDEs with integrated terminals — we can't distinguish "type in the
|
||||
// editor" from "type in the integrated terminal" via screenshot+click.
|
||||
// VS Code family
|
||||
"com.microsoft.VSCode",
|
||||
"com.microsoft.VSCodeInsiders",
|
||||
"com.vscodium", // VSCodium
|
||||
"com.todesktop.230313mzl4w4u92", // Cursor
|
||||
"com.exafunction.windsurf", // Windsurf / Codeium
|
||||
"dev.zed.Zed",
|
||||
"dev.zed.Zed-Preview",
|
||||
'com.microsoft.VSCode',
|
||||
'com.microsoft.VSCodeInsiders',
|
||||
'com.vscodium', // VSCodium
|
||||
'com.todesktop.230313mzl4w4u92', // Cursor
|
||||
'com.exafunction.windsurf', // Windsurf / Codeium
|
||||
'dev.zed.Zed',
|
||||
'dev.zed.Zed-Preview',
|
||||
// JetBrains family (all have integrated terminals)
|
||||
"com.jetbrains.intellij",
|
||||
"com.jetbrains.intellij.ce",
|
||||
"com.jetbrains.pycharm",
|
||||
"com.jetbrains.pycharm.ce",
|
||||
"com.jetbrains.WebStorm",
|
||||
"com.jetbrains.CLion",
|
||||
"com.jetbrains.goland",
|
||||
"com.jetbrains.rubymine",
|
||||
"com.jetbrains.PhpStorm",
|
||||
"com.jetbrains.datagrip",
|
||||
"com.jetbrains.rider",
|
||||
"com.jetbrains.AppCode",
|
||||
"com.jetbrains.rustrover",
|
||||
"com.jetbrains.fleet",
|
||||
"com.google.android.studio", // Android Studio (JetBrains-based)
|
||||
'com.jetbrains.intellij',
|
||||
'com.jetbrains.intellij.ce',
|
||||
'com.jetbrains.pycharm',
|
||||
'com.jetbrains.pycharm.ce',
|
||||
'com.jetbrains.WebStorm',
|
||||
'com.jetbrains.CLion',
|
||||
'com.jetbrains.goland',
|
||||
'com.jetbrains.rubymine',
|
||||
'com.jetbrains.PhpStorm',
|
||||
'com.jetbrains.datagrip',
|
||||
'com.jetbrains.rider',
|
||||
'com.jetbrains.AppCode',
|
||||
'com.jetbrains.rustrover',
|
||||
'com.jetbrains.fleet',
|
||||
'com.google.android.studio', // Android Studio (JetBrains-based)
|
||||
// Other IDEs
|
||||
"com.axosoft.gitkraken", // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
|
||||
"com.sublimetext.4",
|
||||
"com.sublimetext.3",
|
||||
"org.vim.MacVim",
|
||||
"com.neovim.neovim",
|
||||
"org.gnu.Emacs",
|
||||
'com.axosoft.gitkraken', // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
|
||||
'com.sublimetext.4',
|
||||
'com.sublimetext.3',
|
||||
'org.vim.MacVim',
|
||||
'com.neovim.neovim',
|
||||
'org.gnu.Emacs',
|
||||
// Xcode's previous carve-out (full tier for Interface Builder / simulator)
|
||||
// was reversed — at tier "click" IB and simulator taps still work (both are
|
||||
// plain clicks) while the integrated terminal is blocked from keyboard input.
|
||||
"com.apple.dt.Xcode",
|
||||
"org.eclipse.platform.ide",
|
||||
"org.netbeans.ide",
|
||||
"com.microsoft.visual-studio", // Visual Studio for Mac
|
||||
'com.apple.dt.Xcode',
|
||||
'org.eclipse.platform.ide',
|
||||
'org.netbeans.ide',
|
||||
'com.microsoft.visual-studio', // Visual Studio for Mac
|
||||
// AppleScript/automation execution surfaces — same threat as terminals:
|
||||
// type(script) → key("cmd+r") runs arbitrary code. Added after #28011
|
||||
// removed the osascript MCP server, making CU the only tool-call route
|
||||
// to AppleScript.
|
||||
"com.apple.ScriptEditor2",
|
||||
"com.apple.Automator",
|
||||
"com.apple.shortcuts",
|
||||
]);
|
||||
'com.apple.ScriptEditor2',
|
||||
'com.apple.Automator',
|
||||
'com.apple.shortcuts',
|
||||
])
|
||||
|
||||
/**
|
||||
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
|
||||
@@ -178,29 +178,29 @@ const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Verified via Homebrew quit/zap stanzas + mdls + electron-builder source.
|
||||
// Trading
|
||||
"com.webull.desktop.v1", // Webull (direct download, Qt)
|
||||
"com.webull.trade.mac.v1", // Webull (Mac App Store)
|
||||
"com.tastytrade.desktop",
|
||||
"com.tradingview.tradingviewapp.desktop",
|
||||
"com.fidelity.activetrader", // Fidelity Trader+ (new)
|
||||
"com.fmr.activetrader", // Fidelity Active Trader Pro (legacy)
|
||||
'com.webull.desktop.v1', // Webull (direct download, Qt)
|
||||
'com.webull.trade.mac.v1', // Webull (Mac App Store)
|
||||
'com.tastytrade.desktop',
|
||||
'com.tradingview.tradingviewapp.desktop',
|
||||
'com.fidelity.activetrader', // Fidelity Trader+ (new)
|
||||
'com.fmr.activetrader', // Fidelity Active Trader Pro (legacy)
|
||||
// Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is
|
||||
// authoritative for this exact value but install4j IDs can drift across
|
||||
// major versions — name-substring "trader workstation" is the fallback.
|
||||
"com.install4j.5889-6375-8446-2021",
|
||||
'com.install4j.5889-6375-8446-2021',
|
||||
// Crypto
|
||||
"com.binance.BinanceDesktop",
|
||||
"com.electron.exodus",
|
||||
'com.binance.BinanceDesktop',
|
||||
'com.electron.exodus',
|
||||
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
|
||||
// org.pythonmac.unspecified.<AppName>. Confirmed in spesmilo/electrum
|
||||
// source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork.
|
||||
"org.pythonmac.unspecified.Electrum",
|
||||
"com.ledger.live",
|
||||
"io.trezor.TrezorSuite",
|
||||
'org.pythonmac.unspecified.Electrum',
|
||||
'com.ledger.live',
|
||||
'io.trezor.TrezorSuite',
|
||||
// No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation,
|
||||
// Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim
|
||||
// install4j ID drifts per-install — substring safer.
|
||||
]);
|
||||
])
|
||||
|
||||
// ─── Policy-deny (not a tier — cannot be granted at all) ─────────────────
|
||||
//
|
||||
@@ -215,78 +215,78 @@ const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
const POLICY_DENIED_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew.
|
||||
// Apple built-ins
|
||||
"com.apple.TV",
|
||||
"com.apple.Music",
|
||||
"com.apple.iBooksX",
|
||||
"com.apple.podcasts",
|
||||
'com.apple.TV',
|
||||
'com.apple.Music',
|
||||
'com.apple.iBooksX',
|
||||
'com.apple.podcasts',
|
||||
// Music
|
||||
"com.spotify.client",
|
||||
"com.amazon.music",
|
||||
"com.tidal.desktop",
|
||||
"com.deezer.deezer-desktop",
|
||||
"com.pandora.desktop",
|
||||
"com.electron.pocket-casts", // direct-download Electron wrapper
|
||||
"au.com.shiftyjelly.PocketCasts", // Mac App Store
|
||||
'com.spotify.client',
|
||||
'com.amazon.music',
|
||||
'com.tidal.desktop',
|
||||
'com.deezer.deezer-desktop',
|
||||
'com.pandora.desktop',
|
||||
'com.electron.pocket-casts', // direct-download Electron wrapper
|
||||
'au.com.shiftyjelly.PocketCasts', // Mac App Store
|
||||
// Video
|
||||
"tv.plex.desktop",
|
||||
"tv.plex.htpc",
|
||||
"tv.plex.plexamp",
|
||||
"com.amazon.aiv.AIVApp", // Prime Video (iOS-on-Apple-Silicon)
|
||||
'tv.plex.desktop',
|
||||
'tv.plex.htpc',
|
||||
'tv.plex.plexamp',
|
||||
'com.amazon.aiv.AIVApp', // Prime Video (iOS-on-Apple-Silicon)
|
||||
// Ebooks
|
||||
"net.kovidgoyal.calibre",
|
||||
"com.amazon.Kindle", // legacy desktop, discontinued
|
||||
"com.amazon.Lassen", // current Mac App Store (iOS-on-Mac)
|
||||
"com.kobo.desktop.Kobo",
|
||||
'net.kovidgoyal.calibre',
|
||||
'com.amazon.Kindle', // legacy desktop, discontinued
|
||||
'com.amazon.Lassen', // current Mac App Store (iOS-on-Mac)
|
||||
'com.kobo.desktop.Kobo',
|
||||
// No native macOS app (name-substring only): Netflix, Disney+, Hulu,
|
||||
// HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu,
|
||||
// Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac.
|
||||
]);
|
||||
])
|
||||
|
||||
const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [
|
||||
// Video streaming
|
||||
"netflix",
|
||||
"disney+",
|
||||
"hulu",
|
||||
"prime video",
|
||||
"apple tv",
|
||||
"peacock",
|
||||
"paramount+",
|
||||
'netflix',
|
||||
'disney+',
|
||||
'hulu',
|
||||
'prime video',
|
||||
'apple tv',
|
||||
'peacock',
|
||||
'paramount+',
|
||||
// "plex" is too generic — would match "Perplexity". Covered by
|
||||
// tv.plex.* bundle IDs on macOS.
|
||||
"tubi",
|
||||
"crunchyroll",
|
||||
"vudu",
|
||||
'tubi',
|
||||
'crunchyroll',
|
||||
'vudu',
|
||||
// E-readers / audiobooks
|
||||
"kindle",
|
||||
"apple books",
|
||||
"kobo",
|
||||
"play books",
|
||||
"calibre",
|
||||
"libby",
|
||||
"readium",
|
||||
"audible",
|
||||
"libro.fm",
|
||||
"speechify",
|
||||
'kindle',
|
||||
'apple books',
|
||||
'kobo',
|
||||
'play books',
|
||||
'calibre',
|
||||
'libby',
|
||||
'readium',
|
||||
'audible',
|
||||
'libro.fm',
|
||||
'speechify',
|
||||
// Music
|
||||
"spotify",
|
||||
"apple music",
|
||||
"amazon music",
|
||||
"youtube music",
|
||||
"tidal",
|
||||
"deezer",
|
||||
"pandora",
|
||||
"pocket casts",
|
||||
'spotify',
|
||||
'apple music',
|
||||
'amazon music',
|
||||
'youtube music',
|
||||
'tidal',
|
||||
'deezer',
|
||||
'pandora',
|
||||
'pocket casts',
|
||||
// Publisher / social apps (from the same blocklist tab)
|
||||
"naver",
|
||||
"reddit",
|
||||
"sony music",
|
||||
"vegas pro",
|
||||
"pitchfork",
|
||||
"economist",
|
||||
"nytimes",
|
||||
'naver',
|
||||
'reddit',
|
||||
'sony music',
|
||||
'vegas pro',
|
||||
'pitchfork',
|
||||
'economist',
|
||||
'nytimes',
|
||||
// Skipped (too generic for substring matching — need bundle ID):
|
||||
// HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired
|
||||
];
|
||||
]
|
||||
|
||||
/**
|
||||
* Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings
|
||||
@@ -298,19 +298,19 @@ export function isPolicyDenied(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): boolean {
|
||||
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true;
|
||||
const lower = displayName.toLowerCase();
|
||||
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true
|
||||
const lower = displayName.toLowerCase()
|
||||
for (const sub of POLICY_DENIED_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return true;
|
||||
if (lower.includes(sub)) return true
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
export function getDeniedCategory(bundleId: string): DeniedCategory | null {
|
||||
if (BROWSER_BUNDLE_IDS.has(bundleId)) return "browser";
|
||||
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return "terminal";
|
||||
if (TRADING_BUNDLE_IDS.has(bundleId)) return "trading";
|
||||
return null;
|
||||
if (BROWSER_BUNDLE_IDS.has(bundleId)) return 'browser'
|
||||
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return 'terminal'
|
||||
if (TRADING_BUNDLE_IDS.has(bundleId)) return 'trading'
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Display-name fallback (cross-platform) ──────────────────────────────
|
||||
@@ -325,160 +325,160 @@ export function getDeniedCategory(bundleId: string): DeniedCategory | null {
|
||||
* first match, but groupings are by category for readability).
|
||||
*/
|
||||
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
|
||||
"safari",
|
||||
"chrome",
|
||||
"firefox",
|
||||
"microsoft edge",
|
||||
"brave",
|
||||
"opera",
|
||||
"vivaldi",
|
||||
"chromium",
|
||||
'safari',
|
||||
'chrome',
|
||||
'firefox',
|
||||
'microsoft edge',
|
||||
'brave',
|
||||
'opera',
|
||||
'vivaldi',
|
||||
'chromium',
|
||||
// Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for
|
||||
// substring matching (false-positives: "Arcade", "Diagram"). Covered by
|
||||
// bundle ID on macOS. The "... browser" entries below catch natural-language
|
||||
// phrasings ("the arc browser") but NOT the canonical short name.
|
||||
"arc browser",
|
||||
"tor browser",
|
||||
"duckduckgo",
|
||||
"yandex",
|
||||
"orion browser",
|
||||
'arc browser',
|
||||
'tor browser',
|
||||
'duckduckgo',
|
||||
'yandex',
|
||||
'orion browser',
|
||||
// Agentic / AI browsers
|
||||
"comet", // Perplexity's browser — "Comet" substring risks false positives
|
||||
'comet', // Perplexity's browser — "Comet" substring risks false positives
|
||||
// but leaving for now; "comet" in an app name is rare
|
||||
"sigmaos",
|
||||
"dia browser",
|
||||
];
|
||||
'sigmaos',
|
||||
'dia browser',
|
||||
]
|
||||
|
||||
const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [
|
||||
// macOS / cross-platform terminals
|
||||
"terminal", // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
|
||||
"iterm",
|
||||
"wezterm",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
"ghostty",
|
||||
"tabby",
|
||||
"termius",
|
||||
'terminal', // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
|
||||
'iterm',
|
||||
'wezterm',
|
||||
'alacritty',
|
||||
'kitty',
|
||||
'ghostty',
|
||||
'tabby',
|
||||
'termius',
|
||||
// AppleScript runners — see bundle-ID comment above. "shortcuts" is too
|
||||
// generic for substring matching (many apps have "shortcuts" in the name);
|
||||
// covered by bundle ID only, like warp/hyper.
|
||||
"script editor",
|
||||
"automator",
|
||||
'script editor',
|
||||
'automator',
|
||||
// NOTE: "warp" and "hyper" are too generic for substring matching —
|
||||
// they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID
|
||||
// (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name
|
||||
// matching can be added when Windows CU ships.
|
||||
// Windows shells (activate when the darwin gate lifts)
|
||||
"powershell",
|
||||
"cmd.exe",
|
||||
"command prompt",
|
||||
"git bash",
|
||||
"conemu",
|
||||
"cmder",
|
||||
'powershell',
|
||||
'cmd.exe',
|
||||
'command prompt',
|
||||
'git bash',
|
||||
'conemu',
|
||||
'cmder',
|
||||
// IDEs (VS Code family)
|
||||
"visual studio code",
|
||||
"visual studio", // catches VS for Mac + Windows
|
||||
"vscode",
|
||||
"vs code",
|
||||
"vscodium",
|
||||
"cursor", // Cursor IDE — "cursor" is generic but IDE is the only common app
|
||||
"windsurf",
|
||||
'visual studio code',
|
||||
'visual studio', // catches VS for Mac + Windows
|
||||
'vscode',
|
||||
'vs code',
|
||||
'vscodium',
|
||||
'cursor', // Cursor IDE — "cursor" is generic but IDE is the only common app
|
||||
'windsurf',
|
||||
// Zed: display name is just "Zed" — too short for substring matching
|
||||
// (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS.
|
||||
// IDEs (JetBrains family)
|
||||
"intellij",
|
||||
"pycharm",
|
||||
"webstorm",
|
||||
"clion",
|
||||
"goland",
|
||||
"rubymine",
|
||||
"phpstorm",
|
||||
"datagrip",
|
||||
"rider",
|
||||
"appcode",
|
||||
"rustrover",
|
||||
"fleet",
|
||||
"android studio",
|
||||
'intellij',
|
||||
'pycharm',
|
||||
'webstorm',
|
||||
'clion',
|
||||
'goland',
|
||||
'rubymine',
|
||||
'phpstorm',
|
||||
'datagrip',
|
||||
'rider',
|
||||
'appcode',
|
||||
'rustrover',
|
||||
'fleet',
|
||||
'android studio',
|
||||
// Other IDEs
|
||||
"sublime text",
|
||||
"macvim",
|
||||
"neovim",
|
||||
"emacs",
|
||||
"xcode",
|
||||
"eclipse",
|
||||
"netbeans",
|
||||
];
|
||||
'sublime text',
|
||||
'macvim',
|
||||
'neovim',
|
||||
'emacs',
|
||||
'xcode',
|
||||
'eclipse',
|
||||
'netbeans',
|
||||
]
|
||||
|
||||
const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
||||
// Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx
|
||||
// ("Read Only" tab). Name-substring safe for proper nouns below; generic
|
||||
// names (IG, Delta, HTX) are skipped and need bundle-ID matching once
|
||||
// verified.
|
||||
"bloomberg",
|
||||
"ameritrade",
|
||||
"thinkorswim",
|
||||
"schwab",
|
||||
"fidelity",
|
||||
"e*trade",
|
||||
"interactive brokers",
|
||||
"trader workstation", // Interactive Brokers TWS
|
||||
"tradestation",
|
||||
"webull",
|
||||
"robinhood",
|
||||
"tastytrade",
|
||||
"ninjatrader",
|
||||
"tradingview",
|
||||
"moomoo",
|
||||
"tradezero",
|
||||
"prorealtime",
|
||||
"plus500",
|
||||
"saxotrader",
|
||||
"oanda",
|
||||
"metatrader",
|
||||
"forex.com",
|
||||
"avaoptions",
|
||||
"ctrader",
|
||||
"jforex",
|
||||
"iq option",
|
||||
"olymp trade",
|
||||
"binomo",
|
||||
"pocket option",
|
||||
"raceoption",
|
||||
"expertoption",
|
||||
"quotex",
|
||||
"naga",
|
||||
"morgan stanley",
|
||||
"ubs neo",
|
||||
"eikon", // Thomson Reuters / LSEG Workspace
|
||||
'bloomberg',
|
||||
'ameritrade',
|
||||
'thinkorswim',
|
||||
'schwab',
|
||||
'fidelity',
|
||||
'e*trade',
|
||||
'interactive brokers',
|
||||
'trader workstation', // Interactive Brokers TWS
|
||||
'tradestation',
|
||||
'webull',
|
||||
'robinhood',
|
||||
'tastytrade',
|
||||
'ninjatrader',
|
||||
'tradingview',
|
||||
'moomoo',
|
||||
'tradezero',
|
||||
'prorealtime',
|
||||
'plus500',
|
||||
'saxotrader',
|
||||
'oanda',
|
||||
'metatrader',
|
||||
'forex.com',
|
||||
'avaoptions',
|
||||
'ctrader',
|
||||
'jforex',
|
||||
'iq option',
|
||||
'olymp trade',
|
||||
'binomo',
|
||||
'pocket option',
|
||||
'raceoption',
|
||||
'expertoption',
|
||||
'quotex',
|
||||
'naga',
|
||||
'morgan stanley',
|
||||
'ubs neo',
|
||||
'eikon', // Thomson Reuters / LSEG Workspace
|
||||
// Crypto — exchanges, wallets, portfolio trackers
|
||||
"coinbase",
|
||||
"kraken",
|
||||
"binance",
|
||||
"okx",
|
||||
"bybit",
|
||||
'coinbase',
|
||||
'kraken',
|
||||
'binance',
|
||||
'okx',
|
||||
'bybit',
|
||||
// "gate.io" is too generic — the ".io" TLD suffix is common in app names
|
||||
// (e.g., "Draw.io"). Needs bundle-ID matching once verified.
|
||||
"phemex",
|
||||
"stormgain",
|
||||
"crypto.com",
|
||||
'phemex',
|
||||
'stormgain',
|
||||
'crypto.com',
|
||||
// "exodus" is too generic — it's a common noun and would match unrelated
|
||||
// apps/games. Needs bundle-ID matching once verified.
|
||||
"electrum",
|
||||
"ledger live",
|
||||
"trezor",
|
||||
"guarda",
|
||||
"atomic wallet",
|
||||
"bitpay",
|
||||
"bisq",
|
||||
"koinly",
|
||||
"cointracker",
|
||||
"blockfi",
|
||||
"stripe cli",
|
||||
'electrum',
|
||||
'ledger live',
|
||||
'trezor',
|
||||
'guarda',
|
||||
'atomic wallet',
|
||||
'bitpay',
|
||||
'bisq',
|
||||
'koinly',
|
||||
'cointracker',
|
||||
'blockfi',
|
||||
'stripe cli',
|
||||
// Crypto games / metaverse (same trade-execution risk model)
|
||||
"decentraland",
|
||||
"axie infinity",
|
||||
"gods unchained",
|
||||
];
|
||||
'decentraland',
|
||||
'axie infinity',
|
||||
'gods unchained',
|
||||
]
|
||||
|
||||
/**
|
||||
* Display-name substring match. Called when bundle-ID resolution returned
|
||||
@@ -491,20 +491,20 @@ const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
||||
export function getDeniedCategoryByDisplayName(
|
||||
name: string,
|
||||
): DeniedCategory | null {
|
||||
const lower = name.toLowerCase();
|
||||
const lower = name.toLowerCase()
|
||||
// Trading first — proper-noun-only set, most specific. "Bloomberg Terminal"
|
||||
// contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS
|
||||
// ran first.
|
||||
for (const sub of TRADING_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "trading";
|
||||
if (lower.includes(sub)) return 'trading'
|
||||
}
|
||||
for (const sub of BROWSER_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "browser";
|
||||
if (lower.includes(sub)) return 'browser'
|
||||
}
|
||||
for (const sub of TERMINAL_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "terminal";
|
||||
if (lower.includes(sub)) return 'terminal'
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,10 +520,10 @@ export function getDeniedCategoryForApp(
|
||||
displayName: string,
|
||||
): DeniedCategory | null {
|
||||
if (bundleId) {
|
||||
const byId = getDeniedCategory(bundleId);
|
||||
if (byId) return byId;
|
||||
const byId = getDeniedCategory(bundleId)
|
||||
if (byId) return byId
|
||||
}
|
||||
return getDeniedCategoryByDisplayName(displayName);
|
||||
return getDeniedCategoryByDisplayName(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,8 +537,8 @@ export function getDeniedCategoryForApp(
|
||||
export function getDefaultTierForApp(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): "read" | "click" | "full" {
|
||||
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
|
||||
): 'read' | 'click' | 'full' {
|
||||
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName))
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
@@ -550,4 +550,4 @@ export const _test = {
|
||||
TERMINAL_NAME_SUBSTRINGS,
|
||||
TRADING_NAME_SUBSTRINGS,
|
||||
POLICY_DENIED_NAME_SUBSTRINGS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,9 +116,17 @@ export interface ComputerExecutor {
|
||||
|
||||
// ── Window management (Windows only, optional) ──────────────────────────
|
||||
/** Perform a window management action on the bound window. Win32 API only — no global shortcuts. */
|
||||
manageWindow?(action: string, opts?: { x?: number; y?: number; width?: number; height?: number }): Promise<boolean>
|
||||
manageWindow?(
|
||||
action: string,
|
||||
opts?: { x?: number; y?: number; width?: number; height?: number },
|
||||
): Promise<boolean>
|
||||
/** Get the current window rect of the bound window */
|
||||
getWindowRect?(): Promise<{ x: number; y: number; width: number; height: number } | null>
|
||||
getWindowRect?(): Promise<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} | null>
|
||||
|
||||
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
|
||||
/** Open terminal and launch an agent CLI */
|
||||
@@ -129,17 +137,32 @@ export interface ComputerExecutor {
|
||||
workingDirectory?: string
|
||||
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
|
||||
/** Bind to a window by hwnd/title/pid. Returns bound window info or null. */
|
||||
bindToWindow?(query: { hwnd?: string; title?: string; pid?: number }): Promise<{ hwnd: string; title: string; pid: number } | null>
|
||||
bindToWindow?(query: {
|
||||
hwnd?: string
|
||||
title?: string
|
||||
pid?: number
|
||||
}): Promise<{ hwnd: string; title: string; pid: number } | null>
|
||||
/** Unbind from the current window */
|
||||
unbindFromWindow?(): Promise<void>
|
||||
/** Cheap binding-state check for window-targeted routing decisions. */
|
||||
hasBoundWindow?(): Promise<boolean>
|
||||
/** Get current binding status */
|
||||
getBindingStatus?(): Promise<{ bound: boolean; hwnd?: string; title?: string; pid?: number; rect?: { x: number; y: number; width: number; height: number } } | null>
|
||||
getBindingStatus?(): Promise<{
|
||||
bound: boolean
|
||||
hwnd?: string
|
||||
title?: string
|
||||
pid?: number
|
||||
rect?: { x: number; y: number; width: number; height: number }
|
||||
} | null>
|
||||
/** List all visible windows */
|
||||
listVisibleWindows?(): Promise<Array<{ hwnd: string; pid: number; title: string }>>
|
||||
listVisibleWindows?(): Promise<
|
||||
Array<{ hwnd: string; pid: number; title: string }>
|
||||
>
|
||||
/** Control the status indicator overlay */
|
||||
statusIndicator?(action: 'show' | 'hide' | 'status', message?: string): Promise<{ active: boolean; message?: string }>
|
||||
statusIndicator?(
|
||||
action: 'show' | 'hide' | 'status',
|
||||
message?: string,
|
||||
): Promise<{ active: boolean; message?: string }>
|
||||
/** Virtual keyboard — send keys/text/combos to bound window only */
|
||||
virtualKeyboard?(opts: {
|
||||
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
|
||||
@@ -149,12 +172,26 @@ export interface ComputerExecutor {
|
||||
}): Promise<boolean>
|
||||
/** Virtual mouse — click/move/drag on bound window only */
|
||||
virtualMouse?(opts: {
|
||||
action: 'click' | 'double_click' | 'right_click' | 'move' | 'drag' | 'down' | 'up'
|
||||
x: number; y: number
|
||||
startX?: number; startY?: number
|
||||
action:
|
||||
| 'click'
|
||||
| 'double_click'
|
||||
| 'right_click'
|
||||
| 'move'
|
||||
| 'drag'
|
||||
| 'down'
|
||||
| 'up'
|
||||
x: number
|
||||
y: number
|
||||
startX?: number
|
||||
startY?: number
|
||||
}): Promise<boolean>
|
||||
/** Mouse wheel scroll at client coordinates (works on Excel, browsers, modern UI) */
|
||||
mouseWheel?(x: number, y: number, delta: number, horizontal?: boolean): Promise<boolean>
|
||||
mouseWheel?(
|
||||
x: number,
|
||||
y: number,
|
||||
delta: number,
|
||||
horizontal?: boolean,
|
||||
): Promise<boolean>
|
||||
/** Activate the bound window (foreground + click to focus) */
|
||||
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
|
||||
/** Handle a terminal prompt (yes/no/select/type + enter) */
|
||||
@@ -165,7 +202,14 @@ export interface ComputerExecutor {
|
||||
text?: string
|
||||
}): Promise<boolean>
|
||||
/** Click an element by name/role/automationId via UI Automation */
|
||||
clickElement?(query: { name?: string; role?: string; automationId?: string }): Promise<boolean>
|
||||
clickElement?(query: {
|
||||
name?: string
|
||||
role?: string
|
||||
automationId?: string
|
||||
}): Promise<boolean>
|
||||
/** Type text into an element by name/role/automationId via UI Automation ValuePattern */
|
||||
typeIntoElement?(query: { name?: string; role?: string; automationId?: string }, text: string): Promise<boolean>
|
||||
typeIntoElement?(
|
||||
query: { name?: string; role?: string; automationId?: string },
|
||||
text: string,
|
||||
): Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
*/
|
||||
|
||||
export interface ResizeParams {
|
||||
pxPerToken: number;
|
||||
maxTargetPx: number;
|
||||
maxTargetTokens: number;
|
||||
pxPerToken: number
|
||||
maxTargetPx: number
|
||||
maxTargetTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,11 +27,11 @@ export const API_RESIZE_PARAMS: ResizeParams = {
|
||||
pxPerToken: 28,
|
||||
maxTargetPx: 1568,
|
||||
maxTargetTokens: 1568,
|
||||
};
|
||||
}
|
||||
|
||||
/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */
|
||||
export function nTokensForPx(px: number, pxPerToken: number): number {
|
||||
return Math.floor((px - 1) / pxPerToken) + 1;
|
||||
return Math.floor((px - 1) / pxPerToken) + 1
|
||||
}
|
||||
|
||||
function nTokensForImg(
|
||||
@@ -39,7 +39,7 @@ function nTokensForImg(
|
||||
height: number,
|
||||
pxPerToken: number,
|
||||
): number {
|
||||
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken);
|
||||
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,47 +62,47 @@ export function targetImageSize(
|
||||
height: number,
|
||||
params: ResizeParams,
|
||||
): [number, number] {
|
||||
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
|
||||
const { pxPerToken, maxTargetPx, maxTargetTokens } = params
|
||||
|
||||
if (
|
||||
width <= maxTargetPx &&
|
||||
height <= maxTargetPx &&
|
||||
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
|
||||
) {
|
||||
return [width, height];
|
||||
return [width, height]
|
||||
}
|
||||
|
||||
// Normalize to landscape for the search; transpose result back.
|
||||
if (height > width) {
|
||||
const [w, h] = targetImageSize(height, width, params);
|
||||
return [h, w];
|
||||
const [w, h] = targetImageSize(height, width, params)
|
||||
return [h, w]
|
||||
}
|
||||
|
||||
const aspectRatio = width / height;
|
||||
const aspectRatio = width / height
|
||||
|
||||
// Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is
|
||||
// always invalid. ~12 iterations for a 4000px image.
|
||||
let upperBoundWidth = width;
|
||||
let lowerBoundWidth = 1;
|
||||
let upperBoundWidth = width
|
||||
let lowerBoundWidth = 1
|
||||
|
||||
for (;;) {
|
||||
if (lowerBoundWidth + 1 === upperBoundWidth) {
|
||||
return [
|
||||
lowerBoundWidth,
|
||||
Math.max(Math.round(lowerBoundWidth / aspectRatio), 1),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2);
|
||||
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1);
|
||||
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2)
|
||||
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1)
|
||||
|
||||
if (
|
||||
middleWidth <= maxTargetPx &&
|
||||
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
|
||||
) {
|
||||
lowerBoundWidth = middleWidth;
|
||||
lowerBoundWidth = middleWidth
|
||||
} else {
|
||||
upperBoundWidth = middleWidth;
|
||||
upperBoundWidth = middleWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export type {
|
||||
ResolvePrepareCaptureResult,
|
||||
RunningApp,
|
||||
ScreenshotResult,
|
||||
} from "./executor.js";
|
||||
} from './executor.js'
|
||||
|
||||
export type {
|
||||
AppGrant,
|
||||
@@ -25,15 +25,15 @@ export type {
|
||||
ScreenshotDims,
|
||||
TeachStepRequest,
|
||||
TeachStepResult,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
export { DEFAULT_GRANT_FLAGS } from "./types.js";
|
||||
export { DEFAULT_GRANT_FLAGS } from './types.js'
|
||||
|
||||
export {
|
||||
SENTINEL_BUNDLE_IDS,
|
||||
getSentinelCategory,
|
||||
} from "./sentinelApps.js";
|
||||
export type { SentinelCategory } from "./sentinelApps.js";
|
||||
} from './sentinelApps.js'
|
||||
export type { SentinelCategory } from './sentinelApps.js'
|
||||
|
||||
export {
|
||||
categoryToTier,
|
||||
@@ -42,28 +42,28 @@ export {
|
||||
getDeniedCategoryByDisplayName,
|
||||
getDeniedCategoryForApp,
|
||||
isPolicyDenied,
|
||||
} from "./deniedApps.js";
|
||||
export type { DeniedCategory } from "./deniedApps.js";
|
||||
} from './deniedApps.js'
|
||||
export type { DeniedCategory } from './deniedApps.js'
|
||||
|
||||
export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js";
|
||||
export { isSystemKeyCombo, normalizeKeySequence } from './keyBlocklist.js'
|
||||
|
||||
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.js";
|
||||
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from './subGates.js'
|
||||
|
||||
export { API_RESIZE_PARAMS, targetImageSize } from "./imageResize.js";
|
||||
export type { ResizeParams } from "./imageResize.js";
|
||||
export { API_RESIZE_PARAMS, targetImageSize } from './imageResize.js'
|
||||
export type { ResizeParams } from './imageResize.js'
|
||||
|
||||
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
|
||||
export { defersLockAcquire, handleToolCall } from './toolCalls.js'
|
||||
export type {
|
||||
CuCallTelemetry,
|
||||
CuCallToolResult,
|
||||
CuErrorKind,
|
||||
} from "./toolCalls.js";
|
||||
} from './toolCalls.js'
|
||||
|
||||
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
|
||||
export { buildComputerUseTools } from "./tools.js";
|
||||
export { bindSessionContext, createComputerUseMcpServer } from './mcpServer.js'
|
||||
export { buildComputerUseTools } from './tools.js'
|
||||
|
||||
export {
|
||||
comparePixelAtLocation,
|
||||
validateClickTarget,
|
||||
} from "./pixelCompare.js";
|
||||
export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js";
|
||||
} from './pixelCompare.js'
|
||||
export type { CropRawPatchFn, PixelCompareResult } from './pixelCompare.js'
|
||||
|
||||
@@ -21,32 +21,32 @@
|
||||
*/
|
||||
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
|
||||
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
|
||||
meta: "meta",
|
||||
super: "meta",
|
||||
command: "meta",
|
||||
cmd: "meta",
|
||||
windows: "meta",
|
||||
win: "meta",
|
||||
meta: 'meta',
|
||||
super: 'meta',
|
||||
command: 'meta',
|
||||
cmd: 'meta',
|
||||
windows: 'meta',
|
||||
win: 'meta',
|
||||
// Key::Control + LControl + RControl
|
||||
ctrl: "ctrl",
|
||||
control: "ctrl",
|
||||
lctrl: "ctrl",
|
||||
lcontrol: "ctrl",
|
||||
rctrl: "ctrl",
|
||||
rcontrol: "ctrl",
|
||||
ctrl: 'ctrl',
|
||||
control: 'ctrl',
|
||||
lctrl: 'ctrl',
|
||||
lcontrol: 'ctrl',
|
||||
rctrl: 'ctrl',
|
||||
rcontrol: 'ctrl',
|
||||
// Key::Shift + LShift + RShift
|
||||
shift: "shift",
|
||||
lshift: "shift",
|
||||
rshift: "shift",
|
||||
shift: 'shift',
|
||||
lshift: 'shift',
|
||||
rshift: 'shift',
|
||||
// Key::Alt and Key::Option — distinct Rust variants but same keycode on
|
||||
// darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
|
||||
// both Force Quit.
|
||||
alt: "alt",
|
||||
option: "alt",
|
||||
};
|
||||
alt: 'alt',
|
||||
option: 'alt',
|
||||
}
|
||||
|
||||
/** Sort order for canonicals. ctrl < alt < shift < meta. */
|
||||
const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
|
||||
const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta']
|
||||
|
||||
/**
|
||||
* Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
|
||||
@@ -54,21 +54,21 @@ const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
|
||||
* The self-consistency test enforces this.
|
||||
*/
|
||||
const BLOCKED_DARWIN = new Set([
|
||||
"meta+q", // Cmd+Q — quit frontmost app
|
||||
"shift+meta+q", // Cmd+Shift+Q — log out
|
||||
"alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog
|
||||
"meta+tab", // Cmd+Tab — app switcher
|
||||
"meta+space", // Cmd+Space — Spotlight
|
||||
"ctrl+meta+q", // Ctrl+Cmd+Q — lock screen
|
||||
]);
|
||||
'meta+q', // Cmd+Q — quit frontmost app
|
||||
'shift+meta+q', // Cmd+Shift+Q — log out
|
||||
'alt+meta+escape', // Cmd+Option+Esc — Force Quit dialog
|
||||
'meta+tab', // Cmd+Tab — app switcher
|
||||
'meta+space', // Cmd+Space — Spotlight
|
||||
'ctrl+meta+q', // Ctrl+Cmd+Q — lock screen
|
||||
])
|
||||
|
||||
const BLOCKED_WIN32 = new Set([
|
||||
"ctrl+alt+delete", // Secure Attention Sequence
|
||||
"alt+f4", // close window
|
||||
"alt+tab", // window switcher
|
||||
"meta+l", // Win+L — lock
|
||||
"meta+d", // Win+D — show desktop
|
||||
]);
|
||||
'ctrl+alt+delete', // Secure Attention Sequence
|
||||
'alt+f4', // close window
|
||||
'alt+tab', // window switcher
|
||||
'meta+l', // Win+L — lock
|
||||
'meta+d', // Win+D — show desktop
|
||||
])
|
||||
|
||||
/**
|
||||
* Partition into sorted-canonical modifiers and non-modifier keys.
|
||||
@@ -78,25 +78,25 @@ const BLOCKED_WIN32 = new Set([
|
||||
function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
|
||||
const parts = seq
|
||||
.toLowerCase()
|
||||
.split("+")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const mods: string[] = [];
|
||||
const keys: string[] = [];
|
||||
.split('+')
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)
|
||||
const mods: string[] = []
|
||||
const keys: string[] = []
|
||||
for (const p of parts) {
|
||||
const canonical = CANONICAL_MODIFIER[p];
|
||||
const canonical = CANONICAL_MODIFIER[p]
|
||||
if (canonical !== undefined) {
|
||||
mods.push(canonical);
|
||||
mods.push(canonical)
|
||||
} else {
|
||||
keys.push(p);
|
||||
keys.push(p)
|
||||
}
|
||||
}
|
||||
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
|
||||
const uniqueMods = [...new Set(mods)];
|
||||
const uniqueMods = [...new Set(mods)]
|
||||
uniqueMods.sort(
|
||||
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
|
||||
);
|
||||
return { mods: uniqueMods, keys };
|
||||
)
|
||||
return { mods: uniqueMods, keys }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,8 +104,8 @@ function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
|
||||
* canonical, dedupe, sort modifiers, non-modifiers last.
|
||||
*/
|
||||
export function normalizeKeySequence(seq: string): string {
|
||||
const { mods, keys } = partitionKeys(seq);
|
||||
return [...mods, ...keys].join("+");
|
||||
const { mods, keys } = partitionKeys(seq)
|
||||
return [...mods, ...keys].join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,26 +123,26 @@ export function normalizeKeySequence(seq: string): string {
|
||||
*/
|
||||
export function isSystemKeyCombo(
|
||||
seq: string,
|
||||
platform: "darwin" | "win32",
|
||||
platform: 'darwin' | 'win32',
|
||||
): boolean {
|
||||
const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
|
||||
const { mods, keys } = partitionKeys(seq);
|
||||
const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
|
||||
const blocklist = platform === 'darwin' ? BLOCKED_DARWIN : BLOCKED_WIN32
|
||||
const { mods, keys } = partitionKeys(seq)
|
||||
const prefix = mods.length > 0 ? mods.join('+') + '+' : ''
|
||||
|
||||
// No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
|
||||
// whole thing. Never matches (no blocklist entry is modifier-only) but
|
||||
// keeps the contract simple: every call reaches a .has().
|
||||
if (keys.length === 0) {
|
||||
return blocklist.has(mods.join("+"));
|
||||
return blocklist.has(mods.join('+'))
|
||||
}
|
||||
|
||||
// mods + each key. Any hit blocks the whole sequence.
|
||||
for (const key of keys) {
|
||||
if (blocklist.has(prefix + key)) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
@@ -150,4 +150,4 @@ export const _test = {
|
||||
BLOCKED_DARWIN,
|
||||
BLOCKED_WIN32,
|
||||
MODIFIER_ORDER,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,21 +17,21 @@
|
||||
* is the same either way.
|
||||
*/
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import type { ScreenshotResult } from "./executor.js";
|
||||
import type { CuCallToolResult } from "./toolCalls.js";
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { CuCallToolResult } from './toolCalls.js'
|
||||
import {
|
||||
defersLockAcquire,
|
||||
handleToolCall,
|
||||
resetMouseButtonHeld,
|
||||
} from "./toolCalls.js";
|
||||
import { buildComputerUseTools } from "./tools.js";
|
||||
} from './toolCalls.js'
|
||||
import { buildComputerUseTools } from './tools.js'
|
||||
import type {
|
||||
AppGrant,
|
||||
ComputerUseHostAdapter,
|
||||
@@ -40,12 +40,12 @@ import type {
|
||||
CoordinateMode,
|
||||
CuGrantFlags,
|
||||
CuPermissionResponse,
|
||||
} from "./types.js";
|
||||
import { DEFAULT_GRANT_FLAGS } from "./types.js";
|
||||
} from './types.js'
|
||||
import { DEFAULT_GRANT_FLAGS } from './types.js'
|
||||
|
||||
const DEFAULT_LOCK_HELD_MESSAGE =
|
||||
"Another Claude session is currently using the computer. Wait for that " +
|
||||
"session to finish, or find a non-computer-use approach.";
|
||||
'Another Claude session is currently using the computer. Wait for that ' +
|
||||
'session to finish, or find a non-computer-use approach.'
|
||||
|
||||
/**
|
||||
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
|
||||
@@ -60,20 +60,20 @@ function mergePermissionResponse(
|
||||
existingFlags: CuGrantFlags,
|
||||
response: CuPermissionResponse,
|
||||
): { apps: AppGrant[]; flags: CuGrantFlags } {
|
||||
const seen = new Set(existing.map((a) => a.bundleId));
|
||||
const seen = new Set(existing.map(a => a.bundleId))
|
||||
const apps = [
|
||||
...existing,
|
||||
...response.granted.filter((g) => !seen.has(g.bundleId)),
|
||||
];
|
||||
...response.granted.filter(g => !seen.has(g.bundleId)),
|
||||
]
|
||||
const truthyFlags = Object.fromEntries(
|
||||
Object.entries(response.flags).filter(([, v]) => v === true),
|
||||
);
|
||||
)
|
||||
const flags: CuGrantFlags = {
|
||||
...DEFAULT_GRANT_FLAGS,
|
||||
...existingFlags,
|
||||
...truthyFlags,
|
||||
};
|
||||
return { apps, flags };
|
||||
}
|
||||
return { apps, flags }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,53 +91,53 @@ export function bindSessionContext(
|
||||
coordinateMode: CoordinateMode,
|
||||
ctx: ComputerUseSessionContext,
|
||||
): (name: string, args: unknown) => Promise<CuCallToolResult> {
|
||||
const { logger, serverName } = adapter;
|
||||
const { logger, serverName } = adapter
|
||||
|
||||
// Screenshot blob persists here across calls — NOT on `ctx`. Hosts hold
|
||||
// onto the returned dispatcher; that's the identity that matters.
|
||||
let lastScreenshot: ScreenshotResult | undefined;
|
||||
let lastScreenshot: ScreenshotResult | undefined
|
||||
|
||||
const wrapPermission = ctx.onPermissionRequest
|
||||
? async (
|
||||
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse> => {
|
||||
const response = await ctx.onPermissionRequest!(req, signal);
|
||||
const response = await ctx.onPermissionRequest!(req, signal)
|
||||
const { apps, flags } = mergePermissionResponse(
|
||||
ctx.getAllowedApps(),
|
||||
ctx.getGrantFlags(),
|
||||
response,
|
||||
);
|
||||
)
|
||||
logger.debug(
|
||||
`[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
||||
);
|
||||
ctx.onAllowedAppsChanged?.(apps, flags);
|
||||
return response;
|
||||
)
|
||||
ctx.onAllowedAppsChanged?.(apps, flags)
|
||||
return response
|
||||
}
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
const wrapTeachPermission = ctx.onTeachPermissionRequest
|
||||
? async (
|
||||
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse> => {
|
||||
const response = await ctx.onTeachPermissionRequest!(req, signal);
|
||||
const response = await ctx.onTeachPermissionRequest!(req, signal)
|
||||
logger.debug(
|
||||
`[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
||||
);
|
||||
)
|
||||
// Teach doesn't request grant flags — preserve existing.
|
||||
const { apps } = mergePermissionResponse(
|
||||
ctx.getAllowedApps(),
|
||||
ctx.getGrantFlags(),
|
||||
response,
|
||||
);
|
||||
)
|
||||
ctx.onAllowedAppsChanged?.(apps, {
|
||||
...DEFAULT_GRANT_FLAGS,
|
||||
...ctx.getGrantFlags(),
|
||||
});
|
||||
return response;
|
||||
})
|
||||
return response
|
||||
}
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
return async (name, args) => {
|
||||
// ─── Async lock gate ─────────────────────────────────────────────────
|
||||
@@ -146,18 +146,18 @@ export function bindSessionContext(
|
||||
// cross-process locks (O_EXCL file) await the real primitive here
|
||||
// instead of pre-computing + feeding a fake sync result.
|
||||
if (ctx.checkCuLock) {
|
||||
const lock = await ctx.checkCuLock();
|
||||
const lock = await ctx.checkCuLock()
|
||||
if (lock.holder !== undefined && !lock.isSelf) {
|
||||
const text =
|
||||
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
|
||||
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
content: [{ type: 'text', text }],
|
||||
isError: true,
|
||||
telemetry: { error_kind: "cu_lock_held" },
|
||||
};
|
||||
telemetry: { error_kind: 'cu_lock_held' },
|
||||
}
|
||||
}
|
||||
if (lock.holder === undefined && !defersLockAcquire(name)) {
|
||||
await ctx.acquireCuLock?.();
|
||||
await ctx.acquireCuLock?.()
|
||||
// Re-check: the awaits above yield the microtask queue, so another
|
||||
// session's check+acquire can interleave with ours. Hosts where
|
||||
// acquire is a no-op when already held (Cowork's CuLockManager) give
|
||||
@@ -165,21 +165,21 @@ export function bindSessionContext(
|
||||
// proceeding. The CLI's O_EXCL file lock would surface this as a throw from
|
||||
// acquire instead; this re-check is a belt-and-suspenders for that
|
||||
// path too.
|
||||
const recheck = await ctx.checkCuLock();
|
||||
const recheck = await ctx.checkCuLock()
|
||||
if (recheck.holder !== undefined && !recheck.isSelf) {
|
||||
const text =
|
||||
ctx.formatLockHeldMessage?.(recheck.holder) ??
|
||||
DEFAULT_LOCK_HELD_MESSAGE;
|
||||
DEFAULT_LOCK_HELD_MESSAGE
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
content: [{ type: 'text', text }],
|
||||
isError: true,
|
||||
telemetry: { error_kind: "cu_lock_held" },
|
||||
};
|
||||
telemetry: { error_kind: 'cu_lock_held' },
|
||||
}
|
||||
}
|
||||
// Fresh holder → any prior session's mouseButtonHeld is stale.
|
||||
// Mirrors what Gate-3 does on the acquire branch. After the
|
||||
// re-check so we only clear module state when we actually won.
|
||||
resetMouseButtonHeld();
|
||||
resetMouseButtonHeld()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,12 +189,12 @@ export function bindSessionContext(
|
||||
// isEmpty → skip.
|
||||
const dimsFallback = lastScreenshot
|
||||
? undefined
|
||||
: ctx.getLastScreenshotDims?.();
|
||||
: ctx.getLastScreenshotDims?.()
|
||||
|
||||
// Per-call AbortController for dialog dismissal. Aborted in `finally` —
|
||||
// if handleToolCall finishes (MCP timeout, throw) before the user
|
||||
// answers, the host's dialog handler sees the abort and tears down.
|
||||
const dialogAbort = new AbortController();
|
||||
const dialogAbort = new AbortController()
|
||||
|
||||
const overrides: ComputerUseOverrides = {
|
||||
allowedApps: [...ctx.getAllowedApps()],
|
||||
@@ -206,12 +206,12 @@ export function bindSessionContext(
|
||||
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
|
||||
lastScreenshot:
|
||||
lastScreenshot ??
|
||||
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
|
||||
(dimsFallback ? { ...dimsFallback, base64: '' } : undefined),
|
||||
onPermissionRequest: wrapPermission
|
||||
? (req) => wrapPermission(req, dialogAbort.signal)
|
||||
? req => wrapPermission(req, dialogAbort.signal)
|
||||
: undefined,
|
||||
onTeachPermissionRequest: wrapTeachPermission
|
||||
? (req) => wrapTeachPermission(req, dialogAbort.signal)
|
||||
? req => wrapTeachPermission(req, dialogAbort.signal)
|
||||
: undefined,
|
||||
onAppsHidden: ctx.onAppsHidden,
|
||||
getClipboardStash: ctx.getClipboardStash,
|
||||
@@ -228,28 +228,28 @@ export function bindSessionContext(
|
||||
checkCuLock: undefined,
|
||||
acquireCuLock: undefined,
|
||||
isAborted: ctx.isAborted,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`,
|
||||
);
|
||||
)
|
||||
|
||||
// ─── Dispatch ────────────────────────────────────────────────────────
|
||||
try {
|
||||
const result = await handleToolCall(adapter, name, args, overrides);
|
||||
const result = await handleToolCall(adapter, name, args, overrides)
|
||||
|
||||
if (result.screenshot) {
|
||||
lastScreenshot = result.screenshot;
|
||||
const { base64: _blob, ...dims } = result.screenshot;
|
||||
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`);
|
||||
ctx.onScreenshotCaptured?.(dims);
|
||||
lastScreenshot = result.screenshot
|
||||
const { base64: _blob, ...dims } = result.screenshot
|
||||
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`)
|
||||
ctx.onScreenshotCaptured?.(dims)
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
} finally {
|
||||
dialogAbort.abort();
|
||||
dialogAbort.abort()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createComputerUseMcpServer(
|
||||
@@ -257,35 +257,36 @@ export function createComputerUseMcpServer(
|
||||
coordinateMode: CoordinateMode,
|
||||
context?: ComputerUseSessionContext,
|
||||
): Server {
|
||||
const { serverName, logger } = adapter;
|
||||
const { serverName, logger } = adapter
|
||||
|
||||
const server = new Server(
|
||||
{ name: serverName, version: "0.1.3" },
|
||||
{ name: serverName, version: '0.1.3' },
|
||||
{ capabilities: { tools: {}, logging: {} } },
|
||||
);
|
||||
)
|
||||
|
||||
const tools = buildComputerUseTools(
|
||||
adapter.executor.capabilities,
|
||||
coordinateMode,
|
||||
);
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
||||
adapter.isDisabled() ? { tools: [] } : { tools },
|
||||
);
|
||||
)
|
||||
|
||||
if (context) {
|
||||
const dispatch = bindSessionContext(adapter, coordinateMode, context);
|
||||
const dispatch = bindSessionContext(adapter, coordinateMode, context)
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
|
||||
request.params.name,
|
||||
request.params.arguments ?? {},
|
||||
);
|
||||
return result;
|
||||
const {
|
||||
screenshot: _s,
|
||||
telemetry: _t,
|
||||
...result
|
||||
} = await dispatch(request.params.name, request.params.arguments ?? {})
|
||||
return result
|
||||
},
|
||||
);
|
||||
return server;
|
||||
)
|
||||
return server
|
||||
}
|
||||
|
||||
// Legacy: no context → stub handler. Reached only if something calls the
|
||||
@@ -296,18 +297,18 @@ export function createComputerUseMcpServer(
|
||||
async (request): Promise<CallToolResult> => {
|
||||
logger.warn(
|
||||
`[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`,
|
||||
);
|
||||
)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.",
|
||||
type: 'text',
|
||||
text: 'This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
return server;
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -19,28 +19,28 @@
|
||||
* this package never imports it — the crop is a function parameter.
|
||||
*/
|
||||
|
||||
import type { ScreenshotResult } from "./executor.js";
|
||||
import type { Logger } from "./types.js";
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { Logger } from './types.js'
|
||||
|
||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||
export type CropRawPatchFn = (
|
||||
jpegBase64: string,
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
) => Buffer | null;
|
||||
) => Buffer | null
|
||||
|
||||
/** 9×9 is empirically the sweet spot — large enough to catch a tooltip
|
||||
* appearing, small enough to not false-positive on surrounding animation.
|
||||
**/
|
||||
const DEFAULT_GRID_SIZE = 9;
|
||||
const DEFAULT_GRID_SIZE = 9
|
||||
|
||||
export interface PixelCompareResult {
|
||||
/** true → click may proceed. false → patch changed, abort the click. */
|
||||
valid: boolean;
|
||||
valid: boolean
|
||||
/** true → validation did not run (cold start, sub-gate off, or internal
|
||||
* error). The caller MUST treat this identically to `valid: true`. */
|
||||
skipped: boolean;
|
||||
skipped: boolean
|
||||
/** Populated when valid === false. Returned to the model verbatim. */
|
||||
warning?: string;
|
||||
warning?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,22 +57,22 @@ function computeCropRect(
|
||||
yPercent: number,
|
||||
gridSize: number,
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
if (!imgW || !imgH) return null;
|
||||
if (!imgW || !imgH) return null
|
||||
|
||||
const clampedX = Math.max(0, Math.min(100, xPercent));
|
||||
const clampedY = Math.max(0, Math.min(100, yPercent));
|
||||
const clampedX = Math.max(0, Math.min(100, xPercent))
|
||||
const clampedY = Math.max(0, Math.min(100, yPercent))
|
||||
|
||||
const centerX = Math.round((clampedX / 100.0) * imgW);
|
||||
const centerY = Math.round((clampedY / 100.0) * imgH);
|
||||
const centerX = Math.round((clampedX / 100.0) * imgW)
|
||||
const centerY = Math.round((clampedY / 100.0) * imgH)
|
||||
|
||||
const halfGrid = Math.floor(gridSize / 2);
|
||||
const cropX = Math.max(0, centerX - halfGrid);
|
||||
const cropY = Math.max(0, centerY - halfGrid);
|
||||
const cropW = Math.min(gridSize, imgW - cropX);
|
||||
const cropH = Math.min(gridSize, imgH - cropY);
|
||||
if (cropW <= 0 || cropH <= 0) return null;
|
||||
const halfGrid = Math.floor(gridSize / 2)
|
||||
const cropX = Math.max(0, centerX - halfGrid)
|
||||
const cropY = Math.max(0, centerY - halfGrid)
|
||||
const cropW = Math.min(gridSize, imgW - cropX)
|
||||
const cropH = Math.min(gridSize, imgH - cropY)
|
||||
if (cropW <= 0 || cropH <= 0) return null
|
||||
|
||||
return { x: cropX, y: cropY, width: cropW, height: cropH };
|
||||
return { x: cropX, y: cropY, width: cropW, height: cropH }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,17 +98,17 @@ export function comparePixelAtLocation(
|
||||
xPercent,
|
||||
yPercent,
|
||||
gridSize,
|
||||
);
|
||||
if (!rect) return false;
|
||||
)
|
||||
if (!rect) return false
|
||||
|
||||
const patch1 = crop(lastScreenshot.base64, rect);
|
||||
const patch2 = crop(freshScreenshot.base64, rect);
|
||||
if (!patch1 || !patch2) return false;
|
||||
const patch1 = crop(lastScreenshot.base64, rect)
|
||||
const patch2 = crop(freshScreenshot.base64, rect)
|
||||
if (!patch1 || !patch2) return false
|
||||
|
||||
// Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's
|
||||
// .raw() gave RGB.
|
||||
// Doesn't matter — we're comparing two same-format buffers for equality.
|
||||
return patch1.equals(patch2);
|
||||
return patch1.equals(patch2)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,13 +135,13 @@ export async function validateClickTarget(
|
||||
gridSize: number = DEFAULT_GRID_SIZE,
|
||||
): Promise<PixelCompareResult> {
|
||||
if (!lastScreenshot) {
|
||||
return { valid: true, skipped: true };
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const fresh = await takeFreshScreenshot();
|
||||
const fresh = await takeFreshScreenshot()
|
||||
if (!fresh) {
|
||||
return { valid: true, skipped: true };
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
|
||||
const pixelsMatch = comparePixelAtLocation(
|
||||
@@ -151,21 +151,21 @@ export async function validateClickTarget(
|
||||
xPercent,
|
||||
yPercent,
|
||||
gridSize,
|
||||
);
|
||||
)
|
||||
|
||||
if (pixelsMatch) {
|
||||
return { valid: true, skipped: false };
|
||||
return { valid: true, skipped: false }
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
skipped: false,
|
||||
warning:
|
||||
"Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.",
|
||||
};
|
||||
'Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.',
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip validation on technical errors, execute action anyway.
|
||||
// Battle-tested: validation failure must never block the click.
|
||||
logger.debug("[pixelCompare] validation error, skipping", err);
|
||||
return { valid: true, skipped: true };
|
||||
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,33 +11,33 @@
|
||||
|
||||
/** These apps can execute arbitrary shell commands. */
|
||||
const SHELL_ACCESS_BUNDLE_IDS = new Set([
|
||||
"com.apple.Terminal",
|
||||
"com.googlecode.iterm2",
|
||||
"com.microsoft.VSCode",
|
||||
"dev.warp.Warp-Stable",
|
||||
"com.github.wez.wezterm",
|
||||
"io.alacritty",
|
||||
"net.kovidgoyal.kitty",
|
||||
"com.jetbrains.intellij",
|
||||
"com.jetbrains.pycharm",
|
||||
]);
|
||||
'com.apple.Terminal',
|
||||
'com.googlecode.iterm2',
|
||||
'com.microsoft.VSCode',
|
||||
'dev.warp.Warp-Stable',
|
||||
'com.github.wez.wezterm',
|
||||
'io.alacritty',
|
||||
'net.kovidgoyal.kitty',
|
||||
'com.jetbrains.intellij',
|
||||
'com.jetbrains.pycharm',
|
||||
])
|
||||
|
||||
/** Finder in the allowlist ≈ browse + open-any-file. */
|
||||
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(["com.apple.finder"]);
|
||||
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(['com.apple.finder'])
|
||||
|
||||
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]);
|
||||
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(['com.apple.systempreferences'])
|
||||
|
||||
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
...SHELL_ACCESS_BUNDLE_IDS,
|
||||
...FILESYSTEM_ACCESS_BUNDLE_IDS,
|
||||
...SYSTEM_SETTINGS_BUNDLE_IDS,
|
||||
]);
|
||||
])
|
||||
|
||||
export type SentinelCategory = "shell" | "filesystem" | "system_settings";
|
||||
export type SentinelCategory = 'shell' | 'filesystem' | 'system_settings'
|
||||
|
||||
export function getSentinelCategory(bundleId: string): SentinelCategory | null {
|
||||
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return "shell";
|
||||
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return "filesystem";
|
||||
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return "system_settings";
|
||||
return null;
|
||||
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return 'shell'
|
||||
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return 'filesystem'
|
||||
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return 'system_settings'
|
||||
return null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,19 @@ import type {
|
||||
ComputerExecutor,
|
||||
InstalledApp,
|
||||
ScreenshotResult,
|
||||
} from "./executor.js";
|
||||
} from './executor.js'
|
||||
|
||||
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
|
||||
* cross-respawn `scaleCoord` survival. */
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, "base64">;
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
|
||||
|
||||
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
silly: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +35,7 @@ export interface Logger {
|
||||
* Enforced in `runInputActionGates` via the frontmost-app check: keyboard
|
||||
* actions require `"full"`, mouse actions require `"click"` or higher.
|
||||
*/
|
||||
export type CuAppPermTier = "read" | "click" | "full";
|
||||
export type CuAppPermTier = 'read' | 'click' | 'full'
|
||||
|
||||
/**
|
||||
* A single app the user has approved for the current session. Session-scoped
|
||||
@@ -45,32 +45,32 @@ export type CuAppPermTier = "read" | "click" | "full";
|
||||
* scope.
|
||||
*/
|
||||
export interface AppGrant {
|
||||
bundleId: string;
|
||||
displayName: string;
|
||||
bundleId: string
|
||||
displayName: string
|
||||
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
|
||||
grantedAt: number;
|
||||
grantedAt: number
|
||||
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
|
||||
* session state). */
|
||||
tier?: CuAppPermTier;
|
||||
tier?: CuAppPermTier
|
||||
}
|
||||
|
||||
/** Orthogonal to the app allowlist. */
|
||||
export interface CuGrantFlags {
|
||||
clipboardRead: boolean;
|
||||
clipboardWrite: boolean;
|
||||
clipboardRead: boolean
|
||||
clipboardWrite: boolean
|
||||
/**
|
||||
* When false, the `key` tool rejects combos in `keyBlocklist.ts`
|
||||
* (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other
|
||||
* key sequences work regardless.
|
||||
*/
|
||||
systemKeyCombos: boolean;
|
||||
systemKeyCombos: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: false,
|
||||
systemKeyCombos: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked
|
||||
@@ -78,7 +78,7 @@ export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
|
||||
* ONE convention and never learns the other exists. `normalized_0_100`
|
||||
* sidesteps the Retina scaleFactor bug class entirely.
|
||||
*/
|
||||
export type CoordinateMode = "pixels" | "normalized_0_100";
|
||||
export type CoordinateMode = 'pixels' | 'normalized_0_100'
|
||||
|
||||
/**
|
||||
* Independent kill switches for subtle/risky ported behaviors. Read from
|
||||
@@ -86,28 +86,28 @@ export type CoordinateMode = "pixels" | "normalized_0_100";
|
||||
*/
|
||||
export interface CuSubGates {
|
||||
/** 9×9 exact-byte staleness guard before click. */
|
||||
pixelValidation: boolean;
|
||||
pixelValidation: boolean
|
||||
/** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */
|
||||
clipboardPasteMultiline: boolean;
|
||||
clipboardPasteMultiline: boolean
|
||||
/**
|
||||
* Ease-out-cubic mouse glide at 60fps, distance-proportional duration
|
||||
* (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency
|
||||
* per click. When off, cursor teleports instantly.
|
||||
*/
|
||||
mouseAnimation: boolean;
|
||||
mouseAnimation: boolean
|
||||
/**
|
||||
* Pre-action sequence: hide non-allowlisted apps, then defocus us (from the
|
||||
* Vercept acquisition). When off, the
|
||||
* frontmost gate fires in the normal case and the model gets stuck — this
|
||||
* is the A/B-test-the-old-broken-behavior switch.
|
||||
*/
|
||||
hideBeforeAction: boolean;
|
||||
hideBeforeAction: boolean
|
||||
/**
|
||||
* Auto-resolve the target display before each screenshot when the
|
||||
* selected display has no allowed-app windows. When on, `handleScreenshot`
|
||||
* uses the atomic Swift path; off → sticks with `selectedDisplayId`.
|
||||
*/
|
||||
autoTargetDisplay: boolean;
|
||||
autoTargetDisplay: boolean
|
||||
/**
|
||||
* Stash+clear the clipboard while a tier-"click" app is frontmost.
|
||||
* Closes the gap where a click-tier terminal/IDE has a UI Paste button
|
||||
@@ -115,7 +115,7 @@ export interface CuSubGates {
|
||||
* keyboard block can be routed around by clicking Paste. Restored when
|
||||
* a non-"click" app becomes frontmost, or at turn end.
|
||||
*/
|
||||
clipboardGuard: boolean;
|
||||
clipboardGuard: boolean
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -125,17 +125,17 @@ export interface CuSubGates {
|
||||
/** One entry per app the model asked for, after name → bundle ID resolution. */
|
||||
export interface ResolvedAppRequest {
|
||||
/** What the model asked for (e.g. "Slack", "com.tinyspeck.slackmacgap"). */
|
||||
requestedName: string;
|
||||
requestedName: string
|
||||
/** The resolved InstalledApp if found, else undefined (shown greyed in the UI). */
|
||||
resolved?: InstalledApp;
|
||||
resolved?: InstalledApp
|
||||
/** Shell-access-equivalent bundle IDs get a UI warning. See sentinelApps.ts. */
|
||||
isSentinel: boolean;
|
||||
isSentinel: boolean
|
||||
/** Already in the allowlist → skip the checkbox, return in `granted` immediately. */
|
||||
alreadyGranted: boolean;
|
||||
alreadyGranted: boolean
|
||||
/** Hardcoded tier for this app (browser→"read", terminal→"click", else "full").
|
||||
* The dialog displays this read-only; the renderer passes it through
|
||||
* verbatim in the AppGrant. */
|
||||
proposedTier: CuAppPermTier;
|
||||
proposedTier: CuAppPermTier
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,18 +145,18 @@ export interface ResolvedAppRequest {
|
||||
* change needed.
|
||||
*/
|
||||
export interface CuPermissionRequest {
|
||||
requestId: string;
|
||||
requestId: string
|
||||
/** Model-provided reason string. Shown prominently in the approval UI. */
|
||||
reason: string;
|
||||
apps: ResolvedAppRequest[];
|
||||
reason: string
|
||||
apps: ResolvedAppRequest[]
|
||||
/** What the model asked for. User can toggle independently of apps. */
|
||||
requestedFlags: Partial<CuGrantFlags>;
|
||||
requestedFlags: Partial<CuGrantFlags>
|
||||
/**
|
||||
* For the "On Windows, Claude can see all apps..." footnote. Taken from
|
||||
* `executor.capabilities.screenshotFiltering` so the renderer doesn't
|
||||
* need to know about platforms.
|
||||
*/
|
||||
screenshotFiltering: "native" | "none";
|
||||
screenshotFiltering: 'native' | 'none'
|
||||
/**
|
||||
* Present only when TCC permissions are NOT yet granted. When present,
|
||||
* the renderer shows a TCC toggle panel (two rows: Accessibility, Screen
|
||||
@@ -166,9 +166,9 @@ export interface CuPermissionRequest {
|
||||
* restart after granting Screen Recording — we don't.
|
||||
*/
|
||||
tccState?: {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
};
|
||||
accessibility: boolean
|
||||
screenRecording: boolean
|
||||
}
|
||||
/**
|
||||
* Apps with windows on the CU display that aren't in the requested
|
||||
* allowlist. These will be hidden the first time Claude takes an action.
|
||||
@@ -176,13 +176,13 @@ export interface CuPermissionRequest {
|
||||
* user clicks Allow, but it's a preview, not a contract. Absent when
|
||||
* empty so the renderer can skip the section cleanly.
|
||||
*/
|
||||
willHide?: Array<{ bundleId: string; displayName: string }>;
|
||||
willHide?: Array<{ bundleId: string; displayName: string }>
|
||||
/**
|
||||
* `chicagoAutoUnhide` app preference at request time. The renderer picks
|
||||
* between "...then restored when Claude is done" and "...will be hidden"
|
||||
* copy. Absent when `willHide` is absent (same condition).
|
||||
*/
|
||||
autoUnhideEnabled?: boolean;
|
||||
autoUnhideEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,10 +191,10 @@ export interface CuPermissionRequest {
|
||||
* LocalAgentModeSessionManager.ts:2794).
|
||||
*/
|
||||
export interface CuPermissionResponse {
|
||||
granted: AppGrant[];
|
||||
granted: AppGrant[]
|
||||
/** Bundle IDs the user unchecked, or apps that weren't installed. */
|
||||
denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>;
|
||||
flags: CuGrantFlags;
|
||||
denied: Array<{ bundleId: string; reason: 'user_denied' | 'not_installed' }>
|
||||
flags: CuGrantFlags
|
||||
/**
|
||||
* Whether the user clicked Allow in THIS dialog. Only set by the
|
||||
* teach-mode handler — regular request_access doesn't need it (the
|
||||
@@ -205,7 +205,7 @@ export interface CuPermissionResponse {
|
||||
* them apart without this. Undefined → legacy/regular path, do not
|
||||
* gate on it.
|
||||
*/
|
||||
userConsented?: boolean;
|
||||
userConsented?: boolean
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -218,9 +218,9 @@ export interface CuPermissionResponse {
|
||||
* No Electron imports in this package — the host injects everything.
|
||||
*/
|
||||
export interface ComputerUseHostAdapter {
|
||||
serverName: string;
|
||||
logger: Logger;
|
||||
executor: ComputerExecutor;
|
||||
serverName: string
|
||||
logger: Logger
|
||||
executor: ComputerExecutor
|
||||
|
||||
/**
|
||||
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
|
||||
@@ -231,23 +231,23 @@ export interface ComputerUseHostAdapter {
|
||||
ensureOsPermissions(): Promise<
|
||||
| { granted: true }
|
||||
| { granted: false; accessibility: boolean; screenRecording: boolean }
|
||||
>;
|
||||
>
|
||||
|
||||
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
|
||||
isDisabled(): boolean;
|
||||
isDisabled(): boolean
|
||||
|
||||
/**
|
||||
* The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest`
|
||||
* to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's
|
||||
* "will be hidden" copy can say "then restored" only when true.
|
||||
*/
|
||||
getAutoUnhideEnabled(): boolean;
|
||||
getAutoUnhideEnabled(): boolean
|
||||
|
||||
/**
|
||||
* Sub-gates re-read on every tool call so GrowthBook flips take effect
|
||||
* mid-session without restart.
|
||||
*/
|
||||
getSubGates(): CuSubGates;
|
||||
getSubGates(): CuSubGates
|
||||
|
||||
/**
|
||||
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
|
||||
@@ -261,7 +261,7 @@ export interface ComputerUseHostAdapter {
|
||||
cropRawPatch(
|
||||
jpegBase64: string,
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
): Buffer | null;
|
||||
): Buffer | null
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -286,18 +286,18 @@ export interface ComputerUseHostAdapter {
|
||||
export interface ComputerUseSessionContext {
|
||||
// ── Read state fresh per call ──────────────────────────────────────
|
||||
|
||||
getAllowedApps(): readonly AppGrant[];
|
||||
getGrantFlags(): CuGrantFlags;
|
||||
getAllowedApps(): readonly AppGrant[]
|
||||
getGrantFlags(): CuGrantFlags
|
||||
/** Per-user auto-deny list (Settings page). Empty array = none. */
|
||||
getUserDeniedBundleIds(): readonly string[];
|
||||
getSelectedDisplayId(): number | undefined;
|
||||
getDisplayPinnedByModel?(): boolean;
|
||||
getDisplayResolvedForApps?(): string | undefined;
|
||||
getTeachModeActive?(): boolean;
|
||||
getUserDeniedBundleIds(): readonly string[]
|
||||
getSelectedDisplayId(): number | undefined
|
||||
getDisplayPinnedByModel?(): boolean
|
||||
getDisplayResolvedForApps?(): string | undefined
|
||||
getTeachModeActive?(): boolean
|
||||
/** Dims-only fallback when `lastScreenshot` is unset (cross-respawn).
|
||||
* `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord
|
||||
* works and pixelCompare correctly skips. */
|
||||
getLastScreenshotDims?(): ScreenshotDims | undefined;
|
||||
getLastScreenshotDims?(): ScreenshotDims | undefined
|
||||
|
||||
// ── Write-back callbacks ───────────────────────────────────────────
|
||||
|
||||
@@ -307,46 +307,46 @@ export interface ComputerUseSessionContext {
|
||||
onPermissionRequest?(
|
||||
req: CuPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse>;
|
||||
): Promise<CuPermissionResponse>
|
||||
/** Teach-mode sibling of `onPermissionRequest`. */
|
||||
onTeachPermissionRequest?(
|
||||
req: CuTeachPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse>;
|
||||
): Promise<CuPermissionResponse>
|
||||
/** Called by `bindSessionContext` after merging a permission response into
|
||||
* the allowlist (dedupe on bundleId, truthy-only flag spread). Host
|
||||
* persists for resume survival. */
|
||||
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void;
|
||||
onAppsHidden?(bundleIds: string[]): void;
|
||||
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void
|
||||
onAppsHidden?(bundleIds: string[]): void
|
||||
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
|
||||
getClipboardStash?(): string | undefined;
|
||||
getClipboardStash?(): string | undefined
|
||||
/** Writes the clipboardGuard stash. undefined clears it. */
|
||||
onClipboardStashChanged?(stash: string | undefined): void;
|
||||
onResolvedDisplayUpdated?(displayId: number): void;
|
||||
onDisplayPinned?(displayId: number | undefined): void;
|
||||
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void;
|
||||
onClipboardStashChanged?(stash: string | undefined): void
|
||||
onResolvedDisplayUpdated?(displayId: number): void
|
||||
onDisplayPinned?(displayId: number | undefined): void
|
||||
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void
|
||||
/** Called after each screenshot. Host persists for respawn survival. */
|
||||
onScreenshotCaptured?(dims: ScreenshotDims): void;
|
||||
onTeachModeActivated?(): void;
|
||||
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>;
|
||||
onTeachWorking?(): void;
|
||||
onScreenshotCaptured?(dims: ScreenshotDims): void
|
||||
onTeachModeActivated?(): void
|
||||
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>
|
||||
onTeachWorking?(): void
|
||||
|
||||
// ── Lock (async) ───────────────────────────────────────────────────
|
||||
|
||||
/** At most one session uses CU at a time. Awaited by `bindSessionContext`
|
||||
* before dispatch. Undefined → no lock gating (proceed). */
|
||||
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>;
|
||||
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>
|
||||
/** Take the lock. Called when `checkCuLock` returned `holder: undefined`
|
||||
* on a non-deferring tool. Host emits enter-CU signals here. */
|
||||
acquireCuLock?(): Promise<void>;
|
||||
acquireCuLock?(): Promise<void>
|
||||
/** Host-specific lock-held error text. Default is the package's generic
|
||||
* message. The CLI host includes the holder session-ID prefix. */
|
||||
formatLockHeldMessage?(holder: string): string;
|
||||
formatLockHeldMessage?(holder: string): string
|
||||
|
||||
/** User-abort signal. Passed through to `ComputerUseOverrides.isAborted`
|
||||
* for the mid-loop checks in handleComputerBatch / handleType. See that
|
||||
* field for semantics. */
|
||||
isAborted?(): boolean;
|
||||
isAborted?(): boolean
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -360,9 +360,9 @@ export interface ComputerUseSessionContext {
|
||||
* store, not the server.
|
||||
*/
|
||||
export interface ComputerUseOverrides {
|
||||
allowedApps: AppGrant[];
|
||||
grantFlags: CuGrantFlags;
|
||||
coordinateMode: CoordinateMode;
|
||||
allowedApps: AppGrant[]
|
||||
grantFlags: CuGrantFlags
|
||||
coordinateMode: CoordinateMode
|
||||
|
||||
/**
|
||||
* User-configured auto-deny list (Settings → Desktop app → Computer Use).
|
||||
@@ -376,14 +376,14 @@ export interface ComputerUseOverrides {
|
||||
* not session state). Contrast with `allowedApps` which is per-session.
|
||||
* Empty array = no user-configured denies (the default).
|
||||
*/
|
||||
userDeniedBundleIds: readonly string[];
|
||||
userDeniedBundleIds: readonly string[]
|
||||
|
||||
/**
|
||||
* Display CU operates on; read fresh per call. `scaleCoord` uses the
|
||||
* `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches
|
||||
* only affect the NEXT screenshot/prepare call.
|
||||
*/
|
||||
selectedDisplayId?: number;
|
||||
selectedDisplayId?: number
|
||||
|
||||
/**
|
||||
* The `request_access` tool handler calls this and awaits. The wrapper
|
||||
@@ -395,14 +395,16 @@ export interface ComputerUseOverrides {
|
||||
* Undefined when the session wasn't wired with a permission handler (e.g.
|
||||
* a future headless mode). `request_access` returns a tool error in that case.
|
||||
*/
|
||||
onPermissionRequest?: (req: CuPermissionRequest) => Promise<CuPermissionResponse>;
|
||||
onPermissionRequest?: (
|
||||
req: CuPermissionRequest,
|
||||
) => Promise<CuPermissionResponse>
|
||||
|
||||
/**
|
||||
* For the pixel-validation staleness guard. The model's-last-screenshot,
|
||||
* stashed by serverDef.ts after each `screenshot` tool call. Undefined on
|
||||
* cold start → pixel validation skipped (click proceeds).
|
||||
*/
|
||||
lastScreenshot?: ScreenshotResult;
|
||||
lastScreenshot?: ScreenshotResult
|
||||
|
||||
/**
|
||||
* Fired after every `prepareForAction` with the bundle IDs it just hid.
|
||||
@@ -416,7 +418,7 @@ export interface ComputerUseOverrides {
|
||||
* Undefined when the session wasn't wired with a tracker — unhide just
|
||||
* doesn't happen.
|
||||
*/
|
||||
onAppsHidden?: (bundleIds: string[]) => void;
|
||||
onAppsHidden?: (bundleIds: string[]) => void
|
||||
|
||||
/**
|
||||
* Reads the clipboardGuard stash from session state. `undefined` means no
|
||||
@@ -424,7 +426,7 @@ export interface ComputerUseOverrides {
|
||||
* and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern
|
||||
* — state lives on the host's session, not module-level here.
|
||||
*/
|
||||
getClipboardStash?: () => string | undefined;
|
||||
getClipboardStash?: () => string | undefined
|
||||
|
||||
/**
|
||||
* Writes the clipboardGuard stash to session state. `undefined` clears.
|
||||
@@ -433,7 +435,7 @@ export interface ComputerUseOverrides {
|
||||
* directly and restores via Electron's `clipboard.writeText` (no nest-only
|
||||
* import surface).
|
||||
*/
|
||||
onClipboardStashChanged?: (stash: string | undefined) => void;
|
||||
onClipboardStashChanged?: (stash: string | undefined) => void
|
||||
|
||||
/**
|
||||
* Write the resolver's picked display back to session so teach overlay
|
||||
@@ -442,7 +444,7 @@ export interface ComputerUseOverrides {
|
||||
* `resolvePrepareCapture`'s pick differs from `selectedDisplayId`.
|
||||
* Fire-and-forget.
|
||||
*/
|
||||
onResolvedDisplayUpdated?: (displayId: number) => void;
|
||||
onResolvedDisplayUpdated?: (displayId: number) => void
|
||||
|
||||
/**
|
||||
* Set when the model explicitly picked a display via `switch_display`.
|
||||
@@ -453,7 +455,7 @@ export interface ComputerUseOverrides {
|
||||
* overrides any `selectedDisplayId` whenever an allowed app shares the
|
||||
* host's monitor.
|
||||
*/
|
||||
displayPinnedByModel?: boolean;
|
||||
displayPinnedByModel?: boolean
|
||||
|
||||
/**
|
||||
* Write the model's explicit display pick to session. `displayId:
|
||||
@@ -461,7 +463,7 @@ export interface ComputerUseOverrides {
|
||||
* Sibling of `onResolvedDisplayUpdated` but also sets the pin flag —
|
||||
* the two are semantically distinct (resolver-picked vs model-picked).
|
||||
*/
|
||||
onDisplayPinned?: (displayId: number | undefined) => void;
|
||||
onDisplayPinned?: (displayId: number | undefined) => void
|
||||
|
||||
/**
|
||||
* Sorted comma-joined bundle-ID set the display was last auto-resolved
|
||||
@@ -470,14 +472,14 @@ export interface ComputerUseOverrides {
|
||||
* doesn't yank the display on every screenshot, only when the app set
|
||||
* has changed since the last resolve (or manual switch).
|
||||
*/
|
||||
displayResolvedForApps?: string;
|
||||
displayResolvedForApps?: string
|
||||
|
||||
/**
|
||||
* Records which app set the current display selection was made for. Fired
|
||||
* alongside `onResolvedDisplayUpdated` when the resolver picks, so the next
|
||||
* screenshot sees a matching set and skips auto-resolve.
|
||||
*/
|
||||
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void;
|
||||
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void
|
||||
|
||||
/**
|
||||
* Global CU lock — at most one session actively uses CU at a time. Checked
|
||||
@@ -494,7 +496,7 @@ export interface ComputerUseOverrides {
|
||||
* The host manages release (on session idle/stop/archive) — this package
|
||||
* never releases.
|
||||
*/
|
||||
checkCuLock?: () => { holder: string | undefined; isSelf: boolean };
|
||||
checkCuLock?: () => { holder: string | undefined; isSelf: boolean }
|
||||
|
||||
/**
|
||||
* Take the lock for this session. `handleToolCall` calls this exactly once
|
||||
@@ -502,7 +504,7 @@ export interface ComputerUseOverrides {
|
||||
* undefined. No-op if already held (defensive — the check should have
|
||||
* short-circuited). Host emits an event the overlay listens to.
|
||||
*/
|
||||
acquireCuLock?: () => void;
|
||||
acquireCuLock?: () => void
|
||||
|
||||
/**
|
||||
* User-abort signal. Checked mid-iteration inside `handleComputerBatch`
|
||||
@@ -513,7 +515,7 @@ export interface ComputerUseOverrides {
|
||||
* Undefined → never aborts (e.g. unwired host). Live per-check read —
|
||||
* same lazy-getter pattern as `checkCuLock`.
|
||||
*/
|
||||
isAborted?: () => boolean;
|
||||
isAborted?: () => boolean
|
||||
|
||||
// ── Teach mode ───────────────────────────────────────────────────────
|
||||
// Wired only when the host's teachModeEnabled gate is on. All five
|
||||
@@ -529,7 +531,7 @@ export interface ComputerUseOverrides {
|
||||
*/
|
||||
onTeachPermissionRequest?: (
|
||||
req: CuTeachPermissionRequest,
|
||||
) => Promise<CuPermissionResponse>;
|
||||
) => Promise<CuPermissionResponse>
|
||||
|
||||
/**
|
||||
* Called by `handleRequestTeachAccess` after the user approves and at least
|
||||
@@ -538,7 +540,7 @@ export interface ComputerUseOverrides {
|
||||
* fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`)
|
||||
* alongside the CU lock release.
|
||||
*/
|
||||
onTeachModeActivated?: () => void;
|
||||
onTeachModeActivated?: () => void
|
||||
|
||||
/**
|
||||
* Read by `handleRequestAccess` and `handleRequestTeachAccess` to
|
||||
@@ -549,7 +551,7 @@ export interface ComputerUseOverrides {
|
||||
* (not a boolean field) because teach mode state lives on the session,
|
||||
* not on this per-call overrides object.
|
||||
*/
|
||||
getTeachModeActive?: () => boolean;
|
||||
getTeachModeActive?: () => boolean
|
||||
|
||||
/**
|
||||
* Called by `handleTeachStep` with the scaled anchor + text. Host stores
|
||||
@@ -562,7 +564,7 @@ export interface ComputerUseOverrides {
|
||||
* Same blocking-promise pattern as `onPermissionRequest`, but resolved by
|
||||
* the teach overlay's own preload (not the main renderer's tool-approval UI).
|
||||
*/
|
||||
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>;
|
||||
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>
|
||||
|
||||
/**
|
||||
* Called immediately after `onTeachStep` resolves with "next", before
|
||||
@@ -571,7 +573,7 @@ export interface ComputerUseOverrides {
|
||||
* notch). The next `onTeachStep` call replaces the spinner with the new
|
||||
* tooltip content.
|
||||
*/
|
||||
onTeachWorking?: () => void;
|
||||
onTeachWorking?: () => void
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -590,13 +592,13 @@ export interface ComputerUseOverrides {
|
||||
* CSS coords match.
|
||||
*/
|
||||
export interface TeachStepRequest {
|
||||
explanation: string;
|
||||
nextPreview: string;
|
||||
explanation: string
|
||||
nextPreview: string
|
||||
/** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */
|
||||
anchorLogical?: { x: number; y: number };
|
||||
anchorLogical?: { x: number; y: number }
|
||||
}
|
||||
|
||||
export type TeachStepResult = { action: "next" } | { action: "exit" };
|
||||
export type TeachStepResult = { action: 'next' } | { action: 'exit' }
|
||||
|
||||
/**
|
||||
* Payload for the renderer's ComputerUseTeachApproval dialog. Rides through
|
||||
@@ -606,17 +608,17 @@ export type TeachStepResult = { action: "next" } | { action: "exit" };
|
||||
* fields it doesn't render (no grant-flag checkboxes in teach mode).
|
||||
*/
|
||||
export interface CuTeachPermissionRequest {
|
||||
requestId: string;
|
||||
requestId: string
|
||||
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
|
||||
reason: string;
|
||||
apps: ResolvedAppRequest[];
|
||||
screenshotFiltering: "native" | "none";
|
||||
reason: string
|
||||
apps: ResolvedAppRequest[]
|
||||
screenshotFiltering: 'native' | 'none'
|
||||
/** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */
|
||||
tccState?: {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
};
|
||||
willHide?: Array<{ bundleId: string; displayName: string }>;
|
||||
accessibility: boolean
|
||||
screenRecording: boolean
|
||||
}
|
||||
willHide?: Array<{ bundleId: string; displayName: string }>
|
||||
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
|
||||
autoUnhideEnabled?: boolean;
|
||||
autoUnhideEnabled?: boolean
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@ant/computer-use-swift",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
"name": "@ant/computer-use-swift",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -9,9 +9,17 @@ import { readFileSync, unlinkSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
AppInfo,
|
||||
AppsAPI,
|
||||
DisplayAPI,
|
||||
DisplayGeometry,
|
||||
InstalledApp,
|
||||
PrepareDisplayResult,
|
||||
RunningApp,
|
||||
ScreenshotAPI,
|
||||
ScreenshotResult,
|
||||
SwiftBackend,
|
||||
WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
export type {
|
||||
@@ -32,7 +40,8 @@ export type {
|
||||
function jxaSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
@@ -40,14 +49,16 @@ function jxaSync(script: string): string {
|
||||
function osascriptSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(['osascript', '-e', script], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
@@ -56,7 +67,8 @@ async function osascript(script: string): Promise<string> {
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(['osascript', '-l', 'JavaScript', '-e', script], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
@@ -101,8 +113,10 @@ export const display: DisplayAPI = {
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||
width: Number(d.width), height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
||||
width: Number(d.width),
|
||||
height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor),
|
||||
displayId: Number(d.displayId),
|
||||
}))
|
||||
} catch {
|
||||
try {
|
||||
@@ -126,8 +140,10 @@ export const display: DisplayAPI = {
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||
width: Number(d.width), height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
||||
width: Number(d.width),
|
||||
height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor),
|
||||
displayId: Number(d.displayId),
|
||||
}))
|
||||
} catch {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
|
||||
@@ -177,9 +193,15 @@ export const apps: AppsAPI = {
|
||||
const dirs = ['/Applications', '~/Applications', '/System/Applications']
|
||||
const allApps: InstalledApp[] = []
|
||||
for (const dir of dirs) {
|
||||
const expanded = dir.startsWith('~') ? join(process.env.HOME ?? '~', dir.slice(1)) : dir
|
||||
const expanded = dir.startsWith('~')
|
||||
? join(process.env.HOME ?? '~', dir.slice(1))
|
||||
: dir
|
||||
const proc = Bun.spawn(
|
||||
['bash', '-c', `for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`],
|
||||
[
|
||||
'bash',
|
||||
'-c',
|
||||
`for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
const text = await new Response(proc.stdout).text()
|
||||
@@ -245,10 +267,13 @@ export const apps: AppsAPI = {
|
||||
// ScreenshotAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function captureScreenToBase64(args: string[]): Promise<{ base64: string; width: number; height: number }> {
|
||||
async function captureScreenToBase64(
|
||||
args: string[],
|
||||
): Promise<{ base64: string; width: number; height: number }> {
|
||||
const tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`)
|
||||
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
await proc.exited
|
||||
try {
|
||||
@@ -258,18 +283,36 @@ async function captureScreenToBase64(args: string[]): Promise<{ base64: string;
|
||||
const height = buf.readUInt32BE(20)
|
||||
return { base64, width, height }
|
||||
} finally {
|
||||
try { unlinkSync(tmpFile) } catch {}
|
||||
try {
|
||||
unlinkSync(tmpFile)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export const screenshot: ScreenshotAPI = {
|
||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
|
||||
async captureExcluding(
|
||||
_allowedBundleIds,
|
||||
_quality,
|
||||
_targetW,
|
||||
_targetH,
|
||||
displayId,
|
||||
) {
|
||||
const args = ['-x']
|
||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
|
||||
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, displayId) {
|
||||
async captureRegion(
|
||||
_allowedBundleIds,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
_outW,
|
||||
_outH,
|
||||
_quality,
|
||||
displayId,
|
||||
) {
|
||||
const args = ['-x', '-R', `${x},${y},${w},${h}`]
|
||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||
return captureScreenToBase64(args)
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
AppInfo,
|
||||
AppsAPI,
|
||||
DisplayAPI,
|
||||
DisplayGeometry,
|
||||
InstalledApp,
|
||||
PrepareDisplayResult,
|
||||
RunningApp,
|
||||
ScreenshotAPI,
|
||||
ScreenshotResult,
|
||||
SwiftBackend,
|
||||
WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,7 +42,11 @@ async function runAsync(cmd: string[]): Promise<string> {
|
||||
}
|
||||
|
||||
function commandExists(name: string): boolean {
|
||||
const result = Bun.spawnSync({ cmd: ['which', name], stdout: 'pipe', stderr: 'pipe' })
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['which', name],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
@@ -85,7 +97,11 @@ export const display: DisplayAPI = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const apps: AppsAPI = {
|
||||
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId): Promise<PrepareDisplayResult> {
|
||||
async prepareDisplay(
|
||||
_allowlistBundleIds,
|
||||
_surrogateHost,
|
||||
_displayId,
|
||||
): Promise<PrepareDisplayResult> {
|
||||
return { activated: '', hidden: [] }
|
||||
},
|
||||
|
||||
@@ -100,7 +116,15 @@ export const apps: AppsAPI = {
|
||||
async appUnderPoint(x, y): Promise<AppInfo | null> {
|
||||
try {
|
||||
// Move mouse to point, get window under cursor
|
||||
const out = run(['xdotool', 'mousemove', '--sync', String(x), String(y), 'getmouselocation', '--shell'])
|
||||
const out = run([
|
||||
'xdotool',
|
||||
'mousemove',
|
||||
'--sync',
|
||||
String(x),
|
||||
String(y),
|
||||
'getmouselocation',
|
||||
'--shell',
|
||||
])
|
||||
const windowMatch = out.match(/WINDOW=(\d+)/)
|
||||
if (!windowMatch) return null
|
||||
|
||||
@@ -109,10 +133,18 @@ export const apps: AppsAPI = {
|
||||
if (!pidStr) return null
|
||||
|
||||
let exePath = ''
|
||||
try { exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`]) } catch { /* ignore */ }
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
let appName = ''
|
||||
try { appName = run(['cat', `/proc/${pidStr}/comm`]) } catch { /* ignore */ }
|
||||
try {
|
||||
appName = run(['cat', `/proc/${pidStr}/comm`])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!exePath && !appName) return null
|
||||
return { bundleId: exePath || pidStr!, displayName: appName || 'unknown' }
|
||||
@@ -124,14 +156,20 @@ export const apps: AppsAPI = {
|
||||
async listInstalled(): Promise<InstalledApp[]> {
|
||||
try {
|
||||
// Read .desktop files from standard locations
|
||||
const dirs = ['/usr/share/applications', '/usr/local/share/applications', `${process.env.HOME}/.local/share/applications`]
|
||||
const dirs = [
|
||||
'/usr/share/applications',
|
||||
'/usr/local/share/applications',
|
||||
`${process.env.HOME}/.local/share/applications`,
|
||||
]
|
||||
const apps: InstalledApp[] = []
|
||||
|
||||
for (const dir of dirs) {
|
||||
let files: string
|
||||
try {
|
||||
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
|
||||
} catch { continue }
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const filepath of files.split('\n').filter(Boolean)) {
|
||||
try {
|
||||
@@ -146,11 +184,14 @@ export const apps: AppsAPI = {
|
||||
if (!name) continue
|
||||
|
||||
apps.push({
|
||||
bundleId: filepath.split('/').pop()?.replace('.desktop', '') ?? '',
|
||||
bundleId:
|
||||
filepath.split('/').pop()?.replace('.desktop', '') ?? '',
|
||||
displayName: name,
|
||||
path: exec.split(/\s+/)[0] ?? '',
|
||||
})
|
||||
} catch { /* skip unreadable files */ }
|
||||
} catch {
|
||||
/* skip unreadable files */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +218,17 @@ export const apps: AppsAPI = {
|
||||
if (!pid || pid === '0') continue
|
||||
|
||||
let exePath = ''
|
||||
try { exePath = run(['readlink', '-f', `/proc/${pid}/exe`]) } catch { /* ignore */ }
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
let appName = ''
|
||||
try { appName = run(['cat', `/proc/${pid}/comm`]) } catch { /* ignore */ }
|
||||
try {
|
||||
appName = run(['cat', `/proc/${pid}/comm`])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (appName) {
|
||||
apps.push({ bundleId: exePath || pid, displayName: appName })
|
||||
@@ -187,11 +236,13 @@ export const apps: AppsAPI = {
|
||||
}
|
||||
// Deduplicate by bundleId
|
||||
const seen = new Set<string>()
|
||||
return apps.filter(a => {
|
||||
if (seen.has(a.bundleId)) return false
|
||||
seen.add(a.bundleId)
|
||||
return true
|
||||
}).slice(0, 50)
|
||||
return apps
|
||||
.filter(a => {
|
||||
if (seen.has(a.bundleId)) return false
|
||||
seen.add(a.bundleId)
|
||||
return true
|
||||
})
|
||||
.slice(0, 50)
|
||||
}
|
||||
|
||||
// Fallback: ps with visible processes
|
||||
@@ -217,7 +268,9 @@ export const apps: AppsAPI = {
|
||||
await runAsync(['gtk-launch', desktopName])
|
||||
return
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
await runAsync(['xdg-open', name])
|
||||
},
|
||||
@@ -232,7 +285,9 @@ export const apps: AppsAPI = {
|
||||
// Try xdotool windowactivate with search by name
|
||||
await runAsync(['xdotool', 'search', '--name', id, 'windowactivate'])
|
||||
}
|
||||
} catch { /* ignore failures for individual windows */ }
|
||||
} catch {
|
||||
/* ignore failures for individual windows */
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -244,7 +299,13 @@ export const apps: AppsAPI = {
|
||||
const SCREENSHOT_PATH = '/tmp/cu-screenshot.png'
|
||||
|
||||
export const screenshot: ScreenshotAPI = {
|
||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, _displayId): Promise<ScreenshotResult> {
|
||||
async captureExcluding(
|
||||
_allowedBundleIds,
|
||||
_quality,
|
||||
_targetW,
|
||||
_targetH,
|
||||
_displayId,
|
||||
): Promise<ScreenshotResult> {
|
||||
try {
|
||||
await runAsync(['scrot', '-o', SCREENSHOT_PATH])
|
||||
|
||||
@@ -261,10 +322,26 @@ export const screenshot: ScreenshotAPI = {
|
||||
}
|
||||
},
|
||||
|
||||
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId): Promise<ScreenshotResult> {
|
||||
async captureRegion(
|
||||
_allowedBundleIds,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
_outW,
|
||||
_outH,
|
||||
_quality,
|
||||
_displayId,
|
||||
): Promise<ScreenshotResult> {
|
||||
try {
|
||||
// scrot -a x,y,w,h captures a specific region
|
||||
await runAsync(['scrot', '-a', `${x},${y},${w},${h}`, '-o', SCREENSHOT_PATH])
|
||||
await runAsync([
|
||||
'scrot',
|
||||
'-a',
|
||||
`${x},${y},${w},${h}`,
|
||||
'-o',
|
||||
SCREENSHOT_PATH,
|
||||
])
|
||||
|
||||
const file = Bun.file(SCREENSHOT_PATH)
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
@@ -6,13 +6,24 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
AppInfo,
|
||||
AppsAPI,
|
||||
DisplayAPI,
|
||||
DisplayGeometry,
|
||||
InstalledApp,
|
||||
PrepareDisplayResult,
|
||||
RunningApp,
|
||||
ScreenshotAPI,
|
||||
ScreenshotResult,
|
||||
SwiftBackend,
|
||||
WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
import { listWindows } from 'src/utils/computerUse/win32/windowEnum.js'
|
||||
import { captureWindow, captureWindowByHwnd } from 'src/utils/computerUse/win32/windowCapture.js'
|
||||
import {
|
||||
captureWindow,
|
||||
captureWindowByHwnd,
|
||||
} from 'src/utils/computerUse/win32/windowCapture.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PowerShell helper
|
||||
@@ -63,15 +74,18 @@ foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
|
||||
}
|
||||
$result -join "|"
|
||||
`)
|
||||
return raw.split('|').filter(Boolean).map(entry => {
|
||||
const [w, h, id, primary] = entry.split(',')
|
||||
return {
|
||||
width: Number(w),
|
||||
height: Number(h),
|
||||
scaleFactor: 1, // Windows DPI scaling handled at system level
|
||||
displayId: Number(id),
|
||||
}
|
||||
})
|
||||
return raw
|
||||
.split('|')
|
||||
.filter(Boolean)
|
||||
.map(entry => {
|
||||
const [w, h, id, primary] = entry.split(',')
|
||||
return {
|
||||
width: Number(w),
|
||||
height: Number(h),
|
||||
scaleFactor: 1, // Windows DPI scaling handled at system level
|
||||
displayId: Number(id),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
|
||||
}
|
||||
@@ -139,14 +153,17 @@ foreach ($p in $paths) {
|
||||
}
|
||||
$apps | Select-Object -Unique | Select-Object -First 200
|
||||
`)
|
||||
return raw.split('\n').filter(Boolean).map(line => {
|
||||
const [name, path, id] = line.split('|', 3)
|
||||
return {
|
||||
bundleId: id ?? name ?? '',
|
||||
displayName: name ?? '',
|
||||
path: path ?? '',
|
||||
}
|
||||
})
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [name, path, id] = line.split('|', 3)
|
||||
return {
|
||||
bundleId: id ?? name ?? '',
|
||||
displayName: name ?? '',
|
||||
path: path ?? '',
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -204,7 +221,13 @@ if ($proc) { [WinShow]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null; [WinSh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const screenshot: ScreenshotAPI = {
|
||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
|
||||
async captureExcluding(
|
||||
_allowedBundleIds,
|
||||
_quality,
|
||||
_targetW,
|
||||
_targetH,
|
||||
displayId,
|
||||
) {
|
||||
const raw = await psAsync(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
@@ -229,7 +252,17 @@ $ms.Dispose()
|
||||
return { base64, width, height }
|
||||
},
|
||||
|
||||
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId) {
|
||||
async captureRegion(
|
||||
_allowedBundleIds,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
_outW,
|
||||
_outH,
|
||||
_quality,
|
||||
_displayId,
|
||||
) {
|
||||
const raw = await psAsync(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
@@ -37,25 +37,52 @@ const backend = loadBackend()
|
||||
|
||||
export class ComputerUseAPI {
|
||||
apps = backend?.apps ?? {
|
||||
async prepareDisplay() { return { activated: '', hidden: [] } },
|
||||
async previewHideSet() { return [] },
|
||||
async findWindowDisplays(ids: string[]) { return ids.map((b: string) => ({ bundleId: b, displayIds: [] as number[] })) },
|
||||
async appUnderPoint() { return null },
|
||||
async listInstalled() { return [] },
|
||||
iconDataUrl() { return null },
|
||||
listRunning() { return [] },
|
||||
async open() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
async prepareDisplay() {
|
||||
return { activated: '', hidden: [] }
|
||||
},
|
||||
async previewHideSet() {
|
||||
return []
|
||||
},
|
||||
async findWindowDisplays(ids: string[]) {
|
||||
return ids.map((b: string) => ({
|
||||
bundleId: b,
|
||||
displayIds: [] as number[],
|
||||
}))
|
||||
},
|
||||
async appUnderPoint() {
|
||||
return null
|
||||
},
|
||||
async listInstalled() {
|
||||
return []
|
||||
},
|
||||
iconDataUrl() {
|
||||
return null
|
||||
},
|
||||
listRunning() {
|
||||
return []
|
||||
},
|
||||
async open() {
|
||||
throw new Error('@ant/computer-use-swift: macOS only')
|
||||
},
|
||||
async unhide() {},
|
||||
}
|
||||
|
||||
display = backend?.display ?? {
|
||||
getSize() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
listAll() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
getSize() {
|
||||
throw new Error('@ant/computer-use-swift: macOS only')
|
||||
},
|
||||
listAll() {
|
||||
throw new Error('@ant/computer-use-swift: macOS only')
|
||||
},
|
||||
}
|
||||
|
||||
screenshot = backend?.screenshot ?? {
|
||||
async captureExcluding() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
async captureRegion() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
async captureExcluding() {
|
||||
throw new Error('@ant/computer-use-swift: macOS only')
|
||||
},
|
||||
async captureRegion() {
|
||||
throw new Error('@ant/computer-use-swift: macOS only')
|
||||
},
|
||||
}
|
||||
|
||||
async resolvePrepareCapture(
|
||||
@@ -66,6 +93,12 @@ export class ComputerUseAPI {
|
||||
targetH: number,
|
||||
displayId?: number,
|
||||
): Promise<ResolvePrepareCaptureResult> {
|
||||
return this.screenshot.captureExcluding(allowedBundleIds, quality, targetW, targetH, displayId)
|
||||
return this.screenshot.captureExcluding(
|
||||
allowedBundleIds,
|
||||
quality,
|
||||
targetW,
|
||||
targetH,
|
||||
displayId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,11 @@ export interface DisplayAPI {
|
||||
}
|
||||
|
||||
export interface AppsAPI {
|
||||
prepareDisplay(allowlistBundleIds: string[], surrogateHost: string, displayId?: number): Promise<PrepareDisplayResult>
|
||||
prepareDisplay(
|
||||
allowlistBundleIds: string[],
|
||||
surrogateHost: string,
|
||||
displayId?: number,
|
||||
): Promise<PrepareDisplayResult>
|
||||
previewHideSet(bundleIds: string[], displayId?: number): Promise<AppInfo[]>
|
||||
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
|
||||
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
|
||||
@@ -68,13 +72,22 @@ export interface AppsAPI {
|
||||
|
||||
export interface ScreenshotAPI {
|
||||
captureExcluding(
|
||||
allowedBundleIds: string[], quality: number,
|
||||
targetW: number, targetH: number, displayId?: number,
|
||||
allowedBundleIds: string[],
|
||||
quality: number,
|
||||
targetW: number,
|
||||
targetH: number,
|
||||
displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
captureRegion(
|
||||
allowedBundleIds: string[],
|
||||
x: number, y: number, w: number, h: number,
|
||||
outW: number, outH: number, quality: number, displayId?: number,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
outW: number,
|
||||
outH: number,
|
||||
quality: number,
|
||||
displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
|
||||
}
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import React, {
|
||||
type PropsWithChildren,
|
||||
useContext,
|
||||
useInsertionEffect,
|
||||
} from 'react'
|
||||
import instances from '../core/instances.js'
|
||||
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react';
|
||||
import instances from '../core/instances.js';
|
||||
import {
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
ENABLE_MOUSE_TRACKING,
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
} from '../core/termio/dec.js'
|
||||
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js'
|
||||
import Box from './Box.js'
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js'
|
||||
} from '../core/termio/dec.js';
|
||||
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js';
|
||||
import Box from './Box.js';
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js';
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
|
||||
mouseTracking?: boolean
|
||||
}>
|
||||
mouseTracking?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Run children in the terminal's alternate screen buffer, constrained to
|
||||
@@ -39,12 +35,9 @@ type Props = PropsWithChildren<{
|
||||
* from scrolling content) and so signal-exit cleanup can exit the alt
|
||||
* screen if the component's own unmount doesn't run.
|
||||
*/
|
||||
export function AlternateScreen({
|
||||
children,
|
||||
mouseTracking = true,
|
||||
}: Props): React.ReactNode {
|
||||
const size = useContext(TerminalSizeContext)
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
export function AlternateScreen({ children, mouseTracking = true }: Props): React.ReactNode {
|
||||
const size = useContext(TerminalSizeContext);
|
||||
const writeRaw = useContext(TerminalWriteContext);
|
||||
|
||||
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
|
||||
// resetAfterCommit between the mutation and layout commit phases, and
|
||||
@@ -57,31 +50,22 @@ export function AlternateScreen({
|
||||
// Cleanup timing is unchanged: both insertion and layout effect cleanup
|
||||
// run in the mutation phase on unmount, before resetAfterCommit.
|
||||
useInsertionEffect(() => {
|
||||
const ink = instances.get(process.stdout)
|
||||
if (!writeRaw) return
|
||||
const ink = instances.get(process.stdout);
|
||||
if (!writeRaw) return;
|
||||
|
||||
writeRaw(
|
||||
ENTER_ALT_SCREEN +
|
||||
'\x1b[2J\x1b[H' +
|
||||
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
|
||||
)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
writeRaw(ENTER_ALT_SCREEN + '\x1b[2J\x1b[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''));
|
||||
ink?.setAltScreenActive(true, mouseTracking);
|
||||
|
||||
return () => {
|
||||
ink?.setAltScreenActive(false)
|
||||
ink?.clearTextSelection()
|
||||
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
|
||||
}
|
||||
}, [writeRaw, mouseTracking])
|
||||
ink?.setAltScreenActive(false);
|
||||
ink?.clearTextSelection();
|
||||
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN);
|
||||
};
|
||||
}, [writeRaw, mouseTracking]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={size?.rows ?? 24}
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box flexDirection="column" height={size?.rows ?? 24} width="100%" flexShrink={0}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { PureComponent, type ReactNode } from 'react'
|
||||
import React, { PureComponent, type ReactNode } from 'react';
|
||||
// Business-layer callbacks — replaced with inline defaults so this package
|
||||
// has zero dependencies on business code. The business layer can inject
|
||||
// implementations via AppCallbacks when needed.
|
||||
type AppCallbacks = {
|
||||
updateLastInteractionTime?: () => void
|
||||
stopCapturingEarlyInput?: () => void
|
||||
isMouseClicksDisabled?: () => boolean
|
||||
logError?: (error: unknown) => void
|
||||
logForDebugging?: (message: string, opts?: { level?: string }) => void
|
||||
}
|
||||
updateLastInteractionTime?: () => void;
|
||||
stopCapturingEarlyInput?: () => void;
|
||||
isMouseClicksDisabled?: () => boolean;
|
||||
logError?: (error: unknown) => void;
|
||||
logForDebugging?: (message: string, opts?: { level?: string }) => void;
|
||||
};
|
||||
|
||||
/** Default no-op / safe-default implementations */
|
||||
const defaultCallbacks: Required<AppCallbacks> = {
|
||||
@@ -17,46 +17,34 @@ const defaultCallbacks: Required<AppCallbacks> = {
|
||||
isMouseClicksDisabled: () => false,
|
||||
logError: (error: unknown) => console.error(error),
|
||||
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Override the default no-op callbacks. Call this from the business layer
|
||||
* (e.g. src/ink.tsx) before mounting <App>.
|
||||
*/
|
||||
export function setAppCallbacks(cb: AppCallbacks): void {
|
||||
Object.assign(defaultCallbacks, cb)
|
||||
Object.assign(defaultCallbacks, cb);
|
||||
}
|
||||
|
||||
function isEnvTruthy(value: string | undefined): boolean {
|
||||
return value === '1' || value === 'true'
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
import { EventEmitter } from '../core/events/emitter.js'
|
||||
import { InputEvent } from '../core/events/input-event.js'
|
||||
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js'
|
||||
import { EventEmitter } from '../core/events/emitter.js';
|
||||
import { InputEvent } from '../core/events/input-event.js';
|
||||
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js';
|
||||
import {
|
||||
INITIAL_STATE,
|
||||
type ParsedInput,
|
||||
type ParsedKey,
|
||||
type ParsedMouse,
|
||||
parseMultipleKeypresses,
|
||||
} from '../core/parse-keypress.js'
|
||||
import reconciler from '../core/reconciler.js'
|
||||
import {
|
||||
finishSelection,
|
||||
hasSelection,
|
||||
type SelectionState,
|
||||
startSelection,
|
||||
} from '../core/selection.js'
|
||||
import {
|
||||
isXtermJs,
|
||||
setXtversionName,
|
||||
supportsExtendedKeys,
|
||||
} from '../core/terminal.js'
|
||||
import {
|
||||
getTerminalFocused,
|
||||
setTerminalFocused,
|
||||
} from '../core/terminal-focus-state.js'
|
||||
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js'
|
||||
} from '../core/parse-keypress.js';
|
||||
import reconciler from '../core/reconciler.js';
|
||||
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
|
||||
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
|
||||
import { getTerminalFocused, setTerminalFocused } from '../core/terminal-focus-state.js';
|
||||
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js';
|
||||
import {
|
||||
DISABLE_KITTY_KEYBOARD,
|
||||
DISABLE_MODIFY_OTHER_KEYS,
|
||||
@@ -64,155 +52,150 @@ import {
|
||||
ENABLE_MODIFY_OTHER_KEYS,
|
||||
FOCUS_IN,
|
||||
FOCUS_OUT,
|
||||
} from '../core/termio/csi.js'
|
||||
import {
|
||||
DBP,
|
||||
DFE,
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
EBP,
|
||||
EFE,
|
||||
HIDE_CURSOR,
|
||||
SHOW_CURSOR,
|
||||
} from '../core/termio/dec.js'
|
||||
import AppContext from './AppContext.js'
|
||||
import { ClockProvider } from './ClockContext.js'
|
||||
import CursorDeclarationContext, {
|
||||
type CursorDeclarationSetter,
|
||||
} from './CursorDeclarationContext.js'
|
||||
import ErrorOverview from './ErrorOverview.js'
|
||||
import StdinContext from './StdinContext.js'
|
||||
import { TerminalFocusProvider } from './TerminalFocusContext.js'
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js'
|
||||
} from '../core/termio/csi.js';
|
||||
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../core/termio/dec.js';
|
||||
import AppContext from './AppContext.js';
|
||||
import { ClockProvider } from './ClockContext.js';
|
||||
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
|
||||
import ErrorOverview from './ErrorOverview.js';
|
||||
import StdinContext from './StdinContext.js';
|
||||
import { TerminalFocusProvider } from './TerminalFocusContext.js';
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js';
|
||||
|
||||
// Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT)
|
||||
const SUPPORTS_SUSPEND = process.platform !== 'win32'
|
||||
const SUPPORTS_SUSPEND = process.platform !== 'win32';
|
||||
|
||||
// After this many milliseconds of stdin silence, the next chunk triggers
|
||||
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
|
||||
// ssh reconnect, and laptop wake — the terminal resets DEC private modes
|
||||
// but no signal reaches us. 5s is well above normal inter-keystroke gaps
|
||||
// but short enough that the first scroll after reattach works.
|
||||
const STDIN_RESUME_GAP_MS = 5000
|
||||
const STDIN_RESUME_GAP_MS = 5000;
|
||||
|
||||
type Props = {
|
||||
readonly children: ReactNode
|
||||
readonly stdin: NodeJS.ReadStream
|
||||
readonly stdout: NodeJS.WriteStream
|
||||
readonly stderr: NodeJS.WriteStream
|
||||
readonly exitOnCtrlC: boolean
|
||||
readonly onExit: (error?: Error) => void
|
||||
readonly terminalColumns: number
|
||||
readonly terminalRows: number
|
||||
readonly children: ReactNode;
|
||||
readonly stdin: NodeJS.ReadStream;
|
||||
readonly stdout: NodeJS.WriteStream;
|
||||
readonly stderr: NodeJS.WriteStream;
|
||||
readonly exitOnCtrlC: boolean;
|
||||
readonly onExit: (error?: Error) => void;
|
||||
readonly terminalColumns: number;
|
||||
readonly terminalRows: number;
|
||||
// Text selection state. App mutates this directly from mouse events
|
||||
// and calls onSelectionChange to trigger a repaint. Mouse events only
|
||||
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
|
||||
// so the handler is always wired but dormant until tracking is on.
|
||||
readonly selection: SelectionState
|
||||
readonly onSelectionChange: () => void
|
||||
readonly selection: SelectionState;
|
||||
readonly onSelectionChange: () => void;
|
||||
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
|
||||
// onClick handlers. Returns true if a DOM handler consumed the click.
|
||||
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
|
||||
// gates on altScreenActive).
|
||||
readonly onClickAt: (col: number, row: number) => boolean
|
||||
readonly onClickAt: (col: number, row: number) => boolean;
|
||||
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
|
||||
// DOM elements. Called for mode-1003 motion events with no button held.
|
||||
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
|
||||
readonly onHoverAt: (col: number, row: number) => void
|
||||
readonly onHoverAt: (col: number, row: number) => void;
|
||||
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
|
||||
// time. Returns the URL or undefined. The browser-open is deferred by
|
||||
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
|
||||
readonly getHyperlinkAt: (col: number, row: number) => string | undefined
|
||||
readonly getHyperlinkAt: (col: number, row: number) => string | undefined;
|
||||
// Open a hyperlink URL in the browser. Called after the timer fires.
|
||||
readonly onOpenHyperlink: (url: string) => void
|
||||
readonly onOpenHyperlink: (url: string) => void;
|
||||
// Called on double/triple-click PRESS at (col, row). count=2 selects
|
||||
// the word under the cursor; count=3 selects the line. Ink reads the
|
||||
// screen buffer to find word/line boundaries and mutates selection,
|
||||
// setting isDragging=true so a subsequent drag extends by word/line.
|
||||
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void
|
||||
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void;
|
||||
// Called on drag-motion. Mode-aware: char mode updates focus to the
|
||||
// exact cell; word/line mode snaps to word/line boundaries. Needs
|
||||
// screen-buffer access (word boundaries) so lives on Ink, not here.
|
||||
readonly onSelectionDrag: (col: number, row: number) => void
|
||||
readonly onSelectionDrag: (col: number, row: number) => void;
|
||||
// Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
|
||||
// Ink re-asserts terminal modes: extended key reporting, and (when in
|
||||
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
|
||||
// terminal side. Optional so testing.tsx doesn't need to stub it.
|
||||
readonly onStdinResume?: () => void
|
||||
readonly onStdinResume?: () => void;
|
||||
// Receives the declared native-cursor position from useDeclaredCursor
|
||||
// so ink.tsx can park the terminal cursor there after each frame.
|
||||
// Enables IME composition at the input caret and lets screen readers /
|
||||
// magnifiers track the input. Optional so testing.tsx doesn't stub it.
|
||||
readonly onCursorDeclaration?: CursorDeclarationSetter
|
||||
readonly onCursorDeclaration?: CursorDeclarationSetter;
|
||||
// Dispatch a keyboard event through the DOM tree. Called for each
|
||||
// parsed key alongside the legacy EventEmitter path.
|
||||
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void
|
||||
}
|
||||
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void;
|
||||
};
|
||||
|
||||
// Multi-click detection thresholds. 500ms is the macOS default; a small
|
||||
// position tolerance allows for trackpad jitter between clicks.
|
||||
const MULTI_CLICK_TIMEOUT_MS = 500
|
||||
const MULTI_CLICK_DISTANCE = 1
|
||||
const MULTI_CLICK_TIMEOUT_MS = 500;
|
||||
const MULTI_CLICK_DISTANCE = 1;
|
||||
|
||||
type ErrorInfo = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
readonly error?: Error
|
||||
}
|
||||
readonly error?: ErrorInfo;
|
||||
};
|
||||
|
||||
// Root component for all Ink apps
|
||||
// It renders stdin and stdout contexts, so that children can access them if needed
|
||||
// It also handles Ctrl+C exiting and cursor visibility
|
||||
export default class App extends PureComponent<Props, State> {
|
||||
static displayName = 'InternalApp'
|
||||
static displayName = 'InternalApp';
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
return { error: { message: error.message, stack: error.stack } };
|
||||
}
|
||||
|
||||
override state = {
|
||||
error: undefined,
|
||||
}
|
||||
};
|
||||
|
||||
// Count how many components enabled raw mode to avoid disabling
|
||||
// raw mode until all components don't need it anymore
|
||||
rawModeEnabledCount = 0
|
||||
rawModeEnabledCount = 0;
|
||||
|
||||
internal_eventEmitter = new EventEmitter()
|
||||
keyParseState = INITIAL_STATE
|
||||
internal_eventEmitter = new EventEmitter();
|
||||
keyParseState = INITIAL_STATE;
|
||||
// Timer for flushing incomplete escape sequences
|
||||
incompleteEscapeTimer: NodeJS.Timeout | null = null
|
||||
incompleteEscapeTimer: NodeJS.Timeout | null = null;
|
||||
// Timeout durations for incomplete sequences (ms)
|
||||
readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
|
||||
readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
|
||||
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
|
||||
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
|
||||
|
||||
// Terminal query/response dispatch. Responses arrive on stdin (parsed
|
||||
// out by parse-keypress) and are routed to pending promise resolvers.
|
||||
querier = new TerminalQuerier(this.props.stdout)
|
||||
querier = new TerminalQuerier(this.props.stdout);
|
||||
|
||||
// Multi-click tracking for double/triple-click text selection. A click
|
||||
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
|
||||
// click increments clickCount; otherwise it resets to 1.
|
||||
lastClickTime = 0
|
||||
lastClickCol = -1
|
||||
lastClickRow = -1
|
||||
clickCount = 0
|
||||
lastClickTime = 0;
|
||||
lastClickCol = -1;
|
||||
lastClickRow = -1;
|
||||
clickCount = 0;
|
||||
// Deferred hyperlink-open timer — cancelled if a second click arrives
|
||||
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
|
||||
// the word without also opening the browser). DOM onClick dispatch is
|
||||
// NOT deferred — it returns true from onClickAt and skips this timer.
|
||||
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null
|
||||
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Last mode-1003 motion position. Terminals already dedupe to cell
|
||||
// granularity but this also lets us skip dispatchHover entirely on
|
||||
// repeat events (drag-then-release at same cell, etc.).
|
||||
lastHoverCol = -1
|
||||
lastHoverRow = -1
|
||||
lastHoverCol = -1;
|
||||
lastHoverRow = -1;
|
||||
|
||||
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
|
||||
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
|
||||
// Initialized to now so startup doesn't false-trigger.
|
||||
lastStdinTime = Date.now()
|
||||
lastStdinTime = Date.now();
|
||||
|
||||
// Determines if TTY is supported on the provided stdin
|
||||
isRawModeSupported(): boolean {
|
||||
return this.props.stdin.isTTY
|
||||
return this.props.stdin.isTTY;
|
||||
}
|
||||
|
||||
override render() {
|
||||
@@ -242,56 +225,47 @@ export default class App extends PureComponent<Props, State> {
|
||||
>
|
||||
<TerminalFocusProvider>
|
||||
<ClockProvider>
|
||||
<CursorDeclarationContext.Provider
|
||||
value={this.props.onCursorDeclaration ?? (() => {})}
|
||||
>
|
||||
{this.state.error ? (
|
||||
<ErrorOverview error={this.state.error as Error} />
|
||||
) : (
|
||||
this.props.children
|
||||
)}
|
||||
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
||||
{this.state.error ? <ErrorOverview error={this.state.error} /> : this.props.children}
|
||||
</CursorDeclarationContext.Provider>
|
||||
</ClockProvider>
|
||||
</TerminalFocusProvider>
|
||||
</StdinContext.Provider>
|
||||
</AppContext.Provider>
|
||||
</TerminalSizeContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override componentDidMount() {
|
||||
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
|
||||
if (
|
||||
this.props.stdout.isTTY &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
|
||||
) {
|
||||
this.props.stdout.write(HIDE_CURSOR)
|
||||
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
|
||||
this.props.stdout.write(HIDE_CURSOR);
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount() {
|
||||
if (this.props.stdout.isTTY) {
|
||||
this.props.stdout.write(SHOW_CURSOR)
|
||||
this.props.stdout.write(SHOW_CURSOR);
|
||||
}
|
||||
|
||||
// Clear any pending timers
|
||||
if (this.incompleteEscapeTimer) {
|
||||
clearTimeout(this.incompleteEscapeTimer)
|
||||
this.incompleteEscapeTimer = null
|
||||
clearTimeout(this.incompleteEscapeTimer);
|
||||
this.incompleteEscapeTimer = null;
|
||||
}
|
||||
if (this.pendingHyperlinkTimer) {
|
||||
clearTimeout(this.pendingHyperlinkTimer)
|
||||
this.pendingHyperlinkTimer = null
|
||||
clearTimeout(this.pendingHyperlinkTimer);
|
||||
this.pendingHyperlinkTimer = null;
|
||||
}
|
||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(false)
|
||||
this.handleSetRawMode(false);
|
||||
} else {
|
||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
||||
// Windows Node.js), ensure stdin is unref'd so the process can
|
||||
// exit. earlyInput may have called ref() before Ink mounted.
|
||||
try {
|
||||
this.props.stdin.unref()
|
||||
this.props.stdin.unref();
|
||||
} catch {
|
||||
// stdin may already be destroyed
|
||||
}
|
||||
@@ -299,25 +273,25 @@ export default class App extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error) {
|
||||
this.handleExit(error)
|
||||
this.handleExit(error);
|
||||
}
|
||||
|
||||
handleSetRawMode = (isEnabled: boolean): void => {
|
||||
const { stdin } = this.props
|
||||
const { stdin } = this.props;
|
||||
|
||||
if (!this.isRawModeSupported()) {
|
||||
if (stdin === process.stdin) {
|
||||
throw new Error(
|
||||
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
|
||||
)
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stdin.setEncoding('utf8')
|
||||
stdin.setEncoding('utf8');
|
||||
|
||||
if (isEnabled) {
|
||||
// Ensure raw mode is enabled only once
|
||||
@@ -326,34 +300,34 @@ export default class App extends PureComponent<Props, State> {
|
||||
// Both use the same stdin 'readable' + read() pattern, so they can't
|
||||
// coexist -- the early capture handler would drain stdin before ours
|
||||
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
|
||||
defaultCallbacks.stopCapturingEarlyInput()
|
||||
defaultCallbacks.stopCapturingEarlyInput();
|
||||
|
||||
// Safety net: remove any pre-existing readable listeners that aren't
|
||||
// ours. In builds where setAppCallbacks() was never called, the early
|
||||
// input capture's readableHandler remains attached and would consume
|
||||
// all stdin data before our handleReadable sees it.
|
||||
const existingListeners = stdin.listeners('readable')
|
||||
const existingListeners = stdin.listeners('readable');
|
||||
for (const listener of existingListeners) {
|
||||
if (listener !== this.handleReadable) {
|
||||
stdin.removeListener('readable', listener as any)
|
||||
stdin.removeListener('readable', listener as any);
|
||||
}
|
||||
}
|
||||
|
||||
stdin.ref()
|
||||
stdin.setRawMode(true)
|
||||
stdin.addListener('readable', this.handleReadable)
|
||||
stdin.ref();
|
||||
stdin.setRawMode(true);
|
||||
stdin.addListener('readable', this.handleReadable);
|
||||
// Enable bracketed paste mode
|
||||
this.props.stdout.write(EBP)
|
||||
this.props.stdout.write(EBP);
|
||||
// Enable terminal focus reporting (DECSET 1004)
|
||||
this.props.stdout.write(EFE)
|
||||
this.props.stdout.write(EFE);
|
||||
// Enable extended key reporting so ctrl+shift+<letter> is
|
||||
// distinguishable from ctrl+<letter>. We write both the kitty stack
|
||||
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
|
||||
// terminals honor whichever they implement (tmux only accepts the
|
||||
// latter).
|
||||
if (supportsExtendedKeys()) {
|
||||
this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
|
||||
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
|
||||
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
|
||||
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
|
||||
}
|
||||
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
|
||||
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
|
||||
@@ -364,22 +338,19 @@ export default class App extends PureComponent<Props, State> {
|
||||
// init sequence completes — avoids interleaving with alt-screen/mouse
|
||||
// tracking enable writes that may happen in the same render cycle.
|
||||
setImmediate(() => {
|
||||
void Promise.all([
|
||||
this.querier.send(xtversion()),
|
||||
this.querier.flush(),
|
||||
]).then(([r]) => {
|
||||
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
|
||||
if (r) {
|
||||
setXtversionName(r.name)
|
||||
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
|
||||
setXtversionName(r.name);
|
||||
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
|
||||
} else {
|
||||
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
|
||||
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.rawModeEnabledCount++
|
||||
return
|
||||
this.rawModeEnabledCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable raw mode only when no components left that are using it
|
||||
@@ -389,31 +360,31 @@ export default class App extends PureComponent<Props, State> {
|
||||
// If the old tree had more useInput hooks than the new tree, the old
|
||||
// cleanup over-decrements the count to 0 even though the new tree has
|
||||
// active listeners. Detect this and fix the count instead of disabling.
|
||||
const activeListeners = this.internal_eventEmitter.listenerCount('input')
|
||||
const activeListeners = this.internal_eventEmitter.listenerCount('input');
|
||||
if (activeListeners > 0) {
|
||||
this.rawModeEnabledCount = activeListeners
|
||||
return
|
||||
this.rawModeEnabledCount = activeListeners;
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
|
||||
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
|
||||
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
|
||||
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
|
||||
// Disable terminal focus reporting (DECSET 1004)
|
||||
this.props.stdout.write(DFE)
|
||||
this.props.stdout.write(DFE);
|
||||
// Disable bracketed paste mode
|
||||
this.props.stdout.write(DBP)
|
||||
stdin.setRawMode(false)
|
||||
stdin.removeListener('readable', this.handleReadable)
|
||||
stdin.unref()
|
||||
this.props.stdout.write(DBP);
|
||||
stdin.setRawMode(false);
|
||||
stdin.removeListener('readable', this.handleReadable);
|
||||
stdin.unref();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to flush incomplete escape sequences
|
||||
flushIncomplete = (): void => {
|
||||
// Clear the timer reference
|
||||
this.incompleteEscapeTimer = null
|
||||
this.incompleteEscapeTimer = null;
|
||||
|
||||
// Only proceed if we have incomplete sequences
|
||||
if (!this.keyParseState.incomplete) return
|
||||
if (!this.keyParseState.incomplete) return;
|
||||
|
||||
// Fullscreen: if stdin has data waiting, it's almost certainly the
|
||||
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
|
||||
@@ -424,23 +395,20 @@ export default class App extends PureComponent<Props, State> {
|
||||
// drain stdin next and clear this timer. Prevents both the spurious
|
||||
// Escape key and the lost scroll event.
|
||||
if (this.props.stdin.readableLength > 0) {
|
||||
this.incompleteEscapeTimer = setTimeout(
|
||||
this.flushIncomplete,
|
||||
this.NORMAL_TIMEOUT,
|
||||
)
|
||||
return
|
||||
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process incomplete as a flush operation (input=null)
|
||||
// This reuses all existing parsing logic
|
||||
this.processInput(null)
|
||||
}
|
||||
this.processInput(null);
|
||||
};
|
||||
|
||||
// Process input through the parser and handle the results
|
||||
processInput = (input: string | Buffer | null): void => {
|
||||
// Parse input using our state machine
|
||||
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
|
||||
this.keyParseState = newState
|
||||
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
|
||||
this.keyParseState = newState;
|
||||
|
||||
// Process ALL keys in a SINGLE discreteUpdates call to prevent
|
||||
// "Maximum update depth exceeded" error when many keys arrive at once
|
||||
@@ -448,106 +416,94 @@ export default class App extends PureComponent<Props, State> {
|
||||
// This batches all state updates from handleInput and all useInput
|
||||
// listeners together within one high-priority update context.
|
||||
if (keys.length > 0) {
|
||||
reconciler.discreteUpdates(
|
||||
processKeysInBatch,
|
||||
this,
|
||||
keys,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
|
||||
}
|
||||
|
||||
// If we have incomplete escape sequences, set a timer to flush them
|
||||
if (this.keyParseState.incomplete) {
|
||||
// Cancel any existing timer first
|
||||
if (this.incompleteEscapeTimer) {
|
||||
clearTimeout(this.incompleteEscapeTimer)
|
||||
clearTimeout(this.incompleteEscapeTimer);
|
||||
}
|
||||
this.incompleteEscapeTimer = setTimeout(
|
||||
this.flushIncomplete,
|
||||
this.keyParseState.mode === 'IN_PASTE'
|
||||
? this.PASTE_TIMEOUT
|
||||
: this.NORMAL_TIMEOUT,
|
||||
)
|
||||
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleReadable = (): void => {
|
||||
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
|
||||
// The terminal may have reset DEC private modes; re-assert mouse
|
||||
// tracking. Checked before the read loop so one Date.now() covers
|
||||
// all chunks in this readable event.
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
|
||||
this.props.onStdinResume?.()
|
||||
this.props.onStdinResume?.();
|
||||
}
|
||||
this.lastStdinTime = now
|
||||
this.lastStdinTime = now;
|
||||
try {
|
||||
let chunk
|
||||
let chunk;
|
||||
while ((chunk = this.props.stdin.read() as string | null) !== null) {
|
||||
// Process the input chunk
|
||||
this.processInput(chunk)
|
||||
this.processInput(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
// In Bun, an uncaught throw inside a stream 'readable' handler can
|
||||
// permanently wedge the stream: data stays buffered and 'readable'
|
||||
// never re-emits. Catching here ensures the stream stays healthy so
|
||||
// subsequent keystrokes are still delivered.
|
||||
defaultCallbacks.logError(error)
|
||||
defaultCallbacks.logError(error);
|
||||
|
||||
// Re-attach the listener in case the exception detached it.
|
||||
// Bun may remove the listener after an error; without this,
|
||||
// the session freezes permanently (stdin reader dead, event loop alive).
|
||||
const { stdin } = this.props
|
||||
if (
|
||||
this.rawModeEnabledCount > 0 &&
|
||||
!stdin.listeners('readable').includes(this.handleReadable)
|
||||
) {
|
||||
defaultCallbacks.logForDebugging(
|
||||
'handleReadable: re-attaching stdin readable listener after error recovery',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
stdin.addListener('readable', this.handleReadable)
|
||||
const { stdin } = this.props;
|
||||
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
|
||||
defaultCallbacks.logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
|
||||
level: 'warn',
|
||||
});
|
||||
stdin.addListener('readable', this.handleReadable);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleInput = (input: string | undefined): void => {
|
||||
// Exit on Ctrl+C
|
||||
if (input === '\x03' && this.props.exitOnCtrlC) {
|
||||
this.handleExit()
|
||||
this.handleExit();
|
||||
}
|
||||
|
||||
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
|
||||
// parsed key to support both raw (\x1a) and CSI u format from Kitty
|
||||
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
|
||||
}
|
||||
};
|
||||
|
||||
handleExit = (error?: Error): void => {
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(false)
|
||||
this.handleSetRawMode(false);
|
||||
}
|
||||
|
||||
this.props.onExit(error)
|
||||
}
|
||||
this.props.onExit(error);
|
||||
};
|
||||
|
||||
handleTerminalFocus = (isFocused: boolean): void => {
|
||||
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
|
||||
// and Clock (interval speed) — no App setState needed.
|
||||
setTerminalFocused(isFocused)
|
||||
}
|
||||
setTerminalFocused(isFocused);
|
||||
};
|
||||
|
||||
handleSuspend = (): void => {
|
||||
if (!this.isRawModeSupported()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the exact raw mode count to restore it properly
|
||||
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
|
||||
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
|
||||
|
||||
// Completely disable raw mode before suspending
|
||||
while (this.rawModeEnabledCount > 0) {
|
||||
this.handleSetRawMode(false)
|
||||
this.handleSetRawMode(false);
|
||||
}
|
||||
|
||||
// Show cursor, disable focus reporting, and disable mouse tracking
|
||||
@@ -556,49 +512,44 @@ export default class App extends PureComponent<Props, State> {
|
||||
// it, SGR mouse sequences would appear as garbled text at the
|
||||
// shell prompt while suspended.
|
||||
if (this.props.stdout.isTTY) {
|
||||
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING)
|
||||
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING);
|
||||
}
|
||||
|
||||
// Emit suspend event for Claude Code to handle. Mostly just has a notification
|
||||
this.internal_eventEmitter.emit('suspend')
|
||||
this.internal_eventEmitter.emit('suspend');
|
||||
|
||||
// Set up resume handler
|
||||
const resumeHandler = () => {
|
||||
// Restore raw mode to exact previous state
|
||||
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(true)
|
||||
this.handleSetRawMode(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
|
||||
if (this.props.stdout.isTTY) {
|
||||
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
|
||||
this.props.stdout.write(HIDE_CURSOR)
|
||||
this.props.stdout.write(HIDE_CURSOR);
|
||||
}
|
||||
// Re-enable focus reporting to restore terminal state
|
||||
this.props.stdout.write(EFE)
|
||||
this.props.stdout.write(EFE);
|
||||
}
|
||||
|
||||
// Emit resume event for Claude Code to handle
|
||||
this.internal_eventEmitter.emit('resume')
|
||||
this.internal_eventEmitter.emit('resume');
|
||||
|
||||
process.removeListener('SIGCONT', resumeHandler)
|
||||
}
|
||||
process.removeListener('SIGCONT', resumeHandler);
|
||||
};
|
||||
|
||||
process.on('SIGCONT', resumeHandler)
|
||||
process.kill(process.pid, 'SIGSTOP')
|
||||
}
|
||||
process.on('SIGCONT', resumeHandler);
|
||||
process.kill(process.pid, 'SIGSTOP');
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to process all keys within a single discrete update context.
|
||||
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
|
||||
function processKeysInBatch(
|
||||
app: App,
|
||||
items: ParsedInput[],
|
||||
_unused1: undefined,
|
||||
_unused2: undefined,
|
||||
): void {
|
||||
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
|
||||
// Update interaction time for notification timeout tracking.
|
||||
// This is called from the central input handler to avoid having multiple
|
||||
// stdin listeners that can cause race conditions and dropped input.
|
||||
@@ -606,75 +557,70 @@ function processKeysInBatch(
|
||||
// Mode-1003 no-button motion is also excluded — passive cursor drift is
|
||||
// not engagement (would suppress idle notifications + defer housekeeping).
|
||||
if (
|
||||
items.some(
|
||||
i =>
|
||||
i.kind === 'key' ||
|
||||
(i.kind === 'mouse' &&
|
||||
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
|
||||
)
|
||||
items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)))
|
||||
) {
|
||||
defaultCallbacks.updateLastInteractionTime()
|
||||
defaultCallbacks.updateLastInteractionTime();
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
|
||||
// input — route them to the querier to resolve pending promises.
|
||||
if (item.kind === 'response') {
|
||||
app.querier.onResponse(item.response)
|
||||
continue
|
||||
app.querier.onResponse(item.response);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mouse click/drag events update selection state (fullscreen only).
|
||||
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
|
||||
// screen buffer. Button bit 0x20 = drag (motion while button held).
|
||||
if (item.kind === 'mouse') {
|
||||
handleMouseEvent(app, item)
|
||||
continue
|
||||
handleMouseEvent(app, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sequence = item.sequence
|
||||
const sequence = item.sequence;
|
||||
|
||||
// Handle terminal focus events (DECSET 1004)
|
||||
if (sequence === FOCUS_IN) {
|
||||
app.handleTerminalFocus(true)
|
||||
const event = new TerminalFocusEvent('terminalfocus')
|
||||
app.internal_eventEmitter.emit('terminalfocus', event)
|
||||
continue
|
||||
app.handleTerminalFocus(true);
|
||||
const event = new TerminalFocusEvent('terminalfocus');
|
||||
app.internal_eventEmitter.emit('terminalfocus', event);
|
||||
continue;
|
||||
}
|
||||
if (sequence === FOCUS_OUT) {
|
||||
app.handleTerminalFocus(false)
|
||||
app.handleTerminalFocus(false);
|
||||
// Defensive: if we lost the release event (mouse released outside
|
||||
// terminal window — some emulators drop it rather than capturing the
|
||||
// pointer), focus-out is the next observable signal that the drag is
|
||||
// over. Without this, drag-to-scroll's timer runs until the scroll
|
||||
// boundary is hit.
|
||||
if (app.props.selection.isDragging) {
|
||||
finishSelection(app.props.selection)
|
||||
app.props.onSelectionChange()
|
||||
finishSelection(app.props.selection);
|
||||
app.props.onSelectionChange();
|
||||
}
|
||||
const event = new TerminalFocusEvent('terminalblur')
|
||||
app.internal_eventEmitter.emit('terminalblur', event)
|
||||
continue
|
||||
const event = new TerminalFocusEvent('terminalblur');
|
||||
app.internal_eventEmitter.emit('terminalblur', event);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Failsafe: if we receive input, the terminal must be focused
|
||||
if (!getTerminalFocused()) {
|
||||
setTerminalFocused(true)
|
||||
setTerminalFocused(true);
|
||||
}
|
||||
|
||||
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
|
||||
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
|
||||
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
|
||||
app.handleSuspend()
|
||||
continue
|
||||
app.handleSuspend();
|
||||
continue;
|
||||
}
|
||||
|
||||
app.handleInput(sequence)
|
||||
const event = new InputEvent(item)
|
||||
app.internal_eventEmitter.emit('input', event)
|
||||
app.handleInput(sequence);
|
||||
const event = new InputEvent(item);
|
||||
app.internal_eventEmitter.emit('input', event);
|
||||
|
||||
// Also dispatch through the DOM tree so onKeyDown handlers fire.
|
||||
app.props.dispatchKeyboardEvent(item)
|
||||
app.props.dispatchKeyboardEvent(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,13 +628,13 @@ function processKeysInBatch(
|
||||
export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// Allow disabling click handling while keeping wheel scroll (which goes
|
||||
// through the keybinding system as 'wheelup'/'wheeldown', not here).
|
||||
if (defaultCallbacks.isMouseClicksDisabled()) return
|
||||
if (defaultCallbacks.isMouseClicksDisabled()) return;
|
||||
|
||||
const sel = app.props.selection
|
||||
const sel = app.props.selection;
|
||||
// Terminal coords are 1-indexed; screen buffer is 0-indexed
|
||||
const col = m.col - 1
|
||||
const row = m.row - 1
|
||||
const baseButton = m.button & 0x03
|
||||
const col = m.col - 1;
|
||||
const row = m.row - 1;
|
||||
const baseButton = m.button & 0x03;
|
||||
|
||||
if (m.action === 'press') {
|
||||
if ((m.button & 0x20) !== 0 && baseButton === 3) {
|
||||
@@ -702,25 +648,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// past the edge, came back" — and tmux drops focus events unless
|
||||
// `focus-events on` is set, so this is the more reliable signal.
|
||||
if (sel.isDragging) {
|
||||
finishSelection(sel)
|
||||
app.props.onSelectionChange()
|
||||
finishSelection(sel);
|
||||
app.props.onSelectionChange();
|
||||
}
|
||||
if (col === app.lastHoverCol && row === app.lastHoverRow) return
|
||||
app.lastHoverCol = col
|
||||
app.lastHoverRow = row
|
||||
app.props.onHoverAt(col, row)
|
||||
return
|
||||
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
|
||||
app.lastHoverCol = col;
|
||||
app.lastHoverRow = row;
|
||||
app.props.onHoverAt(col, row);
|
||||
return;
|
||||
}
|
||||
if (baseButton !== 0) {
|
||||
// Non-left press breaks the multi-click chain.
|
||||
app.clickCount = 0
|
||||
return
|
||||
app.clickCount = 0;
|
||||
return;
|
||||
}
|
||||
if ((m.button & 0x20) !== 0) {
|
||||
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
|
||||
// calls notifySelectionChange internally — no extra onSelectionChange.
|
||||
app.props.onSelectionDrag(col, row)
|
||||
return
|
||||
app.props.onSelectionDrag(col, row);
|
||||
return;
|
||||
}
|
||||
// Lost-release fallback for mode-1002-only terminals: a fresh press
|
||||
// while isDragging=true means the previous release was dropped (cursor
|
||||
@@ -728,43 +674,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
|
||||
// hit the no-button-motion recovery above instead, so this is rare.
|
||||
if (sel.isDragging) {
|
||||
finishSelection(sel)
|
||||
app.props.onSelectionChange()
|
||||
finishSelection(sel);
|
||||
app.props.onSelectionChange();
|
||||
}
|
||||
// Fresh left press. Detect multi-click HERE (not on release) so the
|
||||
// word/line highlight appears immediately and a subsequent drag can
|
||||
// extend by word/line like native macOS. Previously detected on
|
||||
// release, which meant (a) visible latency before the word highlights
|
||||
// and (b) double-click+drag fell through to char-mode selection.
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
const nearLast =
|
||||
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
|
||||
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
|
||||
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
|
||||
app.clickCount = nearLast ? app.clickCount + 1 : 1
|
||||
app.lastClickTime = now
|
||||
app.lastClickCol = col
|
||||
app.lastClickRow = row
|
||||
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
|
||||
app.clickCount = nearLast ? app.clickCount + 1 : 1;
|
||||
app.lastClickTime = now;
|
||||
app.lastClickCol = col;
|
||||
app.lastClickRow = row;
|
||||
if (app.clickCount >= 2) {
|
||||
// Cancel any pending hyperlink-open from the first click — this is
|
||||
// a double-click, not a single-click on a link.
|
||||
if (app.pendingHyperlinkTimer) {
|
||||
clearTimeout(app.pendingHyperlinkTimer)
|
||||
app.pendingHyperlinkTimer = null
|
||||
clearTimeout(app.pendingHyperlinkTimer);
|
||||
app.pendingHyperlinkTimer = null;
|
||||
}
|
||||
// Cap at 3 (line select) for quadruple+ clicks.
|
||||
const count = app.clickCount === 2 ? 2 : 3
|
||||
app.props.onMultiClick(col, row, count)
|
||||
return
|
||||
const count = app.clickCount === 2 ? 2 : 3;
|
||||
app.props.onMultiClick(col, row, count);
|
||||
return;
|
||||
}
|
||||
startSelection(sel, col, row)
|
||||
startSelection(sel, col, row);
|
||||
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
|
||||
// comment at the hyperlink-open guard below). On macOS xterm.js,
|
||||
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
|
||||
// xterm.js would have consumed the event for native selection).
|
||||
sel.lastPressHadAlt = (m.button & 0x08) !== 0
|
||||
app.props.onSelectionChange()
|
||||
return
|
||||
sel.lastPressHadAlt = (m.button & 0x08) !== 0;
|
||||
app.props.onSelectionChange();
|
||||
return;
|
||||
}
|
||||
|
||||
// Release: end the drag even for non-zero button codes. Some terminals
|
||||
@@ -774,12 +720,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// scroll boundary. Only act on non-left releases when we ARE dragging
|
||||
// (so an unrelated middle/right click-release doesn't touch selection).
|
||||
if (baseButton !== 0) {
|
||||
if (!sel.isDragging) return
|
||||
finishSelection(sel)
|
||||
app.props.onSelectionChange()
|
||||
return
|
||||
if (!sel.isDragging) return;
|
||||
finishSelection(sel);
|
||||
app.props.onSelectionChange();
|
||||
return;
|
||||
}
|
||||
finishSelection(sel)
|
||||
finishSelection(sel);
|
||||
// NOTE: unlike the old release-based detection we do NOT reset clickCount
|
||||
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
|
||||
// an intervening drag doesn't break the click chain. Practical upside:
|
||||
@@ -800,7 +746,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// Resolve the hyperlink URL synchronously while the screen buffer
|
||||
// still reflects what the user clicked — deferring only the
|
||||
// browser-open so double-click can cancel it.
|
||||
const url = app.props.getHyperlinkAt(col, row)
|
||||
const url = app.props.getHyperlinkAt(col, row);
|
||||
// xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
|
||||
// handler that fires on Cmd+click *without consuming the mouse event*
|
||||
// (Linkifier._handleMouseUp calls link.activate() but never
|
||||
@@ -816,19 +762,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
// Clear any prior pending timer — clicking a second link
|
||||
// supersedes the first (only the latest click opens).
|
||||
if (app.pendingHyperlinkTimer) {
|
||||
clearTimeout(app.pendingHyperlinkTimer)
|
||||
clearTimeout(app.pendingHyperlinkTimer);
|
||||
}
|
||||
app.pendingHyperlinkTimer = setTimeout(
|
||||
(app, url) => {
|
||||
app.pendingHyperlinkTimer = null
|
||||
app.props.onOpenHyperlink(url)
|
||||
app.pendingHyperlinkTimer = null;
|
||||
app.props.onOpenHyperlink(url);
|
||||
},
|
||||
MULTI_CLICK_TIMEOUT_MS,
|
||||
app,
|
||||
url,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
app.props.onSelectionChange()
|
||||
app.props.onSelectionChange();
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import React, { type PropsWithChildren, type Ref } from 'react'
|
||||
import type { Except } from 'type-fest'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import type { ClickEvent } from '../core/events/click-event.js'
|
||||
import type { FocusEvent } from '../core/events/focus-event.js'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import type { Styles } from '../core/styles.js'
|
||||
import * as warn from '../core/warn.js'
|
||||
import React, { type PropsWithChildren, type Ref } from 'react';
|
||||
import type { Except } from 'type-fest';
|
||||
import type { DOMElement } from '../core/dom.js';
|
||||
import type { ClickEvent } from '../core/events/click-event.js';
|
||||
import type { FocusEvent } from '../core/events/focus-event.js';
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
|
||||
import type { Styles } from '../core/styles.js';
|
||||
import * as warn from '../core/warn.js';
|
||||
|
||||
export type Props = Except<Styles, 'textWrap'> & {
|
||||
ref?: Ref<DOMElement>
|
||||
ref?: Ref<DOMElement>;
|
||||
/**
|
||||
* Tab order index. Nodes with `tabIndex >= 0` participate in
|
||||
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
|
||||
*/
|
||||
tabIndex?: number
|
||||
tabIndex?: number;
|
||||
/**
|
||||
* Focus this element when it mounts. Like the HTML `autofocus`
|
||||
* attribute — the FocusManager calls `focus(node)` during the
|
||||
* reconciler's `commitMount` phase.
|
||||
*/
|
||||
autoFocus?: boolean
|
||||
autoFocus?: boolean;
|
||||
/**
|
||||
* Fired on left-button click (press + release without drag). Only works
|
||||
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
|
||||
* otherwise. The event bubbles from the deepest hit Box up through
|
||||
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
|
||||
*/
|
||||
onClick?: (event: ClickEvent) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onFocusCapture?: (event: FocusEvent) => void
|
||||
onBlur?: (event: FocusEvent) => void
|
||||
onBlurCapture?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onKeyDownCapture?: (event: KeyboardEvent) => void
|
||||
onClick?: (event: ClickEvent) => void;
|
||||
onFocus?: (event: FocusEvent) => void;
|
||||
onFocusCapture?: (event: FocusEvent) => void;
|
||||
onBlur?: (event: FocusEvent) => void;
|
||||
onBlurCapture?: (event: FocusEvent) => void;
|
||||
onKeyDown?: (event: KeyboardEvent) => void;
|
||||
onKeyDownCapture?: (event: KeyboardEvent) => void;
|
||||
/**
|
||||
* Fired when the mouse moves into this Box's rendered rect. Like DOM
|
||||
* `mouseenter`, does NOT bubble — moving between children does not
|
||||
* re-fire on the parent. Only works inside `<AlternateScreen>` where
|
||||
* mode-1003 mouse tracking is enabled.
|
||||
*/
|
||||
onMouseEnter?: () => void
|
||||
onMouseEnter?: () => void;
|
||||
/** Fired when the mouse moves out of this Box's rendered rect. */
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
onMouseLeave?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
|
||||
@@ -68,23 +68,23 @@ function Box({
|
||||
...style
|
||||
}: PropsWithChildren<Props>): React.ReactNode {
|
||||
// Warn if spacing values are not integers to prevent fractional layout dimensions
|
||||
warn.ifNotInteger(style.margin, 'margin')
|
||||
warn.ifNotInteger(style.marginX, 'marginX')
|
||||
warn.ifNotInteger(style.marginY, 'marginY')
|
||||
warn.ifNotInteger(style.marginTop, 'marginTop')
|
||||
warn.ifNotInteger(style.marginBottom, 'marginBottom')
|
||||
warn.ifNotInteger(style.marginLeft, 'marginLeft')
|
||||
warn.ifNotInteger(style.marginRight, 'marginRight')
|
||||
warn.ifNotInteger(style.padding, 'padding')
|
||||
warn.ifNotInteger(style.paddingX, 'paddingX')
|
||||
warn.ifNotInteger(style.paddingY, 'paddingY')
|
||||
warn.ifNotInteger(style.paddingTop, 'paddingTop')
|
||||
warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
|
||||
warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
|
||||
warn.ifNotInteger(style.paddingRight, 'paddingRight')
|
||||
warn.ifNotInteger(style.gap, 'gap')
|
||||
warn.ifNotInteger(style.columnGap, 'columnGap')
|
||||
warn.ifNotInteger(style.rowGap, 'rowGap')
|
||||
warn.ifNotInteger(style.margin, 'margin');
|
||||
warn.ifNotInteger(style.marginX, 'marginX');
|
||||
warn.ifNotInteger(style.marginY, 'marginY');
|
||||
warn.ifNotInteger(style.marginTop, 'marginTop');
|
||||
warn.ifNotInteger(style.marginBottom, 'marginBottom');
|
||||
warn.ifNotInteger(style.marginLeft, 'marginLeft');
|
||||
warn.ifNotInteger(style.marginRight, 'marginRight');
|
||||
warn.ifNotInteger(style.padding, 'padding');
|
||||
warn.ifNotInteger(style.paddingX, 'paddingX');
|
||||
warn.ifNotInteger(style.paddingY, 'paddingY');
|
||||
warn.ifNotInteger(style.paddingTop, 'paddingTop');
|
||||
warn.ifNotInteger(style.paddingBottom, 'paddingBottom');
|
||||
warn.ifNotInteger(style.paddingLeft, 'paddingLeft');
|
||||
warn.ifNotInteger(style.paddingRight, 'paddingRight');
|
||||
warn.ifNotInteger(style.gap, 'gap');
|
||||
warn.ifNotInteger(style.columnGap, 'columnGap');
|
||||
warn.ifNotInteger(style.rowGap, 'rowGap');
|
||||
|
||||
return (
|
||||
<ink-box
|
||||
@@ -112,7 +112,7 @@ function Box({
|
||||
>
|
||||
{children}
|
||||
</ink-box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default Box
|
||||
export default Box;
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import React, {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Except } from 'type-fest'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import type { ClickEvent } from '../core/events/click-event.js'
|
||||
import type { FocusEvent } from '../core/events/focus-event.js'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import type { Styles } from '../core/styles.js'
|
||||
import Box from './Box.js'
|
||||
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Except } from 'type-fest';
|
||||
import type { DOMElement } from '../core/dom.js';
|
||||
import type { ClickEvent } from '../core/events/click-event.js';
|
||||
import type { FocusEvent } from '../core/events/focus-event.js';
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
|
||||
import type { Styles } from '../core/styles.js';
|
||||
import Box from './Box.js';
|
||||
|
||||
type ButtonState = {
|
||||
focused: boolean
|
||||
hovered: boolean
|
||||
active: boolean
|
||||
}
|
||||
focused: boolean;
|
||||
hovered: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type Props = Except<Styles, 'textWrap'> & {
|
||||
ref?: Ref<DOMElement>
|
||||
ref?: Ref<DOMElement>;
|
||||
/**
|
||||
* Called when the button is activated via Enter, Space, or click.
|
||||
*/
|
||||
onAction: () => void
|
||||
onAction: () => void;
|
||||
/**
|
||||
* Tab order index. Defaults to 0 (in tab order).
|
||||
* Set to -1 for programmatically focusable only.
|
||||
*/
|
||||
tabIndex?: number
|
||||
tabIndex?: number;
|
||||
/**
|
||||
* Focus this button when it mounts.
|
||||
*/
|
||||
autoFocus?: boolean
|
||||
autoFocus?: boolean;
|
||||
/**
|
||||
* Render prop receiving the interactive state. Use this to
|
||||
* style children based on focus/hover/active — Button itself
|
||||
@@ -41,64 +35,53 @@ export type Props = Except<Styles, 'textWrap'> & {
|
||||
*
|
||||
* If not provided, children render as-is (no state-dependent styling).
|
||||
*/
|
||||
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
|
||||
}
|
||||
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
|
||||
};
|
||||
|
||||
function Button({
|
||||
onAction,
|
||||
tabIndex = 0,
|
||||
autoFocus,
|
||||
children,
|
||||
ref,
|
||||
...style
|
||||
}: Props): React.ReactNode {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
function Button({ onAction, tabIndex = 0, autoFocus, children, ref, ...style }: Props): React.ReactNode {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeTimer.current) clearTimeout(activeTimer.current)
|
||||
}
|
||||
}, [])
|
||||
if (activeTimer.current) clearTimeout(activeTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'return' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsActive(true)
|
||||
onAction()
|
||||
if (activeTimer.current) clearTimeout(activeTimer.current)
|
||||
activeTimer.current = setTimeout(
|
||||
setter => setter(false),
|
||||
100,
|
||||
setIsActive,
|
||||
)
|
||||
e.preventDefault();
|
||||
setIsActive(true);
|
||||
onAction();
|
||||
if (activeTimer.current) clearTimeout(activeTimer.current);
|
||||
activeTimer.current = setTimeout(setter => setter(false), 100, setIsActive);
|
||||
}
|
||||
},
|
||||
[onAction],
|
||||
)
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(_e: ClickEvent) => {
|
||||
onAction()
|
||||
onAction();
|
||||
},
|
||||
[onAction],
|
||||
)
|
||||
);
|
||||
|
||||
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
|
||||
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
|
||||
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
|
||||
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
|
||||
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []);
|
||||
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []);
|
||||
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const state: ButtonState = {
|
||||
focused: isFocused,
|
||||
hovered: isHovered,
|
||||
active: isActive,
|
||||
}
|
||||
const content = typeof children === 'function' ? children(state) : children
|
||||
};
|
||||
const content = typeof children === 'function' ? children(state) : children;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -115,8 +98,8 @@ function Button({
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default Button
|
||||
export type { ButtonState }
|
||||
export default Button;
|
||||
export type { ButtonState };
|
||||
|
||||
@@ -1,99 +1,93 @@
|
||||
import React, { createContext, useEffect, useState } from 'react'
|
||||
import { FRAME_INTERVAL_MS } from '../core/constants.js'
|
||||
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
|
||||
import React, { createContext, useEffect, useState } from 'react';
|
||||
import { FRAME_INTERVAL_MS } from '../core/constants.js';
|
||||
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
|
||||
|
||||
export type Clock = {
|
||||
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
|
||||
now: () => number
|
||||
setTickInterval: (ms: number) => void
|
||||
}
|
||||
subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
|
||||
now: () => number;
|
||||
setTickInterval: (ms: number) => void;
|
||||
};
|
||||
|
||||
export function createClock(tickIntervalMs: number): Clock {
|
||||
const subscribers = new Map<() => void, boolean>()
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let currentTickIntervalMs = tickIntervalMs
|
||||
let startTime = 0
|
||||
const subscribers = new Map<() => void, boolean>();
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
let currentTickIntervalMs = tickIntervalMs;
|
||||
let startTime = 0;
|
||||
// Snapshot of the current tick's time, ensuring all subscribers in the same
|
||||
// tick see the same value (keeps animations synchronized)
|
||||
let tickTime = 0
|
||||
let tickTime = 0;
|
||||
|
||||
function tick(): void {
|
||||
tickTime = Date.now() - startTime
|
||||
tickTime = Date.now() - startTime;
|
||||
for (const onChange of subscribers.keys()) {
|
||||
onChange()
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateInterval(): void {
|
||||
const anyKeepAlive = [...subscribers.values()].some(Boolean)
|
||||
const anyKeepAlive = [...subscribers.values()].some(Boolean);
|
||||
|
||||
if (anyKeepAlive) {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
if (startTime === 0) {
|
||||
startTime = Date.now()
|
||||
startTime = Date.now();
|
||||
}
|
||||
interval = setInterval(tick, currentTickIntervalMs)
|
||||
interval = setInterval(tick, currentTickIntervalMs);
|
||||
} else if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(onChange, keepAlive) {
|
||||
subscribers.set(onChange, keepAlive)
|
||||
updateInterval()
|
||||
subscribers.set(onChange, keepAlive);
|
||||
updateInterval();
|
||||
return () => {
|
||||
subscribers.delete(onChange)
|
||||
updateInterval()
|
||||
}
|
||||
subscribers.delete(onChange);
|
||||
updateInterval();
|
||||
};
|
||||
},
|
||||
|
||||
now() {
|
||||
if (startTime === 0) {
|
||||
startTime = Date.now()
|
||||
startTime = Date.now();
|
||||
}
|
||||
// When the clock interval is running, return the synchronized tickTime
|
||||
// so all subscribers in the same tick see the same value.
|
||||
// When paused (no keepAlive subscribers), return real-time to avoid
|
||||
// returning a stale tickTime from the last tick before the pause.
|
||||
if (interval && tickTime) {
|
||||
return tickTime
|
||||
return tickTime;
|
||||
}
|
||||
return Date.now() - startTime
|
||||
return Date.now() - startTime;
|
||||
},
|
||||
|
||||
setTickInterval(ms) {
|
||||
if (ms === currentTickIntervalMs) return
|
||||
currentTickIntervalMs = ms
|
||||
updateInterval()
|
||||
if (ms === currentTickIntervalMs) return;
|
||||
currentTickIntervalMs = ms;
|
||||
updateInterval();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const ClockContext = createContext<Clock | null>(null)
|
||||
export const ClockContext = createContext<Clock | null>(null);
|
||||
|
||||
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
|
||||
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
|
||||
|
||||
// Own component so App.tsx doesn't re-render when the clock is created.
|
||||
// The clock value is stable (created once via useState), so the provider
|
||||
// never causes consumer re-renders on its own.
|
||||
export function ClockProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
|
||||
const focused = useTerminalFocus()
|
||||
export function ClockProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS));
|
||||
const focused = useTerminalFocus();
|
||||
|
||||
useEffect(() => {
|
||||
clock.setTickInterval(
|
||||
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
|
||||
)
|
||||
}, [clock, focused])
|
||||
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
|
||||
}, [clock, focused]);
|
||||
|
||||
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
|
||||
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,53 @@
|
||||
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
|
||||
import { readFileSync } from 'fs'
|
||||
import React from 'react'
|
||||
import StackUtils from 'stack-utils'
|
||||
import Box from './Box.js'
|
||||
import Text from './Text.js'
|
||||
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
|
||||
import { readFileSync } from 'fs';
|
||||
import React from 'react';
|
||||
import StackUtils from 'stack-utils';
|
||||
import Box from './Box.js';
|
||||
import Text from './Text.js';
|
||||
|
||||
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
|
||||
|
||||
// Error's source file is reported as file:///home/user/file.js
|
||||
// This function removes the file://[cwd] part
|
||||
const cleanupPath = (path: string | undefined): string | undefined => {
|
||||
return path?.replace(`file://${process.cwd()}/`, '')
|
||||
}
|
||||
return path?.replace(`file://${process.cwd()}/`, '');
|
||||
};
|
||||
|
||||
let stackUtils: StackUtils | undefined
|
||||
let stackUtils: StackUtils | undefined;
|
||||
function getStackUtils(): StackUtils {
|
||||
return (stackUtils ??= new StackUtils({
|
||||
cwd: process.cwd(),
|
||||
internals: StackUtils.nodeInternals(),
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/* eslint-enable custom-rules/no-process-cwd */
|
||||
|
||||
type ErrorLike = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly error: Error
|
||||
}
|
||||
readonly error: ErrorLike;
|
||||
};
|
||||
|
||||
export default function ErrorOverview({ error }: Props) {
|
||||
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
|
||||
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
|
||||
const filePath = cleanupPath(origin?.file)
|
||||
let excerpt: CodeExcerpt[] | undefined
|
||||
let lineWidth = 0
|
||||
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
|
||||
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
|
||||
const filePath = cleanupPath(origin?.file);
|
||||
let excerpt: CodeExcerpt[] | undefined;
|
||||
let lineWidth = 0;
|
||||
|
||||
if (filePath && origin?.line) {
|
||||
try {
|
||||
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
|
||||
const sourceCode = readFileSync(filePath, 'utf8')
|
||||
excerpt = codeExcerpt(sourceCode, origin.line)
|
||||
const sourceCode = readFileSync(filePath, 'utf8');
|
||||
excerpt = codeExcerpt(sourceCode, origin.line);
|
||||
|
||||
if (excerpt) {
|
||||
for (const { line } of excerpt) {
|
||||
lineWidth = Math.max(lineWidth, String(line).length)
|
||||
lineWidth = Math.max(lineWidth, String(line).length);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -76,9 +81,7 @@ export default function ErrorOverview({ error }: Props) {
|
||||
<Box width={lineWidth + 1}>
|
||||
<Text
|
||||
dim={line !== origin.line}
|
||||
backgroundColor={
|
||||
line === origin.line ? 'ansi:red' : undefined
|
||||
}
|
||||
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
|
||||
color={line === origin.line ? 'ansi:white' : undefined}
|
||||
>
|
||||
{String(line).padStart(lineWidth, ' ')}:
|
||||
@@ -103,7 +106,7 @@ export default function ErrorOverview({ error }: Props) {
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.map(line => {
|
||||
const parsedLine = getStackUtils().parseLine(line)
|
||||
const parsedLine = getStackUtils().parseLine(line);
|
||||
|
||||
// If the line from the stack cannot be parsed, we print out the unparsed line.
|
||||
if (!parsedLine) {
|
||||
@@ -112,7 +115,7 @@ export default function ErrorOverview({ error }: Props) {
|
||||
<Text dim>- </Text>
|
||||
<Text bold>{line}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -121,14 +124,13 @@ export default function ErrorOverview({ error }: Props) {
|
||||
<Text bold>{parsedLine.function}</Text>
|
||||
<Text dim>
|
||||
{' '}
|
||||
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
|
||||
{parsedLine.column})
|
||||
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column})
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
|
||||
import Text from './Text.js'
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { supportsHyperlinks } from '../core/supports-hyperlinks.js';
|
||||
import Text from './Text.js';
|
||||
|
||||
export type Props = {
|
||||
readonly children?: ReactNode
|
||||
readonly url: string
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
readonly children?: ReactNode;
|
||||
readonly url: string;
|
||||
readonly fallback?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Link({
|
||||
children,
|
||||
url,
|
||||
fallback,
|
||||
}: Props): React.ReactNode {
|
||||
export default function Link({ children, url, fallback }: Props): React.ReactNode {
|
||||
// Use children if provided, otherwise display the URL
|
||||
const content = children ?? url
|
||||
const content = children ?? url;
|
||||
|
||||
if (supportsHyperlinks()) {
|
||||
// Wrap in Text to ensure we're in a text context
|
||||
@@ -24,8 +20,8 @@ export default function Link({
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return <Text>{fallback ?? content}</Text>
|
||||
return <Text>{fallback ?? content}</Text>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
@@ -6,12 +6,12 @@ export type Props = {
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
readonly count?: number
|
||||
}
|
||||
readonly count?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||
*/
|
||||
export default function Newline({ count = 1 }: Props) {
|
||||
return <ink-text>{'\n'.repeat(count)}</ink-text>
|
||||
return <ink-text>{'\n'.repeat(count)}</ink-text>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { type PropsWithChildren } from 'react'
|
||||
import Box, { type Props as BoxProps } from './Box.js'
|
||||
import React, { type PropsWithChildren } from 'react';
|
||||
import Box, { type Props as BoxProps } from './Box.js';
|
||||
|
||||
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
/**
|
||||
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
fromLeftEdge?: boolean
|
||||
}
|
||||
fromLeftEdge?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks its contents as non-selectable in fullscreen text selection.
|
||||
@@ -32,14 +32,10 @@ type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
* tracking). No-op in the main-screen scrollback render where the
|
||||
* terminal's native selection is used instead.
|
||||
*/
|
||||
export function NoSelect({
|
||||
children,
|
||||
fromLeftEdge,
|
||||
...boxProps
|
||||
}: PropsWithChildren<Props>): React.ReactNode {
|
||||
export function NoSelect({ children, fromLeftEdge, ...boxProps }: PropsWithChildren<Props>): React.ReactNode {
|
||||
return (
|
||||
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
||||
*/
|
||||
lines: string[]
|
||||
lines: string[];
|
||||
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
|
||||
width: number
|
||||
}
|
||||
width: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
|
||||
@@ -27,13 +27,7 @@ type Props = {
|
||||
*/
|
||||
export function RawAnsi({ lines, width }: Props): React.ReactNode {
|
||||
if (lines.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ink-raw-ansi
|
||||
rawText={lines.join('\n')}
|
||||
rawWidth={width}
|
||||
rawHeight={lines.length}
|
||||
/>
|
||||
)
|
||||
return <ink-raw-ansi rawText={lines.join('\n')} rawWidth={width} rawHeight={lines.length} />;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import React, {
|
||||
type PropsWithChildren,
|
||||
type Ref,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Except } from 'type-fest'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
|
||||
import { markCommitStart } from '../core/reconciler.js'
|
||||
import type { Styles } from '../core/styles.js'
|
||||
import Box from './Box.js'
|
||||
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
|
||||
import type { Except } from 'type-fest';
|
||||
import type { DOMElement } from '../core/dom.js';
|
||||
import { markDirty, scheduleRenderFrom } from '../core/dom.js';
|
||||
import { markCommitStart } from '../core/reconciler.js';
|
||||
import type { Styles } from '../core/styles.js';
|
||||
import Box from './Box.js';
|
||||
|
||||
export type ScrollBoxHandle = {
|
||||
scrollTo: (y: number) => void
|
||||
scrollBy: (dy: number) => void
|
||||
scrollTo: (y: number) => void;
|
||||
scrollBy: (dy: number) => void;
|
||||
/**
|
||||
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
|
||||
* scrollTo which bakes a number that's stale by the time the throttled
|
||||
@@ -22,24 +16,24 @@ export type ScrollBoxHandle = {
|
||||
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
|
||||
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
|
||||
*/
|
||||
scrollToElement: (el: DOMElement, offset?: number) => void
|
||||
scrollToBottom: () => void
|
||||
getScrollTop: () => number
|
||||
getPendingDelta: () => number
|
||||
getScrollHeight: () => number
|
||||
scrollToElement: (el: DOMElement, offset?: number) => void;
|
||||
scrollToBottom: () => void;
|
||||
getScrollTop: () => number;
|
||||
getPendingDelta: () => number;
|
||||
getScrollHeight: () => number;
|
||||
/**
|
||||
* Like getScrollHeight, but reads Yoga directly instead of the cached
|
||||
* value written by render-node-to-output (throttled, up to 16ms stale).
|
||||
* Use when you need a fresh value in useLayoutEffect after a React commit
|
||||
* that grew content. Slightly more expensive (native Yoga call).
|
||||
*/
|
||||
getFreshScrollHeight: () => number
|
||||
getViewportHeight: () => number
|
||||
getFreshScrollHeight: () => number;
|
||||
getViewportHeight: () => number;
|
||||
/**
|
||||
* Absolute screen-buffer row of the first visible content line (inside
|
||||
* padding). Used for drag-to-scroll edge detection.
|
||||
*/
|
||||
getViewportTop: () => number
|
||||
getViewportTop: () => number;
|
||||
/**
|
||||
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
|
||||
* initial stickyScroll attribute, and by the renderer when positional
|
||||
@@ -47,14 +41,14 @@ export type ScrollBoxHandle = {
|
||||
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
|
||||
* layout values (unlike scrollTop+viewportH >= scrollHeight).
|
||||
*/
|
||||
isSticky: () => boolean
|
||||
isSticky: () => boolean;
|
||||
/**
|
||||
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
|
||||
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
|
||||
* happen during Ink's render phase after React has committed. Callers that
|
||||
* care about the sticky case should treat "at bottom" as a fallback.
|
||||
*/
|
||||
subscribe: (listener: () => void) => () => void
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/**
|
||||
* Set the render-time scrollTop clamp to the currently-mounted children's
|
||||
* coverage span. Called by useVirtualScroll after computing its range;
|
||||
@@ -63,20 +57,17 @@ export type ScrollBoxHandle = {
|
||||
* content instead of blank spacer. Pass undefined to disable (sticky,
|
||||
* cold start).
|
||||
*/
|
||||
setClampBounds: (min: number | undefined, max: number | undefined) => void
|
||||
}
|
||||
setClampBounds: (min: number | undefined, max: number | undefined) => void;
|
||||
};
|
||||
|
||||
export type ScrollBoxProps = Except<
|
||||
Styles,
|
||||
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
|
||||
> & {
|
||||
ref?: Ref<ScrollBoxHandle>
|
||||
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
|
||||
ref?: Ref<ScrollBoxHandle>;
|
||||
/**
|
||||
* When true, automatically pins scroll position to the bottom when content
|
||||
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
|
||||
*/
|
||||
stickyScroll?: boolean
|
||||
}
|
||||
stickyScroll?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A Box with `overflow: scroll` and an imperative scroll API.
|
||||
@@ -88,13 +79,8 @@ export type ScrollBoxProps = Except<
|
||||
*
|
||||
* Works best inside a fullscreen (constrained-height root) Ink tree.
|
||||
*/
|
||||
function ScrollBox({
|
||||
children,
|
||||
ref,
|
||||
stickyScroll,
|
||||
...style
|
||||
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
|
||||
const domRef = useRef<DOMElement>(null)
|
||||
function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
|
||||
const domRef = useRef<DOMElement>(null);
|
||||
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
|
||||
// mark it dirty, and call the root's throttled scheduleRender directly.
|
||||
// The Ink renderer reads scrollTop from the node — no React state needed,
|
||||
@@ -103,113 +89,109 @@ function ScrollBox({
|
||||
// render — otherwise scheduleRender's leading edge fires on the FIRST
|
||||
// event before subsequent events mutate scrollTop. scrollToBottom still
|
||||
// forces a React render: sticky is attribute-observed, no DOM-only path.
|
||||
const [, forceRender] = useState(0)
|
||||
const listenersRef = useRef(new Set<() => void>())
|
||||
const renderQueuedRef = useRef(false)
|
||||
const [, forceRender] = useState(0);
|
||||
const listenersRef = useRef(new Set<() => void>());
|
||||
const renderQueuedRef = useRef(false);
|
||||
|
||||
const notify = () => {
|
||||
for (const l of listenersRef.current) l()
|
||||
}
|
||||
for (const l of listenersRef.current) l();
|
||||
};
|
||||
|
||||
function scrollMutated(el: DOMElement): void {
|
||||
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
|
||||
// check) to skip their next tick — they compete for the event loop and
|
||||
// contributed to 1402ms max frame gaps during scroll drain.
|
||||
// noop — injected by business layer via onScrollActivity callback
|
||||
markDirty(el)
|
||||
markCommitStart()
|
||||
notify()
|
||||
if (renderQueuedRef.current) return
|
||||
renderQueuedRef.current = true
|
||||
markDirty(el);
|
||||
markCommitStart();
|
||||
notify();
|
||||
if (renderQueuedRef.current) return;
|
||||
renderQueuedRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
renderQueuedRef.current = false
|
||||
scheduleRenderFrom(el)
|
||||
})
|
||||
renderQueuedRef.current = false;
|
||||
scheduleRenderFrom(el);
|
||||
});
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ScrollBoxHandle => ({
|
||||
scrollTo(y: number) {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
const el = domRef.current;
|
||||
if (!el) return;
|
||||
// Explicit false overrides the DOM attribute so manual scroll
|
||||
// breaks stickiness. Render code checks ?? precedence.
|
||||
el.stickyScroll = false
|
||||
el.pendingScrollDelta = undefined
|
||||
el.scrollAnchor = undefined
|
||||
el.scrollTop = Math.max(0, Math.floor(y))
|
||||
scrollMutated(el)
|
||||
el.stickyScroll = false;
|
||||
el.pendingScrollDelta = undefined;
|
||||
el.scrollAnchor = undefined;
|
||||
el.scrollTop = Math.max(0, Math.floor(y));
|
||||
scrollMutated(el);
|
||||
},
|
||||
scrollToElement(el: DOMElement, offset = 0) {
|
||||
const box = domRef.current
|
||||
if (!box) return
|
||||
box.stickyScroll = false
|
||||
box.pendingScrollDelta = undefined
|
||||
box.scrollAnchor = { el, offset }
|
||||
scrollMutated(box)
|
||||
const box = domRef.current;
|
||||
if (!box) return;
|
||||
box.stickyScroll = false;
|
||||
box.pendingScrollDelta = undefined;
|
||||
box.scrollAnchor = { el, offset };
|
||||
scrollMutated(box);
|
||||
},
|
||||
scrollBy(dy: number) {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
el.stickyScroll = false
|
||||
const el = domRef.current;
|
||||
if (!el) return;
|
||||
el.stickyScroll = false;
|
||||
// Wheel input cancels any in-flight anchor seek — user override.
|
||||
el.scrollAnchor = undefined
|
||||
el.scrollAnchor = undefined;
|
||||
// Accumulate in pendingScrollDelta; renderer drains it at a capped
|
||||
// rate so fast flicks show intermediate frames. Pure accumulator:
|
||||
// scroll-up followed by scroll-down naturally cancels.
|
||||
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
|
||||
scrollMutated(el)
|
||||
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
|
||||
scrollMutated(el);
|
||||
},
|
||||
scrollToBottom() {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
el.pendingScrollDelta = undefined
|
||||
el.stickyScroll = true
|
||||
markDirty(el)
|
||||
notify()
|
||||
forceRender(n => n + 1)
|
||||
const el = domRef.current;
|
||||
if (!el) return;
|
||||
el.pendingScrollDelta = undefined;
|
||||
el.stickyScroll = true;
|
||||
markDirty(el);
|
||||
notify();
|
||||
forceRender(n => n + 1);
|
||||
},
|
||||
getScrollTop() {
|
||||
return domRef.current?.scrollTop ?? 0
|
||||
return domRef.current?.scrollTop ?? 0;
|
||||
},
|
||||
getPendingDelta() {
|
||||
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
|
||||
// this to mount the union [committed, committed+pending] range —
|
||||
// otherwise intermediate drain frames find no children (blank).
|
||||
return domRef.current?.pendingScrollDelta ?? 0
|
||||
return domRef.current?.pendingScrollDelta ?? 0;
|
||||
},
|
||||
getScrollHeight() {
|
||||
return domRef.current?.scrollHeight ?? 0
|
||||
return domRef.current?.scrollHeight ?? 0;
|
||||
},
|
||||
getFreshScrollHeight() {
|
||||
const content = domRef.current?.childNodes[0] as DOMElement | undefined
|
||||
return (
|
||||
content?.yogaNode?.getComputedHeight() ??
|
||||
domRef.current?.scrollHeight ??
|
||||
0
|
||||
)
|
||||
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
|
||||
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
|
||||
},
|
||||
getViewportHeight() {
|
||||
return domRef.current?.scrollViewportHeight ?? 0
|
||||
return domRef.current?.scrollViewportHeight ?? 0;
|
||||
},
|
||||
getViewportTop() {
|
||||
return domRef.current?.scrollViewportTop ?? 0
|
||||
return domRef.current?.scrollViewportTop ?? 0;
|
||||
},
|
||||
isSticky() {
|
||||
const el = domRef.current
|
||||
if (!el) return false
|
||||
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
|
||||
const el = domRef.current;
|
||||
if (!el) return false;
|
||||
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listenersRef.current.add(listener)
|
||||
return () => listenersRef.current.delete(listener)
|
||||
listenersRef.current.add(listener);
|
||||
return () => listenersRef.current.delete(listener);
|
||||
},
|
||||
setClampBounds(min, max) {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
el.scrollClampMin = min
|
||||
el.scrollClampMax = max
|
||||
const el = domRef.current;
|
||||
if (!el) return;
|
||||
el.scrollClampMin = min;
|
||||
el.scrollClampMax = max;
|
||||
},
|
||||
}),
|
||||
// notify/scrollMutated are inline (no useCallback) but only close over
|
||||
@@ -217,7 +199,7 @@ function ScrollBox({
|
||||
// every render (which re-registers the ref = churn).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
);
|
||||
|
||||
// Structure: outer viewport (overflow:scroll, constrained height) >
|
||||
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
|
||||
@@ -233,8 +215,8 @@ function ScrollBox({
|
||||
return (
|
||||
<ink-box
|
||||
ref={el => {
|
||||
domRef.current = el
|
||||
if (el) el.scrollTop ??= 0
|
||||
domRef.current = el;
|
||||
if (el) el.scrollTop ??= 0;
|
||||
}}
|
||||
style={{
|
||||
flexWrap: 'nowrap',
|
||||
@@ -251,7 +233,7 @@ function ScrollBox({
|
||||
{children}
|
||||
</Box>
|
||||
</ink-box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ScrollBox
|
||||
export default ScrollBox;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import Box from './Box.js'
|
||||
import React from 'react';
|
||||
import Box from './Box.js';
|
||||
|
||||
/**
|
||||
* A flexible space that expands along the major axis of its containing layout.
|
||||
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||
*/
|
||||
export default function Spacer() {
|
||||
return <Box flexGrow={1} />
|
||||
return <Box flexGrow={1} />;
|
||||
}
|
||||
|
||||
@@ -1,53 +1,36 @@
|
||||
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
|
||||
import React, { createContext, useMemo, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
getTerminalFocused,
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
type TerminalFocusState,
|
||||
} from '../core/terminal-focus-state.js'
|
||||
} from '../core/terminal-focus-state.js';
|
||||
|
||||
export type { TerminalFocusState }
|
||||
export type { TerminalFocusState };
|
||||
|
||||
export type TerminalFocusContextProps = {
|
||||
readonly isTerminalFocused: boolean
|
||||
readonly terminalFocusState: TerminalFocusState
|
||||
}
|
||||
readonly isTerminalFocused: boolean;
|
||||
readonly terminalFocusState: TerminalFocusState;
|
||||
};
|
||||
|
||||
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||
isTerminalFocused: true,
|
||||
terminalFocusState: 'unknown',
|
||||
})
|
||||
});
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
TerminalFocusContext.displayName = 'TerminalFocusContext'
|
||||
TerminalFocusContext.displayName = 'TerminalFocusContext';
|
||||
|
||||
// Separate component so App.tsx doesn't re-render on focus changes.
|
||||
// Children are a stable prop reference, so they don't re-render either —
|
||||
// only components that consume the context will re-render.
|
||||
export function TerminalFocusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const isTerminalFocused = useSyncExternalStore(
|
||||
subscribeTerminalFocus,
|
||||
getTerminalFocused,
|
||||
)
|
||||
const terminalFocusState = useSyncExternalStore(
|
||||
subscribeTerminalFocus,
|
||||
getTerminalFocusState,
|
||||
)
|
||||
export function TerminalFocusProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
|
||||
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ isTerminalFocused, terminalFocusState }),
|
||||
[isTerminalFocused, terminalFocusState],
|
||||
)
|
||||
const value = useMemo(() => ({ isTerminalFocused, terminalFocusState }), [isTerminalFocused, terminalFocusState]);
|
||||
|
||||
return (
|
||||
<TerminalFocusContext.Provider value={value}>
|
||||
{children}
|
||||
</TerminalFocusContext.Provider>
|
||||
)
|
||||
return <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>;
|
||||
}
|
||||
|
||||
export default TerminalFocusContext
|
||||
export default TerminalFocusContext;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext } from 'react'
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type TerminalSize = {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
columns: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null);
|
||||
|
||||
@@ -1,58 +1,55 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import type { Color, Styles, TextStyles } from '../core/styles.js'
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import type { Color, Styles, TextStyles } from '../core/styles.js';
|
||||
|
||||
type BaseProps = {
|
||||
/**
|
||||
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
||||
*/
|
||||
readonly color?: Color
|
||||
readonly color?: Color;
|
||||
|
||||
/**
|
||||
* Same as `color`, but for background.
|
||||
*/
|
||||
readonly backgroundColor?: Color
|
||||
readonly backgroundColor?: Color;
|
||||
|
||||
/**
|
||||
* Make the text italic.
|
||||
*/
|
||||
readonly italic?: boolean
|
||||
readonly italic?: boolean;
|
||||
|
||||
/**
|
||||
* Make the text underlined.
|
||||
*/
|
||||
readonly underline?: boolean
|
||||
readonly underline?: boolean;
|
||||
|
||||
/**
|
||||
* Make the text crossed with a line.
|
||||
*/
|
||||
readonly strikethrough?: boolean
|
||||
readonly strikethrough?: boolean;
|
||||
|
||||
/**
|
||||
* Inverse background and foreground colors.
|
||||
*/
|
||||
readonly inverse?: boolean
|
||||
readonly inverse?: boolean;
|
||||
|
||||
/**
|
||||
* This property tells Ink to wrap or truncate text if its width is larger than container.
|
||||
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
|
||||
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
|
||||
*/
|
||||
readonly wrap?: Styles['textWrap']
|
||||
readonly wrap?: Styles['textWrap'];
|
||||
|
||||
readonly children?: ReactNode
|
||||
}
|
||||
readonly children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bold and dim are mutually exclusive in terminals.
|
||||
* This type ensures you can use one or the other, but not both.
|
||||
*/
|
||||
type WeightProps =
|
||||
| { bold?: never; dim?: never }
|
||||
| { bold: boolean; dim?: never }
|
||||
| { dim: boolean; bold?: never }
|
||||
type WeightProps = { bold?: never; dim?: never } | { bold: boolean; dim?: never } | { dim: boolean; bold?: never };
|
||||
|
||||
export type Props = BaseProps & WeightProps
|
||||
export type Props = BaseProps & WeightProps;
|
||||
|
||||
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||
wrap: {
|
||||
@@ -103,7 +100,7 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-start',
|
||||
},
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
|
||||
@@ -121,7 +118,7 @@ export default function Text({
|
||||
children,
|
||||
}: Props): React.ReactNode {
|
||||
if (children === undefined || children === null) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build textStyles object with only the properties that are set
|
||||
@@ -134,11 +131,11 @@ export default function Text({
|
||||
...(underline && { underline }),
|
||||
...(strikethrough && { strikethrough }),
|
||||
...(inverse && { inverse }),
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
|
||||
{children}
|
||||
</ink-text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import React from 'react'
|
||||
import Link from '../components/Link.js'
|
||||
import Text from '../components/Text.js'
|
||||
import type { Color } from './styles.js'
|
||||
import {
|
||||
type NamedColor,
|
||||
Parser,
|
||||
type Color as TermioColor,
|
||||
type TextStyle,
|
||||
} from './termio.js'
|
||||
import React from 'react';
|
||||
import Link from '../components/Link.js';
|
||||
import Text from '../components/Text.js';
|
||||
import type { Color } from './styles.js';
|
||||
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js';
|
||||
|
||||
type Props = {
|
||||
children: string
|
||||
children: string;
|
||||
/** When true, force all text to be rendered with dim styling */
|
||||
dimColor?: boolean
|
||||
}
|
||||
dimColor?: boolean;
|
||||
};
|
||||
|
||||
type SpanProps = {
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
dim?: boolean
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
hyperlink?: string
|
||||
}
|
||||
color?: Color;
|
||||
backgroundColor?: Color;
|
||||
dim?: boolean;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
inverse?: boolean;
|
||||
hyperlink?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that parses ANSI escape codes and renders them using Text components.
|
||||
@@ -35,43 +30,32 @@ type SpanProps = {
|
||||
*
|
||||
* Memoized to prevent re-renders when parent changes but children string is the same.
|
||||
*/
|
||||
export const Ansi = React.memo(function Ansi({
|
||||
children,
|
||||
dimColor,
|
||||
}: Props): React.ReactNode {
|
||||
export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): React.ReactNode {
|
||||
if (typeof children !== 'string') {
|
||||
return dimColor ? (
|
||||
<Text dim>{String(children)}</Text>
|
||||
) : (
|
||||
<Text>{String(children)}</Text>
|
||||
)
|
||||
return dimColor ? <Text dim>{String(children)}</Text> : <Text>{String(children)}</Text>;
|
||||
}
|
||||
|
||||
if (children === '') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const spans = parseToSpans(children)
|
||||
const spans = parseToSpans(children);
|
||||
|
||||
if (spans.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
|
||||
return dimColor ? (
|
||||
<Text dim>{spans[0]!.text}</Text>
|
||||
) : (
|
||||
<Text>{spans[0]!.text}</Text>
|
||||
)
|
||||
return dimColor ? <Text dim>{spans[0]!.text}</Text> : <Text>{spans[0]!.text}</Text>;
|
||||
}
|
||||
|
||||
const content = spans.map((span, i) => {
|
||||
const hyperlink = span.props.hyperlink
|
||||
const hyperlink = span.props.hyperlink;
|
||||
// When dimColor is forced, override the span's dim prop
|
||||
if (dimColor) {
|
||||
span.props.dim = true
|
||||
span.props.dim = true;
|
||||
}
|
||||
const hasTextProps = hasAnyTextProps(span.props)
|
||||
const hasTextProps = hasAnyTextProps(span.props);
|
||||
|
||||
if (hyperlink) {
|
||||
return hasTextProps ? (
|
||||
@@ -93,7 +77,7 @@ export const Ansi = React.memo(function Ansi({
|
||||
<Link key={i} url={hyperlink}>
|
||||
{span.text}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return hasTextProps ? (
|
||||
@@ -112,79 +96,79 @@ export const Ansi = React.memo(function Ansi({
|
||||
</StyledText>
|
||||
) : (
|
||||
span.text
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
|
||||
})
|
||||
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>;
|
||||
});
|
||||
|
||||
type Span = {
|
||||
text: string
|
||||
props: SpanProps
|
||||
}
|
||||
text: string;
|
||||
props: SpanProps;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an ANSI string into spans using the termio parser.
|
||||
*/
|
||||
function parseToSpans(input: string): Span[] {
|
||||
const parser = new Parser()
|
||||
const actions = parser.feed(input)
|
||||
const spans: Span[] = []
|
||||
const parser = new Parser();
|
||||
const actions = parser.feed(input);
|
||||
const spans: Span[] = [];
|
||||
|
||||
let currentHyperlink: string | undefined
|
||||
let currentHyperlink: string | undefined;
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.type === 'link') {
|
||||
if (action.action.type === 'start') {
|
||||
currentHyperlink = action.action.url
|
||||
currentHyperlink = action.action.url;
|
||||
} else {
|
||||
currentHyperlink = undefined
|
||||
currentHyperlink = undefined;
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.type === 'text') {
|
||||
const text = action.graphemes.map(g => g.value).join('')
|
||||
if (!text) continue
|
||||
const text = action.graphemes.map(g => g.value).join('');
|
||||
if (!text) continue;
|
||||
|
||||
const props = textStyleToSpanProps(action.style)
|
||||
const props = textStyleToSpanProps(action.style);
|
||||
if (currentHyperlink) {
|
||||
props.hyperlink = currentHyperlink
|
||||
props.hyperlink = currentHyperlink;
|
||||
}
|
||||
|
||||
// Try to merge with previous span if props match
|
||||
const lastSpan = spans[spans.length - 1]
|
||||
const lastSpan = spans[spans.length - 1];
|
||||
if (lastSpan && propsEqual(lastSpan.props, props)) {
|
||||
lastSpan.text += text
|
||||
lastSpan.text += text;
|
||||
} else {
|
||||
spans.push({ text, props })
|
||||
spans.push({ text, props });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spans
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert termio's TextStyle to SpanProps.
|
||||
*/
|
||||
function textStyleToSpanProps(style: TextStyle): SpanProps {
|
||||
const props: SpanProps = {}
|
||||
const props: SpanProps = {};
|
||||
|
||||
if (style.bold) props.bold = true
|
||||
if (style.dim) props.dim = true
|
||||
if (style.italic) props.italic = true
|
||||
if (style.underline !== 'none') props.underline = true
|
||||
if (style.strikethrough) props.strikethrough = true
|
||||
if (style.inverse) props.inverse = true
|
||||
if (style.bold) props.bold = true;
|
||||
if (style.dim) props.dim = true;
|
||||
if (style.italic) props.italic = true;
|
||||
if (style.underline !== 'none') props.underline = true;
|
||||
if (style.strikethrough) props.strikethrough = true;
|
||||
if (style.inverse) props.inverse = true;
|
||||
|
||||
const fgColor = colorToString(style.fg)
|
||||
if (fgColor) props.color = fgColor
|
||||
const fgColor = colorToString(style.fg);
|
||||
if (fgColor) props.color = fgColor;
|
||||
|
||||
const bgColor = colorToString(style.bg)
|
||||
if (bgColor) props.backgroundColor = bgColor
|
||||
const bgColor = colorToString(style.bg);
|
||||
if (bgColor) props.backgroundColor = bgColor;
|
||||
|
||||
return props
|
||||
return props;
|
||||
}
|
||||
|
||||
// Map termio named colors to the ansi: format
|
||||
@@ -205,7 +189,7 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
|
||||
brightMagenta: 'ansi:magentaBright',
|
||||
brightCyan: 'ansi:cyanBright',
|
||||
brightWhite: 'ansi:whiteBright',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert termio's Color to the string format used by Ink.
|
||||
@@ -213,13 +197,13 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
|
||||
function colorToString(color: TermioColor): Color | undefined {
|
||||
switch (color.type) {
|
||||
case 'named':
|
||||
return NAMED_COLOR_MAP[color.name] as Color
|
||||
return NAMED_COLOR_MAP[color.name] as Color;
|
||||
case 'indexed':
|
||||
return `ansi256(${color.index})` as Color
|
||||
return `ansi256(${color.index})` as Color;
|
||||
case 'rgb':
|
||||
return `rgb(${color.r},${color.g},${color.b})` as Color
|
||||
return `rgb(${color.r},${color.g},${color.b})` as Color;
|
||||
case 'default':
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +221,7 @@ function propsEqual(a: SpanProps, b: SpanProps): boolean {
|
||||
a.strikethrough === b.strikethrough &&
|
||||
a.inverse === b.inverse &&
|
||||
a.hyperlink === b.hyperlink
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyProps(props: SpanProps): boolean {
|
||||
@@ -251,7 +235,7 @@ function hasAnyProps(props: SpanProps): boolean {
|
||||
props.strikethrough === true ||
|
||||
props.inverse === true ||
|
||||
props.hyperlink !== undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyTextProps(props: SpanProps): boolean {
|
||||
@@ -264,18 +248,18 @@ function hasAnyTextProps(props: SpanProps): boolean {
|
||||
props.underline === true ||
|
||||
props.strikethrough === true ||
|
||||
props.inverse === true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Text style props without weight (bold/dim) - these are handled separately
|
||||
type BaseTextStyleProps = {
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
}
|
||||
color?: Color;
|
||||
backgroundColor?: Color;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
inverse?: boolean;
|
||||
};
|
||||
|
||||
// Wrapper component that handles bold/dim mutual exclusivity for Text
|
||||
function StyledText({
|
||||
@@ -284,9 +268,9 @@ function StyledText({
|
||||
children,
|
||||
...rest
|
||||
}: BaseTextStyleProps & {
|
||||
bold?: boolean
|
||||
dim?: boolean
|
||||
children: string
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
children: string;
|
||||
}): React.ReactNode {
|
||||
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
|
||||
if (dim) {
|
||||
@@ -294,14 +278,14 @@ function StyledText({
|
||||
<Text {...rest} dim>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (bold) {
|
||||
return (
|
||||
<Text {...rest} bold>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
return <Text {...rest}>{children}</Text>
|
||||
return <Text {...rest}>{children}</Text>;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,16 @@
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
type BidiInstance = {
|
||||
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
|
||||
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
|
||||
getEmbeddingLevels: (
|
||||
text: string,
|
||||
defaultDirection?: string,
|
||||
) => { paragraphLevel: number; levels: Uint8Array }
|
||||
getReorderSegments: (
|
||||
text: string,
|
||||
embeddingLevels: { paragraphLevel: number; levels: Uint8Array },
|
||||
start?: number,
|
||||
end?: number,
|
||||
) => [number, number][]
|
||||
getVisualOrder: (reorderSegments: [number, number][]) => number[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Cursor = any;
|
||||
export type Cursor = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export {}
|
||||
|
||||
@@ -37,7 +37,9 @@ export class MouseActionEvent extends Event {
|
||||
|
||||
/** Recompute local coords relative to the target Box. */
|
||||
prepareForTarget(target: EventTarget): void {
|
||||
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
|
||||
const dom = target as unknown as {
|
||||
yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number }
|
||||
}
|
||||
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
|
||||
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type PasteEvent = any;
|
||||
export type PasteEvent = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ResizeEvent = any;
|
||||
export type ResizeEvent = any
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,7 @@ export class LogUpdate {
|
||||
const { screen } = frame
|
||||
const lines: string[] = []
|
||||
let currentStyles: AnsiCode[] = []
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
let currentHyperlink: Hyperlink
|
||||
for (let y = 0; y < screen.height; y++) {
|
||||
let line = ''
|
||||
for (let x = 0; x < screen.width; x++) {
|
||||
@@ -301,7 +301,7 @@ export class LogUpdate {
|
||||
cursorRestoreScroll
|
||||
|
||||
let currentStyleId = stylePool.none
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
let currentHyperlink: Hyperlink
|
||||
|
||||
// First pass: render changes to existing rows (rows < prev.screen.height)
|
||||
let needsFullReset = false
|
||||
@@ -533,7 +533,7 @@ function renderFrameSlice(
|
||||
stylePool: StylePool,
|
||||
): VirtualScreen {
|
||||
let currentStyleId = stylePool.none
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
let currentHyperlink: Hyperlink
|
||||
// Track the styleId of the last rendered cell on this line (-1 if none).
|
||||
// Passed to visibleCellAtIndex to enable fg-only space optimization.
|
||||
let lastRenderedStyleId = -1
|
||||
|
||||
@@ -8,18 +8,17 @@ import { Buffer } from 'buffer'
|
||||
import { PASTE_END, PASTE_START } from './termio/csi.js'
|
||||
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const FN_KEY_RE =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
|
||||
|
||||
// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
|
||||
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
|
||||
// Modifier is optional - when absent, defaults to 1 (no modifiers)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
|
||||
|
||||
// xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
|
||||
@@ -27,41 +26,41 @@ const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
|
||||
// modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
|
||||
// TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
|
||||
// Note param order is reversed vs CSI u (modifier first, keycode second).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
|
||||
|
||||
// -- Terminal response patterns (inbound sequences from the terminal itself) --
|
||||
// DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
|
||||
// DA1: CSI ? Ps ; ... c — primary device attributes response
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
|
||||
// DA2: CSI > Ps ; ... c — secondary device attributes response
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const DA2_RE = /^\x1b\[>([\d;]*)c$/
|
||||
// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
|
||||
// (private ? marker distinguishes from CSI u key events)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
|
||||
// DECXCPR cursor position: CSI ? row ; col R
|
||||
// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
|
||||
// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
|
||||
// OSC response: OSC code ; data (BEL|ST)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
|
||||
// XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q).
|
||||
// xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
|
||||
// their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
|
||||
// goes through the pty, not the environment.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
|
||||
// SGR mouse event: CSI < button ; col ; row M (press) or m (release)
|
||||
// Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
|
||||
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
|
||||
|
||||
function createPasteKey(content: string): ParsedKey {
|
||||
|
||||
@@ -34,8 +34,10 @@ if (process.env.NODE_ENV === 'development') {
|
||||
void import('./devtools.js')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional warning
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND'
|
||||
) {
|
||||
console.warn(
|
||||
`
|
||||
The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
|
||||
@@ -197,7 +199,6 @@ let _prepareAt = 0
|
||||
|
||||
/** Debug log helper — replaces fs.appendFileSync with console.warn. */
|
||||
function debugLog(message: string): void {
|
||||
// biome-ignore lint/suspicious/noConsole: debug instrumentation
|
||||
console.warn(`[ink-commit] ${message}`)
|
||||
}
|
||||
// --- END ---
|
||||
@@ -304,9 +305,7 @@ const reconciler = createReconciler<
|
||||
if (COMMIT_LOG) {
|
||||
const renderMs = performance.now() - _tr
|
||||
if (renderMs > 10) {
|
||||
debugLog(
|
||||
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`,
|
||||
)
|
||||
debugLog(`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -114,7 +114,6 @@ const wrappedRender = async (
|
||||
await Promise.resolve()
|
||||
const instance = renderSync(node, options)
|
||||
if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') {
|
||||
// biome-ignore lint/suspicious/noConsole: debug instrumentation
|
||||
console.warn(
|
||||
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
|
||||
)
|
||||
|
||||
@@ -119,6 +119,44 @@ export class StylePool {
|
||||
this.none = this.intern([])
|
||||
}
|
||||
|
||||
private static readonly CACHE_MAX = 1000
|
||||
|
||||
/**
|
||||
* Evict oldest entries from derivative caches when they exceed the limit.
|
||||
* ids/styles are never evicted (id is an array index).
|
||||
*/
|
||||
private evictCacheIfNeeded(): void {
|
||||
if (this.transitionCache.size > StylePool.CACHE_MAX) {
|
||||
const keys = this.transitionCache.keys()
|
||||
for (
|
||||
let i = 0;
|
||||
i < this.transitionCache.size - StylePool.CACHE_MAX;
|
||||
i++
|
||||
) {
|
||||
const k = keys.next().value
|
||||
if (k !== undefined) this.transitionCache.delete(k)
|
||||
}
|
||||
}
|
||||
if (this.inverseCache.size > StylePool.CACHE_MAX) {
|
||||
const keys = this.inverseCache.keys()
|
||||
for (let i = 0; i < this.inverseCache.size - StylePool.CACHE_MAX; i++) {
|
||||
const k = keys.next().value
|
||||
if (k !== undefined) this.inverseCache.delete(k)
|
||||
}
|
||||
}
|
||||
if (this.currentMatchCache.size > StylePool.CACHE_MAX) {
|
||||
const keys = this.currentMatchCache.keys()
|
||||
for (
|
||||
let i = 0;
|
||||
i < this.currentMatchCache.size - StylePool.CACHE_MAX;
|
||||
i++
|
||||
) {
|
||||
const k = keys.next().value
|
||||
if (k !== undefined) this.currentMatchCache.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
|
||||
* style has a visible effect on space characters (background, inverse,
|
||||
@@ -136,6 +174,7 @@ export class StylePool {
|
||||
(rawId << 1) |
|
||||
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
|
||||
this.ids.set(key, id)
|
||||
this.evictCacheIfNeeded()
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -286,7 +325,7 @@ function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
|
||||
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
|
||||
*/
|
||||
// const enum is inlined at compile time - no runtime object, no property access
|
||||
export const enum CellWidth {
|
||||
export enum CellWidth {
|
||||
// Not a wide character, cell width 1
|
||||
Narrow = 0,
|
||||
// Wide character, cell width 2. This cell contains the actual character.
|
||||
@@ -1144,7 +1183,7 @@ type DiffCallback = (
|
||||
y: number,
|
||||
removed: Cell | undefined,
|
||||
added: Cell | undefined,
|
||||
) => boolean | void
|
||||
) => boolean | undefined
|
||||
|
||||
/**
|
||||
* Like diff(), but calls a callback for each change instead of building an array.
|
||||
|
||||
@@ -14,9 +14,18 @@ function execFileNoThrow(
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise(resolve => {
|
||||
const { input, timeout } = options
|
||||
const proc = nodeExecFile(command, args, { timeout }, (error, stdout, stderr) => {
|
||||
resolve({ code: error ? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
|
||||
})
|
||||
const proc = nodeExecFile(
|
||||
command,
|
||||
args,
|
||||
{ timeout },
|
||||
(error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: error ? 1 : 0,
|
||||
stdout: stdout ?? '',
|
||||
stderr: stderr ?? '',
|
||||
})
|
||||
},
|
||||
)
|
||||
if (input && proc.stdin) {
|
||||
proc.stdin.write(input)
|
||||
proc.stdin.end()
|
||||
|
||||
@@ -49,7 +49,13 @@ export default function sliceAnsi(
|
||||
// pass start/end in display cells (via stringWidth), so position must
|
||||
// track the same units.
|
||||
const width =
|
||||
token.type === 'ansi' ? 0 : token.type === 'char' ? (token.fullWidth ? 2 : stringWidth(token.value)) : 0
|
||||
token.type === 'ansi'
|
||||
? 0
|
||||
: token.type === 'char'
|
||||
? token.fullWidth
|
||||
? 2
|
||||
: stringWidth(token.value)
|
||||
: 0
|
||||
|
||||
// Break AFTER trailing zero-width marks — a combining mark attaches to
|
||||
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at
|
||||
|
||||
@@ -111,6 +111,7 @@ function isDefined(n: number): boolean {
|
||||
|
||||
// NaN-safe equality for layout-cache input comparison
|
||||
function sameFloat(a: number, b: number): boolean {
|
||||
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check (x !== x ↔ isNaN)
|
||||
return a === b || (a !== a && b !== b)
|
||||
}
|
||||
|
||||
@@ -2372,12 +2373,14 @@ function boundAxis(
|
||||
if (v > maxV.value) v = maxV.value
|
||||
} else if (maxU === 2) {
|
||||
const m = (maxV.value * owner) / 100
|
||||
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
|
||||
if (m === m && v > m) v = m
|
||||
}
|
||||
if (minU === 1) {
|
||||
if (v < minV.value) v = minV.value
|
||||
} else if (minU === 2) {
|
||||
const m = (minV.value * owner) / 100
|
||||
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
|
||||
if (m === m && v < m) v = m
|
||||
}
|
||||
return v
|
||||
|
||||
@@ -110,7 +110,9 @@ export function useSearchInput({
|
||||
if (e.key === 'delete') {
|
||||
e.preventDefault()
|
||||
if (cursorOffset < query.length) {
|
||||
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
|
||||
setQueryState(
|
||||
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -159,7 +161,9 @@ export function useSearchInput({
|
||||
return
|
||||
}
|
||||
if (cursorOffset < query.length) {
|
||||
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
|
||||
setQueryState(
|
||||
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -207,7 +211,9 @@ export function useSearchInput({
|
||||
// Regular character input
|
||||
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
|
||||
e.preventDefault()
|
||||
setQueryState(query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset))
|
||||
setQueryState(
|
||||
query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset),
|
||||
)
|
||||
setCursorOffset(cursorOffset + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
||||
import { isProgressReportingAvailable, type Progress } from '../core/terminal.js'
|
||||
import {
|
||||
isProgressReportingAvailable,
|
||||
type Progress,
|
||||
} from '../core/terminal.js'
|
||||
import { BEL } from '../core/termio/ansi.js'
|
||||
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from '../core/termio/osc.js'
|
||||
import {
|
||||
ITERM2,
|
||||
OSC,
|
||||
osc,
|
||||
PROGRESS,
|
||||
wrapForMultiplexer,
|
||||
} from '../core/termio/osc.js'
|
||||
|
||||
type WriteRaw = (data: string) => void
|
||||
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
// ============================================================
|
||||
// Core API (render/createRoot)
|
||||
// ============================================================
|
||||
export { default as wrappedRender, renderSync, createRoot } from './core/root.js'
|
||||
export {
|
||||
default as wrappedRender,
|
||||
renderSync,
|
||||
createRoot,
|
||||
} from './core/root.js'
|
||||
export type { RenderOptions, Instance, Root } from './core/root.js'
|
||||
export * from './theme/theme-types.js'
|
||||
// InkCore class
|
||||
export { default as Ink } from './core/ink.js'
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Keybindings
|
||||
// ============================================================
|
||||
@@ -68,8 +71,21 @@ export type {
|
||||
// ============================================================
|
||||
// Core types
|
||||
// ============================================================
|
||||
export type { DOMElement, TextNode, ElementNames, DOMNodeAttribute } from './core/dom.js'
|
||||
export type { Styles, TextStyles, Color, RGBColor, HexColor, Ansi256Color, AnsiColor } from './core/styles.js'
|
||||
export type {
|
||||
DOMElement,
|
||||
TextNode,
|
||||
ElementNames,
|
||||
DOMNodeAttribute,
|
||||
} from './core/dom.js'
|
||||
export type {
|
||||
Styles,
|
||||
TextStyles,
|
||||
Color,
|
||||
RGBColor,
|
||||
HexColor,
|
||||
Ansi256Color,
|
||||
AnsiColor,
|
||||
} from './core/styles.js'
|
||||
export type { Key } from './core/events/input-event.js'
|
||||
export type { FlickerReason, FrameEvent } from './core/frame.js'
|
||||
export type { MatchPosition } from './core/render-to-screen.js'
|
||||
@@ -83,7 +99,10 @@ export { ClickEvent } from './core/events/click-event.js'
|
||||
export { EventEmitter } from './core/events/emitter.js'
|
||||
export { Event } from './core/events/event.js'
|
||||
export { InputEvent } from './core/events/input-event.js'
|
||||
export { TerminalFocusEvent, type TerminalFocusEventType } from './core/events/terminal-focus-event.js'
|
||||
export {
|
||||
TerminalFocusEvent,
|
||||
type TerminalFocusEventType,
|
||||
} from './core/events/terminal-focus-event.js'
|
||||
export { KeyboardEvent } from './core/events/keyboard-event.js'
|
||||
export { FocusEvent } from './core/events/focus-event.js'
|
||||
export { FocusManager } from './core/focus.js'
|
||||
@@ -92,17 +111,53 @@ export { stringWidth } from './core/stringWidth.js'
|
||||
export { default as wrapText } from './core/wrap-text.js'
|
||||
export { default as measureElement } from './core/measure-element.js'
|
||||
export { supportsTabStatus } from './core/termio/osc.js'
|
||||
export { setClipboard, getClipboardPath, CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, CLEAR_TERMINAL_TITLE, wrapForMultiplexer } from './core/termio/osc.js'
|
||||
export { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS } from './core/termio/csi.js'
|
||||
export { SHOW_CURSOR, DBP, DFE, DISABLE_MOUSE_TRACKING, EXIT_ALT_SCREEN, HIDE_CURSOR, ENTER_ALT_SCREEN, ENABLE_MOUSE_TRACKING } from './core/termio/dec.js'
|
||||
export {
|
||||
setClipboard,
|
||||
getClipboardPath,
|
||||
CLEAR_ITERM2_PROGRESS,
|
||||
CLEAR_TAB_STATUS,
|
||||
CLEAR_TERMINAL_TITLE,
|
||||
wrapForMultiplexer,
|
||||
} from './core/termio/osc.js'
|
||||
export {
|
||||
DISABLE_KITTY_KEYBOARD,
|
||||
DISABLE_MODIFY_OTHER_KEYS,
|
||||
} from './core/termio/csi.js'
|
||||
export {
|
||||
SHOW_CURSOR,
|
||||
DBP,
|
||||
DFE,
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
EXIT_ALT_SCREEN,
|
||||
HIDE_CURSOR,
|
||||
ENTER_ALT_SCREEN,
|
||||
ENABLE_MOUSE_TRACKING,
|
||||
} from './core/termio/dec.js'
|
||||
export { default as instances } from './core/instances.js'
|
||||
export { default as renderBorder, type BorderTextOptions } from './core/render-border.js'
|
||||
export { isSynchronizedOutputSupported, isXtermJs, hasCursorUpViewportYankBug, writeDiffToTerminal } from './core/terminal.js'
|
||||
export { colorize, applyColor, applyTextStyles, type ColorType } from './core/colorize.js'
|
||||
export {
|
||||
default as renderBorder,
|
||||
type BorderTextOptions,
|
||||
} from './core/render-border.js'
|
||||
export {
|
||||
isSynchronizedOutputSupported,
|
||||
isXtermJs,
|
||||
hasCursorUpViewportYankBug,
|
||||
writeDiffToTerminal,
|
||||
} from './core/terminal.js'
|
||||
export {
|
||||
colorize,
|
||||
applyColor,
|
||||
applyTextStyles,
|
||||
type ColorType,
|
||||
} from './core/colorize.js'
|
||||
export { wrapAnsi } from './core/wrapAnsi.js'
|
||||
export { default as styles } from './core/styles.js'
|
||||
export { clamp } from './core/layout/geometry.js'
|
||||
export { getTerminalFocusState, getTerminalFocused, subscribeTerminalFocus } from './core/terminal-focus-state.js'
|
||||
export {
|
||||
getTerminalFocusState,
|
||||
getTerminalFocused,
|
||||
subscribeTerminalFocus,
|
||||
} from './core/terminal-focus-state.js'
|
||||
export { supportsHyperlinks } from './core/supports-hyperlinks.js'
|
||||
|
||||
// ============================================================
|
||||
@@ -112,7 +167,11 @@ export { default as BaseBox } from './components/Box.js'
|
||||
export type { Props as BaseBoxProps } from './components/Box.js'
|
||||
export { default as BaseText } from './components/Text.js'
|
||||
export type { Props as BaseTextProps } from './components/Text.js'
|
||||
export { default as Button, type ButtonState, type Props as ButtonProps } from './components/Button.js'
|
||||
export {
|
||||
default as Button,
|
||||
type ButtonState,
|
||||
type Props as ButtonProps,
|
||||
} from './components/Button.js'
|
||||
export { default as Link } from './components/Link.js'
|
||||
export type { Props as LinkProps } from './components/Link.js'
|
||||
export { default as Newline } from './components/Newline.js'
|
||||
@@ -120,13 +179,19 @@ export type { Props as NewlineProps } from './components/Newline.js'
|
||||
export { default as Spacer } from './components/Spacer.js'
|
||||
export { NoSelect } from './components/NoSelect.js'
|
||||
export { RawAnsi } from './components/RawAnsi.js'
|
||||
export { default as ScrollBox, type ScrollBoxHandle } from './components/ScrollBox.js'
|
||||
export {
|
||||
default as ScrollBox,
|
||||
type ScrollBoxHandle,
|
||||
} from './components/ScrollBox.js'
|
||||
export { AlternateScreen } from './components/AlternateScreen.js'
|
||||
|
||||
// App types
|
||||
export type { Props as AppProps } from './components/AppContext.js'
|
||||
export type { Props as StdinProps } from './components/StdinContext.js'
|
||||
export { TerminalSizeContext, type TerminalSize } from './components/TerminalSizeContext.js'
|
||||
export {
|
||||
TerminalSizeContext,
|
||||
type TerminalSize,
|
||||
} from './components/TerminalSizeContext.js'
|
||||
|
||||
// ============================================================
|
||||
// Hooks
|
||||
@@ -140,20 +205,28 @@ export { default as useStdin } from './hooks/use-stdin.js'
|
||||
export { useTerminalSize } from './hooks/useTerminalSize.js'
|
||||
export { useTimeout } from './hooks/useTimeout.js'
|
||||
export { useMinDisplayTime } from './hooks/useMinDisplayTime.js'
|
||||
export { useDoublePress, DOUBLE_PRESS_TIMEOUT_MS } from './hooks/useDoublePress.js'
|
||||
export {
|
||||
useDoublePress,
|
||||
DOUBLE_PRESS_TIMEOUT_MS,
|
||||
} from './hooks/useDoublePress.js'
|
||||
export { useTabStatus, type TabStatusKind } from './hooks/use-tab-status.js'
|
||||
export { useTerminalFocus } from './hooks/use-terminal-focus.js'
|
||||
export { useTerminalTitle } from './hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './hooks/use-terminal-viewport.js'
|
||||
export { useSearchHighlight } from './hooks/use-search-highlight.js'
|
||||
export { useDeclaredCursor } from './hooks/use-declared-cursor.js'
|
||||
export { TerminalWriteProvider, useTerminalNotification, type TerminalNotification } from './hooks/useTerminalNotification.js'
|
||||
export {
|
||||
TerminalWriteProvider,
|
||||
useTerminalNotification,
|
||||
type TerminalNotification,
|
||||
} from './hooks/useTerminalNotification.js'
|
||||
|
||||
// ============================================================
|
||||
// Theme (Layer 3)
|
||||
// ============================================================
|
||||
export {
|
||||
ThemeProvider,
|
||||
setThemeConfigCallbacks,
|
||||
usePreviewTheme,
|
||||
useTheme,
|
||||
useThemeSetting,
|
||||
|
||||
@@ -1,84 +1,63 @@
|
||||
import React, {
|
||||
createContext,
|
||||
type RefObject,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import type { Key } from '../core/events/input-event.js'
|
||||
import {
|
||||
type ChordResolveResult,
|
||||
getBindingDisplayText,
|
||||
resolveKeyWithChordState,
|
||||
} from './resolver.js'
|
||||
import type {
|
||||
KeybindingContextName,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
} from './types.js'
|
||||
import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react';
|
||||
import type { Key } from '../core/events/input-event.js';
|
||||
import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js';
|
||||
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js';
|
||||
|
||||
/** Handler registration for action callbacks */
|
||||
type HandlerRegistration = {
|
||||
action: string
|
||||
context: KeybindingContextName
|
||||
handler: () => void
|
||||
}
|
||||
action: string;
|
||||
context: KeybindingContextName;
|
||||
handler: () => void;
|
||||
};
|
||||
|
||||
type KeybindingContextValue = {
|
||||
/** Resolve a key input to an action name (with chord support) */
|
||||
resolve: (
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
) => ChordResolveResult
|
||||
resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult;
|
||||
|
||||
/** Update the pending chord state */
|
||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
|
||||
|
||||
/** Get display text for an action (e.g., "ctrl+t") */
|
||||
getDisplayText: (
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
) => string | undefined
|
||||
getDisplayText: (action: string, context: KeybindingContextName) => string | undefined;
|
||||
|
||||
/** All parsed bindings (for help display) */
|
||||
bindings: ParsedBinding[]
|
||||
bindings: ParsedBinding[];
|
||||
|
||||
/** Current pending chord keystrokes (null if not in a chord) */
|
||||
pendingChord: ParsedKeystroke[] | null
|
||||
pendingChord: ParsedKeystroke[] | null;
|
||||
|
||||
/** Currently active keybinding contexts (for priority resolution) */
|
||||
activeContexts: Set<KeybindingContextName>
|
||||
activeContexts: Set<KeybindingContextName>;
|
||||
|
||||
/** Register a context as active (call on mount) */
|
||||
registerActiveContext: (context: KeybindingContextName) => void
|
||||
registerActiveContext: (context: KeybindingContextName) => void;
|
||||
|
||||
/** Unregister a context (call on unmount) */
|
||||
unregisterActiveContext: (context: KeybindingContextName) => void
|
||||
unregisterActiveContext: (context: KeybindingContextName) => void;
|
||||
|
||||
/** Register a handler for an action (used by useKeybinding) */
|
||||
registerHandler: (registration: HandlerRegistration) => () => void
|
||||
registerHandler: (registration: HandlerRegistration) => () => void;
|
||||
|
||||
/** Invoke all handlers for an action (used by ChordInterceptor) */
|
||||
invokeAction: (action: string) => boolean
|
||||
}
|
||||
invokeAction: (action: string) => boolean;
|
||||
};
|
||||
|
||||
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
|
||||
const KeybindingContext = createContext<KeybindingContextValue | null>(null);
|
||||
|
||||
type ProviderProps = {
|
||||
bindings: ParsedBinding[]
|
||||
bindings: ParsedBinding[];
|
||||
/** Ref for immediate access to pending chord (avoids React state delay) */
|
||||
pendingChordRef: RefObject<ParsedKeystroke[] | null>
|
||||
pendingChordRef: RefObject<ParsedKeystroke[] | null>;
|
||||
/** State value for re-renders (UI updates) */
|
||||
pendingChord: ParsedKeystroke[] | null
|
||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
||||
activeContexts: Set<KeybindingContextName>
|
||||
registerActiveContext: (context: KeybindingContextName) => void
|
||||
unregisterActiveContext: (context: KeybindingContextName) => void
|
||||
pendingChord: ParsedKeystroke[] | null;
|
||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
|
||||
activeContexts: Set<KeybindingContextName>;
|
||||
registerActiveContext: (context: KeybindingContextName) => void;
|
||||
unregisterActiveContext: (context: KeybindingContextName) => void;
|
||||
/** Ref to handler registry (used by ChordInterceptor) */
|
||||
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
|
||||
children: React.ReactNode
|
||||
}
|
||||
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function KeybindingProvider({
|
||||
bindings,
|
||||
@@ -93,60 +72,54 @@ export function KeybindingProvider({
|
||||
}: ProviderProps): React.ReactNode {
|
||||
const value = useMemo<KeybindingContextValue>(() => {
|
||||
const getDisplay = (action: string, context: KeybindingContextName) =>
|
||||
getBindingDisplayText(action, context, bindings)
|
||||
getBindingDisplayText(action, context, bindings);
|
||||
|
||||
// Register a handler for an action
|
||||
const registerHandler = (registration: HandlerRegistration) => {
|
||||
const registry = handlerRegistryRef.current
|
||||
if (!registry) return () => {}
|
||||
const registry = handlerRegistryRef.current;
|
||||
if (!registry) return () => {};
|
||||
|
||||
if (!registry.has(registration.action)) {
|
||||
registry.set(registration.action, new Set())
|
||||
registry.set(registration.action, new Set());
|
||||
}
|
||||
registry.get(registration.action)!.add(registration)
|
||||
registry.get(registration.action)!.add(registration);
|
||||
|
||||
// Return unregister function
|
||||
return () => {
|
||||
const handlers = registry.get(registration.action)
|
||||
const handlers = registry.get(registration.action);
|
||||
if (handlers) {
|
||||
handlers.delete(registration)
|
||||
handlers.delete(registration);
|
||||
if (handlers.size === 0) {
|
||||
registry.delete(registration.action)
|
||||
registry.delete(registration.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Invoke all handlers for an action
|
||||
const invokeAction = (action: string): boolean => {
|
||||
const registry = handlerRegistryRef.current
|
||||
if (!registry) return false
|
||||
const registry = handlerRegistryRef.current;
|
||||
if (!registry) return false;
|
||||
|
||||
const handlers = registry.get(action)
|
||||
if (!handlers || handlers.size === 0) return false
|
||||
const handlers = registry.get(action);
|
||||
if (!handlers || handlers.size === 0) return false;
|
||||
|
||||
// Find handlers whose context is active
|
||||
for (const registration of handlers) {
|
||||
if (activeContexts.has(registration.context)) {
|
||||
registration.handler()
|
||||
return true
|
||||
registration.handler();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
// Use ref for immediate access to pending chord, avoiding React state delay
|
||||
// This is critical for chord sequences where the second key might be pressed
|
||||
// before React re-renders with the updated pendingChord state
|
||||
resolve: (input, key, contexts) =>
|
||||
resolveKeyWithChordState(
|
||||
input,
|
||||
key,
|
||||
contexts,
|
||||
bindings,
|
||||
pendingChordRef.current,
|
||||
),
|
||||
resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current),
|
||||
setPendingChord,
|
||||
getDisplayText: getDisplay,
|
||||
bindings,
|
||||
@@ -156,7 +129,7 @@ export function KeybindingProvider({
|
||||
unregisterActiveContext,
|
||||
registerHandler,
|
||||
invokeAction,
|
||||
}
|
||||
};
|
||||
}, [
|
||||
bindings,
|
||||
pendingChordRef,
|
||||
@@ -166,23 +139,17 @@ export function KeybindingProvider({
|
||||
registerActiveContext,
|
||||
unregisterActiveContext,
|
||||
handlerRegistryRef,
|
||||
])
|
||||
]);
|
||||
|
||||
return (
|
||||
<KeybindingContext.Provider value={value}>
|
||||
{children}
|
||||
</KeybindingContext.Provider>
|
||||
)
|
||||
return <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>;
|
||||
}
|
||||
|
||||
export function useKeybindingContext(): KeybindingContextValue {
|
||||
const ctx = useContext(KeybindingContext)
|
||||
const ctx = useContext(KeybindingContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useKeybindingContext must be used within KeybindingProvider',
|
||||
)
|
||||
throw new Error('useKeybindingContext must be used within KeybindingProvider');
|
||||
}
|
||||
return ctx
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +157,7 @@ export function useKeybindingContext(): KeybindingContextValue {
|
||||
* Useful for components that may render before provider is available.
|
||||
*/
|
||||
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
||||
return useContext(KeybindingContext)
|
||||
return useContext(KeybindingContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,18 +175,15 @@ export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useRegisterKeybindingContext(
|
||||
context: KeybindingContextName,
|
||||
isActive: boolean = true,
|
||||
): void {
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void {
|
||||
const keybindingContext = useOptionalKeybindingContext();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!keybindingContext || !isActive) return
|
||||
if (!keybindingContext || !isActive) return;
|
||||
|
||||
keybindingContext.registerActiveContext(context)
|
||||
keybindingContext.registerActiveContext(context);
|
||||
return () => {
|
||||
keybindingContext.unregisterActiveContext(context)
|
||||
}
|
||||
}, [context, keybindingContext, isActive])
|
||||
keybindingContext.unregisterActiveContext(context);
|
||||
};
|
||||
}, [context, keybindingContext, isActive]);
|
||||
}
|
||||
|
||||
@@ -5,49 +5,47 @@
|
||||
* wrapper. App-specific dependencies (binding loading, change subscription,
|
||||
* warning display, debug logging) are injected via props.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { InputEvent } from '../core/events/input-event.js'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { InputEvent } from '../core/events/input-event.js';
|
||||
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
|
||||
// other handlers process them - this is required for chord sequence support
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings
|
||||
import useInput from '../hooks/use-input.js'
|
||||
import type { Key } from '../core/events/input-event.js'
|
||||
import { KeybindingProvider } from './KeybindingContext.js'
|
||||
import { resolveKeyWithChordState } from './resolver.js'
|
||||
import useInput from '../hooks/use-input.js';
|
||||
import type { Key } from '../core/events/input-event.js';
|
||||
import { KeybindingProvider } from './KeybindingContext.js';
|
||||
import { resolveKeyWithChordState } from './resolver.js';
|
||||
import type {
|
||||
KeybindingContextName,
|
||||
KeybindingsLoadResult,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
KeybindingWarning,
|
||||
} from './types.js'
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Timeout for chord sequences in milliseconds.
|
||||
* If the user doesn't complete the chord within this time, it's cancelled.
|
||||
*/
|
||||
const CHORD_TIMEOUT_MS = 1000
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
|
||||
export type KeybindingSetupProps = {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
|
||||
/** Load bindings synchronously for initial render */
|
||||
loadBindings: () => KeybindingsLoadResult
|
||||
loadBindings: () => KeybindingsLoadResult;
|
||||
|
||||
/** Subscribe to binding changes; return an unsubscribe function */
|
||||
subscribeToChanges: (
|
||||
callback: (result: KeybindingsLoadResult) => void,
|
||||
) => () => void
|
||||
subscribeToChanges: (callback: (result: KeybindingsLoadResult) => void) => () => void;
|
||||
|
||||
/** Initialize any file watcher (idempotent). Called once on mount. */
|
||||
initWatcher?: () => void | Promise<void>
|
||||
initWatcher?: () => void | Promise<void>;
|
||||
|
||||
/** Optional callback when warnings are emitted (initial load or reload) */
|
||||
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void
|
||||
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void;
|
||||
|
||||
/** Optional debug logger */
|
||||
onDebugLog?: (message: string) => void
|
||||
}
|
||||
onDebugLog?: (message: string) => void;
|
||||
};
|
||||
|
||||
export function KeybindingSetup({
|
||||
children,
|
||||
@@ -59,115 +57,105 @@ export function KeybindingSetup({
|
||||
}: KeybindingSetupProps): React.ReactNode {
|
||||
// Load bindings synchronously for initial render
|
||||
const [loadResult, setLoadResult] = useState<KeybindingsLoadResult>(() => {
|
||||
const result = loadBindings()
|
||||
const result = loadBindings();
|
||||
onDebugLog?.(
|
||||
`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
|
||||
)
|
||||
return result
|
||||
})
|
||||
);
|
||||
return result;
|
||||
});
|
||||
|
||||
const { bindings, warnings } = loadResult
|
||||
const { bindings, warnings } = loadResult;
|
||||
|
||||
// Track if this is a reload (not initial load)
|
||||
const [isReload, setIsReload] = useState(false)
|
||||
const [isReload, setIsReload] = useState(false);
|
||||
|
||||
// Notify about warnings
|
||||
useEffect(() => {
|
||||
onWarnings?.(warnings, isReload)
|
||||
}, [warnings, isReload, onWarnings])
|
||||
onWarnings?.(warnings, isReload);
|
||||
}, [warnings, isReload, onWarnings]);
|
||||
|
||||
// Chord state management - use ref for immediate access, state for re-renders
|
||||
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
|
||||
const [pendingChord, setPendingChordState] = useState<
|
||||
ParsedKeystroke[] | null
|
||||
>(null)
|
||||
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null);
|
||||
const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null);
|
||||
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
|
||||
const handlerRegistryRef = useRef(
|
||||
new Map<
|
||||
string,
|
||||
Set<{
|
||||
action: string
|
||||
context: KeybindingContextName
|
||||
handler: () => void
|
||||
action: string;
|
||||
context: KeybindingContextName;
|
||||
handler: () => void;
|
||||
}>
|
||||
>(),
|
||||
)
|
||||
);
|
||||
|
||||
// Active context tracking for keybinding priority resolution
|
||||
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
|
||||
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set());
|
||||
|
||||
const registerActiveContext = useCallback(
|
||||
(context: KeybindingContextName) => {
|
||||
activeContextsRef.current.add(context)
|
||||
},
|
||||
[],
|
||||
)
|
||||
const registerActiveContext = useCallback((context: KeybindingContextName) => {
|
||||
activeContextsRef.current.add(context);
|
||||
}, []);
|
||||
|
||||
const unregisterActiveContext = useCallback(
|
||||
(context: KeybindingContextName) => {
|
||||
activeContextsRef.current.delete(context)
|
||||
},
|
||||
[],
|
||||
)
|
||||
const unregisterActiveContext = useCallback((context: KeybindingContextName) => {
|
||||
activeContextsRef.current.delete(context);
|
||||
}, []);
|
||||
|
||||
// Clear chord timeout when component unmounts or chord changes
|
||||
const clearChordTimeout = useCallback(() => {
|
||||
if (chordTimeoutRef.current) {
|
||||
clearTimeout(chordTimeoutRef.current)
|
||||
chordTimeoutRef.current = null
|
||||
clearTimeout(chordTimeoutRef.current);
|
||||
chordTimeoutRef.current = null;
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// Wrapper for setPendingChord that manages timeout and syncs ref+state
|
||||
const setPendingChord = useCallback(
|
||||
(pending: ParsedKeystroke[] | null) => {
|
||||
clearChordTimeout()
|
||||
clearChordTimeout();
|
||||
|
||||
if (pending !== null) {
|
||||
// Set timeout to cancel chord if not completed
|
||||
chordTimeoutRef.current = setTimeout(
|
||||
(pendingChordRef, setPendingChordState) => {
|
||||
onDebugLog?.('[keybindings] Chord timeout - cancelling')
|
||||
pendingChordRef.current = null
|
||||
setPendingChordState(null)
|
||||
onDebugLog?.('[keybindings] Chord timeout - cancelling');
|
||||
pendingChordRef.current = null;
|
||||
setPendingChordState(null);
|
||||
},
|
||||
CHORD_TIMEOUT_MS,
|
||||
pendingChordRef,
|
||||
setPendingChordState,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update ref immediately for synchronous access in resolve()
|
||||
pendingChordRef.current = pending
|
||||
pendingChordRef.current = pending;
|
||||
// Update state to trigger re-renders for UI updates
|
||||
setPendingChordState(pending)
|
||||
setPendingChordState(pending);
|
||||
},
|
||||
[clearChordTimeout, onDebugLog],
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize file watcher (idempotent - only runs once)
|
||||
void initWatcher?.()
|
||||
void initWatcher?.();
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = subscribeToChanges(result => {
|
||||
// Any callback invocation is a reload since initial load happens
|
||||
// synchronously in useState, not via this subscription
|
||||
setIsReload(true)
|
||||
setIsReload(true);
|
||||
|
||||
setLoadResult(result)
|
||||
onDebugLog?.(
|
||||
`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
|
||||
)
|
||||
})
|
||||
setLoadResult(result);
|
||||
onDebugLog?.(`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearChordTimeout()
|
||||
}
|
||||
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog])
|
||||
unsubscribe();
|
||||
clearChordTimeout();
|
||||
};
|
||||
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog]);
|
||||
|
||||
return (
|
||||
<KeybindingProvider
|
||||
@@ -189,7 +177,7 @@ export function KeybindingSetup({
|
||||
/>
|
||||
{children}
|
||||
</KeybindingProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,10 +191,10 @@ export function KeybindingSetup({
|
||||
* system could recognize it as completing a chord.
|
||||
*/
|
||||
type HandlerRegistration = {
|
||||
action: string
|
||||
context: KeybindingContextName
|
||||
handler: () => void
|
||||
}
|
||||
action: string;
|
||||
context: KeybindingContextName;
|
||||
handler: () => void;
|
||||
};
|
||||
|
||||
function ChordInterceptor({
|
||||
bindings,
|
||||
@@ -215,11 +203,11 @@ function ChordInterceptor({
|
||||
activeContexts,
|
||||
handlerRegistryRef,
|
||||
}: {
|
||||
bindings: ParsedBinding[]
|
||||
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>
|
||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
||||
activeContexts: Set<KeybindingContextName>
|
||||
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
|
||||
bindings: ParsedBinding[];
|
||||
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>;
|
||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
|
||||
activeContexts: Set<KeybindingContextName>;
|
||||
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>;
|
||||
}): null {
|
||||
const handleInput = useCallback(
|
||||
(input: string, key: Key, event: InputEvent) => {
|
||||
@@ -228,94 +216,78 @@ function ChordInterceptor({
|
||||
// here. Skip the registry scan. Mid-chord wheel still falls through so
|
||||
// scrolling cancels the pending chord like any other non-matching key.
|
||||
if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Build context list from registered handlers + activeContexts + Global
|
||||
const registry = handlerRegistryRef.current
|
||||
const handlerContexts = new Set<KeybindingContextName>()
|
||||
const registry = handlerRegistryRef.current;
|
||||
const handlerContexts = new Set<KeybindingContextName>();
|
||||
if (registry) {
|
||||
for (const handlers of registry.values()) {
|
||||
for (const registration of handlers) {
|
||||
handlerContexts.add(registration.context)
|
||||
handlerContexts.add(registration.context);
|
||||
}
|
||||
}
|
||||
}
|
||||
const contexts: KeybindingContextName[] = [
|
||||
...handlerContexts,
|
||||
...activeContexts,
|
||||
'Global',
|
||||
]
|
||||
const contexts: KeybindingContextName[] = [...handlerContexts, ...activeContexts, 'Global'];
|
||||
|
||||
// Track whether we're completing a chord (pending was non-null)
|
||||
const wasInChord = pendingChordRef.current !== null
|
||||
const wasInChord = pendingChordRef.current !== null;
|
||||
|
||||
// Check if this keystroke is part of a chord sequence
|
||||
const result = resolveKeyWithChordState(
|
||||
input,
|
||||
key,
|
||||
contexts,
|
||||
bindings,
|
||||
pendingChordRef.current,
|
||||
)
|
||||
const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current);
|
||||
|
||||
switch (result.type) {
|
||||
case 'chord_started':
|
||||
// This key starts a chord - store pending state and stop propagation
|
||||
setPendingChord(result.pending)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
setPendingChord(result.pending);
|
||||
event.stopImmediatePropagation();
|
||||
break;
|
||||
|
||||
case 'match': {
|
||||
// Clear pending state
|
||||
setPendingChord(null)
|
||||
setPendingChord(null);
|
||||
|
||||
// Only invoke handlers and stop propagation for chord completions
|
||||
// (multi-keystroke sequences). Single-keystroke matches should propagate
|
||||
// to per-hook handlers to avoid interfering with other input handling.
|
||||
if (wasInChord) {
|
||||
const contextsSet = new Set(contexts)
|
||||
const contextsSet = new Set(contexts);
|
||||
if (registry) {
|
||||
const handlers = registry.get(result.action)
|
||||
const handlers = registry.get(result.action);
|
||||
if (handlers && handlers.size > 0) {
|
||||
for (const registration of handlers) {
|
||||
if (contextsSet.has(registration.context)) {
|
||||
registration.handler()
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
registration.handler();
|
||||
event.stopImmediatePropagation();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chord_cancelled':
|
||||
setPendingChord(null)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
setPendingChord(null);
|
||||
event.stopImmediatePropagation();
|
||||
break;
|
||||
|
||||
case 'unbound':
|
||||
setPendingChord(null)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
setPendingChord(null);
|
||||
event.stopImmediatePropagation();
|
||||
break;
|
||||
|
||||
case 'none':
|
||||
// No chord involvement - let other handlers process
|
||||
break
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
bindings,
|
||||
pendingChordRef,
|
||||
setPendingChord,
|
||||
activeContexts,
|
||||
handlerRegistryRef,
|
||||
],
|
||||
)
|
||||
[bindings, pendingChordRef, setPendingChord, activeContexts, handlerRegistryRef],
|
||||
);
|
||||
|
||||
useInput(handleInput)
|
||||
useInput(handleInput);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { Children, isValidElement } from 'react'
|
||||
import { Text } from '../index.js'
|
||||
import React, { Children, isValidElement } from 'react';
|
||||
import { Text } from '../index.js';
|
||||
|
||||
type Props = {
|
||||
/** The items to join with a middot separator */
|
||||
children: React.ReactNode
|
||||
}
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins children with a middot separator (" · ") for inline metadata display.
|
||||
@@ -36,22 +36,20 @@ type Props = {
|
||||
*/
|
||||
export function Byline({ children }: Props): React.ReactNode {
|
||||
// Children.toArray already filters out null, undefined, and booleans
|
||||
const validChildren = Children.toArray(children)
|
||||
const validChildren = Children.toArray(children);
|
||||
|
||||
if (validChildren.length === 0) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{validChildren.map((child, index) => (
|
||||
<React.Fragment
|
||||
key={isValidElement(child) ? (child.key ?? index) : index}
|
||||
>
|
||||
<React.Fragment key={isValidElement(child) ? (child.key ?? index) : index}>
|
||||
{index > 0 && <Text dimColor> · </Text>}
|
||||
{child}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,30 +6,18 @@
|
||||
* internal theme components.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
|
||||
import React from 'react';
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||
|
||||
type Props = {
|
||||
action: string
|
||||
context: string
|
||||
fallback: string
|
||||
description: string
|
||||
parens?: boolean
|
||||
bold?: boolean
|
||||
}
|
||||
action: string;
|
||||
context: string;
|
||||
fallback: string;
|
||||
description: string;
|
||||
parens?: boolean;
|
||||
bold?: boolean;
|
||||
};
|
||||
|
||||
export function ConfigurableShortcutHint({
|
||||
fallback,
|
||||
description,
|
||||
parens,
|
||||
bold,
|
||||
}: Props): React.ReactNode {
|
||||
return (
|
||||
<KeyboardShortcutHint
|
||||
shortcut={fallback}
|
||||
action={description}
|
||||
parens={parens}
|
||||
bold={bold}
|
||||
/>
|
||||
)
|
||||
export function ConfigurableShortcutHint({ fallback, description, parens, bold }: Props): React.ReactNode {
|
||||
return <KeyboardShortcutHint shortcut={fallback} action={description} parens={parens} bold={bold} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
type ExitState,
|
||||
useExitOnCtrlCDWithKeybindings,
|
||||
} from '../hooks/useExitOnCtrlCD.js'
|
||||
import { Box, Text } from '../index.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import type { Theme } from './theme-types.js'
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||
import { Byline } from './Byline.js'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
|
||||
import { Pane } from './Pane.js'
|
||||
import React from 'react';
|
||||
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCD.js';
|
||||
import { Box, Text } from '../index.js';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
import type { Theme } from './theme-types.js';
|
||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||
import { Byline } from './Byline.js';
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||
import { Pane } from './Pane.js';
|
||||
|
||||
type DialogProps = {
|
||||
title: React.ReactNode
|
||||
subtitle?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onCancel: () => void
|
||||
color?: keyof Theme
|
||||
hideInputGuide?: boolean
|
||||
hideBorder?: boolean
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onCancel: () => void;
|
||||
color?: keyof Theme;
|
||||
hideInputGuide?: boolean;
|
||||
hideBorder?: boolean;
|
||||
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
|
||||
inputGuide?: (exitState: ExitState) => React.ReactNode
|
||||
inputGuide?: (exitState: ExitState) => React.ReactNode;
|
||||
/**
|
||||
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
|
||||
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
|
||||
@@ -28,8 +25,8 @@ type DialogProps = {
|
||||
* consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on
|
||||
* press, delete-forward on ctrl+d with text). Defaults to `true`.
|
||||
*/
|
||||
isCancelActive?: boolean
|
||||
}
|
||||
isCancelActive?: boolean;
|
||||
};
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
@@ -42,11 +39,7 @@ export function Dialog({
|
||||
inputGuide,
|
||||
isCancelActive = true,
|
||||
}: DialogProps): React.ReactNode {
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(
|
||||
undefined,
|
||||
undefined,
|
||||
isCancelActive,
|
||||
)
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
|
||||
|
||||
// Use configurable keybinding for ESC to cancel.
|
||||
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
|
||||
@@ -55,21 +48,16 @@ export function Dialog({
|
||||
useKeybinding('confirm:no', onCancel, {
|
||||
context: 'Confirmation',
|
||||
isActive: isCancelActive,
|
||||
})
|
||||
});
|
||||
|
||||
const defaultInputGuide = exitState.pending ? (
|
||||
<Text>Press {exitState.keyName} again to exit</Text>
|
||||
) : (
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||
</Byline>
|
||||
)
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -90,11 +78,11 @@ export function Dialog({
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
if (hideBorder) {
|
||||
return content
|
||||
return content;
|
||||
}
|
||||
|
||||
return <Pane color={color}>{content}</Pane>
|
||||
return <Pane color={color}>{content}</Pane>;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import React from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { stringWidth } from '../core/stringWidth.js'
|
||||
import { Ansi, Text } from '../index.js'
|
||||
import type { Theme } from './theme-types.js'
|
||||
import React from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { stringWidth } from '../core/stringWidth.js';
|
||||
import { Ansi, Text } from '../index.js';
|
||||
import type { Theme } from './theme-types.js';
|
||||
|
||||
type DividerProps = {
|
||||
/**
|
||||
* Width of the divider in characters.
|
||||
* Defaults to terminal width.
|
||||
*/
|
||||
width?: number
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* Theme color for the divider.
|
||||
* If not provided, dimColor is used.
|
||||
*/
|
||||
color?: keyof Theme
|
||||
color?: keyof Theme;
|
||||
|
||||
/**
|
||||
* Character to use for the divider line.
|
||||
* @default '─'
|
||||
*/
|
||||
char?: string
|
||||
char?: string;
|
||||
|
||||
/**
|
||||
* Padding to subtract from the width (e.g., for indentation).
|
||||
* @default 0
|
||||
*/
|
||||
padding?: number
|
||||
padding?: number;
|
||||
|
||||
/**
|
||||
* Title shown in the middle of the divider.
|
||||
@@ -37,8 +37,8 @@ type DividerProps = {
|
||||
* // ─────────── Title ───────────
|
||||
* <Divider title="Title" />
|
||||
*/
|
||||
title?: string
|
||||
}
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A horizontal divider line.
|
||||
@@ -63,21 +63,15 @@ type DividerProps = {
|
||||
* // With centered title
|
||||
* <Divider title="3 new messages" />
|
||||
*/
|
||||
export function Divider({
|
||||
width,
|
||||
color,
|
||||
char = '─',
|
||||
padding = 0,
|
||||
title,
|
||||
}: DividerProps): React.ReactNode {
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
|
||||
export function Divider({ width, color, char = '─', padding = 0, title }: DividerProps): React.ReactNode {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding);
|
||||
|
||||
if (title) {
|
||||
const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
|
||||
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
|
||||
const leftWidth = Math.floor(sideWidth / 2)
|
||||
const rightWidth = sideWidth - leftWidth
|
||||
const titleWidth = stringWidth(title) + 2; // +2 for spaces around title
|
||||
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
|
||||
const leftWidth = Math.floor(sideWidth / 2);
|
||||
const rightWidth = sideWidth - leftWidth;
|
||||
return (
|
||||
<Text color={color} dimColor={!color}>
|
||||
{char.repeat(leftWidth)}{' '}
|
||||
@@ -86,12 +80,12 @@ export function Divider({
|
||||
</Text>{' '}
|
||||
{char.repeat(rightWidth)}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={color} dimColor={!color}>
|
||||
{char.repeat(effectiveWidth)}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchInput } from '../hooks/useSearchInput.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import { clamp } from '../core/layout/geometry.js'
|
||||
import { Box, Text, useTerminalFocus } from '../index.js'
|
||||
import { SearchBox } from './SearchBox.js'
|
||||
import { Byline } from './Byline.js'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
|
||||
import { ListItem } from './ListItem.js'
|
||||
import { Pane } from './Pane.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchInput } from '../hooks/useSearchInput.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
|
||||
import { clamp } from '../core/layout/geometry.js';
|
||||
import { Box, Text, useTerminalFocus } from '../index.js';
|
||||
import { SearchBox } from './SearchBox.js';
|
||||
import { Byline } from './Byline.js';
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||
import { ListItem } from './ListItem.js';
|
||||
import { Pane } from './Pane.js';
|
||||
|
||||
type PickerAction<T> = {
|
||||
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
|
||||
action: string
|
||||
handler: (item: T) => void
|
||||
}
|
||||
action: string;
|
||||
handler: (item: T) => void;
|
||||
};
|
||||
|
||||
type Props<T> = {
|
||||
title: string
|
||||
placeholder?: string
|
||||
initialQuery?: string
|
||||
items: readonly T[]
|
||||
getKey: (item: T) => string
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
initialQuery?: string;
|
||||
items: readonly T[];
|
||||
getKey: (item: T) => string;
|
||||
/** Keep to one line — preview handles overflow. */
|
||||
renderItem: (item: T, isFocused: boolean) => React.ReactNode
|
||||
renderPreview?: (item: T) => React.ReactNode
|
||||
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
|
||||
renderPreview?: (item: T) => React.ReactNode;
|
||||
/** 'right' keeps hints stable (no bounce), but needs width. */
|
||||
previewPosition?: 'bottom' | 'right'
|
||||
visibleCount?: number
|
||||
previewPosition?: 'bottom' | 'right';
|
||||
visibleCount?: number;
|
||||
/**
|
||||
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
|
||||
* always match screen direction — ↑ walks visually up regardless.
|
||||
*/
|
||||
direction?: 'down' | 'up'
|
||||
direction?: 'down' | 'up';
|
||||
/** Caller owns filtering: re-filter on each call and pass new items. */
|
||||
onQueryChange: (query: string) => void
|
||||
onQueryChange: (query: string) => void;
|
||||
/** Enter key. Primary action. */
|
||||
onSelect: (item: T) => void
|
||||
onSelect: (item: T) => void;
|
||||
/**
|
||||
* Tab key. If provided, Tab no longer aliases Enter — it gets its own
|
||||
* handler and hint. Shift+Tab falls through to this if onShiftTab is unset.
|
||||
*/
|
||||
onTab?: PickerAction<T>
|
||||
onTab?: PickerAction<T>;
|
||||
/** Shift+Tab key. Gets its own hint. */
|
||||
onShiftTab?: PickerAction<T>
|
||||
onShiftTab?: PickerAction<T>;
|
||||
/**
|
||||
* Fires when the focused item changes (via arrows or when items reset).
|
||||
* Useful for async preview loading — keeps I/O out of renderPreview.
|
||||
*/
|
||||
onFocus?: (item: T | undefined) => void
|
||||
onCancel: () => void
|
||||
onFocus?: (item: T | undefined) => void;
|
||||
onCancel: () => void;
|
||||
/** Shown when items is empty. Caller bakes loading/searching state into this. */
|
||||
emptyMessage?: string | ((query: string) => string)
|
||||
emptyMessage?: string | ((query: string) => string);
|
||||
/**
|
||||
* Status line below the list, e.g. "500+ matches" or "42 matches…".
|
||||
* Caller decides when to show it — pass undefined to hide.
|
||||
*/
|
||||
matchLabel?: string
|
||||
selectAction?: string
|
||||
extraHints?: React.ReactNode
|
||||
}
|
||||
matchLabel?: string;
|
||||
selectAction?: string;
|
||||
extraHints?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DEFAULT_VISIBLE = 8
|
||||
const DEFAULT_VISIBLE = 8;
|
||||
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
|
||||
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
|
||||
const CHROME_ROWS = 10
|
||||
const MIN_VISIBLE = 2
|
||||
const CHROME_ROWS = 10;
|
||||
const MIN_VISIBLE = 2;
|
||||
|
||||
export function FuzzyPicker<T>({
|
||||
title,
|
||||
@@ -90,25 +90,22 @@ export function FuzzyPicker<T>({
|
||||
selectAction = 'select',
|
||||
extraHints,
|
||||
}: Props<T>): React.ReactNode {
|
||||
const isTerminalFocused = useTerminalFocus()
|
||||
const { rows, columns } = useTerminalSize()
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
const isTerminalFocused = useTerminalFocus();
|
||||
const { rows, columns } = useTerminalSize();
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
|
||||
// Cap visibleCount so the picker never exceeds the terminal height. When it
|
||||
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
|
||||
// by the overflow amount and a previously-drawn line flashes blank.
|
||||
const visibleCount = Math.max(
|
||||
MIN_VISIBLE,
|
||||
Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)),
|
||||
)
|
||||
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
|
||||
|
||||
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
|
||||
// below that. Compact mode drops shift+tab and shortens labels.
|
||||
const compact = columns < 120
|
||||
const compact = columns < 120;
|
||||
|
||||
const step = (delta: 1 | -1) => {
|
||||
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1))
|
||||
}
|
||||
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1));
|
||||
};
|
||||
|
||||
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
|
||||
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
|
||||
@@ -120,67 +117,62 @@ export function FuzzyPicker<T>({
|
||||
onCancel,
|
||||
initialQuery,
|
||||
backspaceExitsOnEmpty: false,
|
||||
})
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
step(direction === 'up' ? 1 : -1)
|
||||
return
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(direction === 'up' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
step(direction === 'up' ? -1 : 1)
|
||||
return
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
step(direction === 'up' ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'return') {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
const selected = items[focusedIndex]
|
||||
if (selected) onSelect(selected)
|
||||
return
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const selected = items[focusedIndex];
|
||||
if (selected) onSelect(selected);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'tab') {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
const selected = items[focusedIndex]
|
||||
if (!selected) return
|
||||
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const selected = items[focusedIndex];
|
||||
if (!selected) return;
|
||||
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab;
|
||||
if (tabAction) {
|
||||
tabAction.handler(selected)
|
||||
tabAction.handler(selected);
|
||||
} else {
|
||||
onSelect(selected)
|
||||
onSelect(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onQueryChange(query)
|
||||
setFocusedIndex(0)
|
||||
onQueryChange(query);
|
||||
setFocusedIndex(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query])
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedIndex(i => clamp(i, 0, items.length - 1))
|
||||
}, [items.length])
|
||||
setFocusedIndex(i => clamp(i, 0, items.length - 1));
|
||||
}, [items.length]);
|
||||
|
||||
const focused = items[focusedIndex]
|
||||
const focused = items[focusedIndex];
|
||||
useEffect(() => {
|
||||
onFocus?.(focused)
|
||||
onFocus?.(focused);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focused])
|
||||
}, [focused]);
|
||||
|
||||
const windowStart = clamp(
|
||||
focusedIndex - visibleCount + 1,
|
||||
0,
|
||||
items.length - visibleCount,
|
||||
)
|
||||
const visible = items.slice(windowStart, windowStart + visibleCount)
|
||||
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
|
||||
const visible = items.slice(windowStart, windowStart + visibleCount);
|
||||
|
||||
const emptyText =
|
||||
typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage
|
||||
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
|
||||
|
||||
const searchBox = (
|
||||
<SearchBox
|
||||
@@ -190,7 +182,7 @@ export function FuzzyPicker<T>({
|
||||
isFocused
|
||||
isTerminalFocused={isTerminalFocused}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const listBlock = (
|
||||
<List
|
||||
@@ -204,25 +196,21 @@ export function FuzzyPicker<T>({
|
||||
renderItem={renderItem}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const preview =
|
||||
renderPreview && focused ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{renderPreview(focused)}
|
||||
</Box>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
// Structure must not depend on preview truthiness — when focused goes
|
||||
// undefined (e.g. delete clears matches), switching row→fragment would
|
||||
// change both layout AND gap count, bouncing the searchBox below.
|
||||
const listGroup =
|
||||
renderPreview && previewPosition === 'right' ? (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
height={visibleCount + (matchLabel ? 1 : 0)}
|
||||
>
|
||||
<Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
{listBlock}
|
||||
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
||||
@@ -238,18 +226,12 @@ export function FuzzyPicker<T>({
|
||||
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
||||
{preview}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
const inputAbove = direction !== 'up'
|
||||
const inputAbove = direction !== 'up';
|
||||
return (
|
||||
<Pane color="permission">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Text bold color="permission">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -258,42 +240,26 @@ export function FuzzyPicker<T>({
|
||||
{!inputAbove && searchBox}
|
||||
<Text dimColor>
|
||||
<Byline>
|
||||
<KeyboardShortcutHint
|
||||
shortcut="↑/↓"
|
||||
action={compact ? 'nav' : 'navigate'}
|
||||
/>
|
||||
<KeyboardShortcutHint
|
||||
shortcut="Enter"
|
||||
action={compact ? firstWord(selectAction) : selectAction}
|
||||
/>
|
||||
{onTab && (
|
||||
<KeyboardShortcutHint shortcut="Tab" action={onTab.action} />
|
||||
)}
|
||||
{onShiftTab && !compact && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut="shift+tab"
|
||||
action={onShiftTab.action}
|
||||
/>
|
||||
)}
|
||||
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
|
||||
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
|
||||
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
|
||||
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
|
||||
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
|
||||
{extraHints}
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
</Pane>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type ListProps<T> = Pick<
|
||||
Props<T>,
|
||||
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
|
||||
> & {
|
||||
visible: readonly T[]
|
||||
windowStart: number
|
||||
total: number
|
||||
focusedIndex: number
|
||||
emptyText: string
|
||||
}
|
||||
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
|
||||
visible: readonly T[];
|
||||
windowStart: number;
|
||||
total: number;
|
||||
focusedIndex: number;
|
||||
emptyText: string;
|
||||
};
|
||||
|
||||
function List<T>({
|
||||
visible,
|
||||
@@ -311,15 +277,14 @@ function List<T>({
|
||||
<Box height={visibleCount} flexShrink={0}>
|
||||
<Text dimColor>{emptyText}</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rows = visible.map((item, i) => {
|
||||
const actualIndex = windowStart + i
|
||||
const isFocused = actualIndex === focusedIndex
|
||||
const atLowEdge = i === 0 && windowStart > 0
|
||||
const atHighEdge =
|
||||
i === visible.length - 1 && windowStart + visibleCount! < total
|
||||
const actualIndex = windowStart + i;
|
||||
const isFocused = actualIndex === focusedIndex;
|
||||
const atLowEdge = i === 0 && windowStart > 0;
|
||||
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total;
|
||||
return (
|
||||
<ListItem
|
||||
key={getKey(item)}
|
||||
@@ -330,21 +295,17 @@ function List<T>({
|
||||
>
|
||||
{renderItem(item, isFocused)}
|
||||
</ListItem>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
height={visibleCount}
|
||||
flexShrink={0}
|
||||
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
|
||||
>
|
||||
<Box height={visibleCount} flexShrink={0} flexDirection={direction === 'up' ? 'column-reverse' : 'column'}>
|
||||
{rows}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function firstWord(s: string): string {
|
||||
const i = s.indexOf(' ')
|
||||
return i === -1 ? s : s.slice(0, i)
|
||||
const i = s.indexOf(' ');
|
||||
return i === -1 ? s : s.slice(0, i);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react'
|
||||
import Text from '../components/Text.js'
|
||||
import React from 'react';
|
||||
import Text from '../components/Text.js';
|
||||
|
||||
type Props = {
|
||||
/** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */
|
||||
shortcut: string
|
||||
shortcut: string;
|
||||
/** The action the key performs (e.g., "expand", "select", "navigate") */
|
||||
action: string
|
||||
action: string;
|
||||
/** Whether to wrap the hint in parentheses. Default: false */
|
||||
parens?: boolean
|
||||
parens?: boolean;
|
||||
/** Whether to render the shortcut in bold. Default: false */
|
||||
bold?: boolean
|
||||
}
|
||||
bold?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)"
|
||||
@@ -35,24 +35,19 @@ type Props = {
|
||||
* </Byline>
|
||||
* </Text>
|
||||
*/
|
||||
export function KeyboardShortcutHint({
|
||||
shortcut,
|
||||
action,
|
||||
parens = false,
|
||||
bold = false,
|
||||
}: Props): React.ReactNode {
|
||||
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
|
||||
export function KeyboardShortcutHint({ shortcut, action, parens = false, bold = false }: Props): React.ReactNode {
|
||||
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut;
|
||||
|
||||
if (parens) {
|
||||
return (
|
||||
<Text>
|
||||
({shortcutText} to {action})
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text>
|
||||
{shortcutText} to {action}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import figures from 'figures'
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js'
|
||||
import { Box, Text } from '../index.js'
|
||||
import figures from 'figures';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js';
|
||||
import { Box, Text } from '../index.js';
|
||||
|
||||
type ListItemProps = {
|
||||
/**
|
||||
* Whether this item is currently focused (keyboard selection).
|
||||
* Shows the pointer indicator (❯) when true.
|
||||
*/
|
||||
isFocused: boolean
|
||||
isFocused: boolean;
|
||||
|
||||
/**
|
||||
* Whether this item is selected (chosen/checked).
|
||||
* Shows the checkmark indicator (✓) when true.
|
||||
* @default false
|
||||
*/
|
||||
isSelected?: boolean
|
||||
isSelected?: boolean;
|
||||
|
||||
/**
|
||||
* The content to display for this item.
|
||||
*/
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* Optional description text displayed below the main content.
|
||||
*/
|
||||
description?: string
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Show a down arrow indicator instead of pointer (for scroll hints).
|
||||
* Only applies when not focused.
|
||||
*/
|
||||
showScrollDown?: boolean
|
||||
showScrollDown?: boolean;
|
||||
|
||||
/**
|
||||
* Show an up arrow indicator instead of pointer (for scroll hints).
|
||||
* Only applies when not focused.
|
||||
*/
|
||||
showScrollUp?: boolean
|
||||
showScrollUp?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to apply automatic styling to the children based on focus/selection state.
|
||||
@@ -46,21 +46,21 @@ type ListItemProps = {
|
||||
* - When false: children are rendered as-is, allowing custom styling
|
||||
* @default true
|
||||
*/
|
||||
styled?: boolean
|
||||
styled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this ListItem should declare the terminal cursor position.
|
||||
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
|
||||
* @default true
|
||||
*/
|
||||
declareCursor?: boolean
|
||||
}
|
||||
declareCursor?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list item component for selection UIs (dropdowns, multi-selects, menus).
|
||||
@@ -115,46 +115,46 @@ export function ListItem({
|
||||
// Determine which indicator to show
|
||||
function renderIndicator(): ReactNode {
|
||||
if (disabled) {
|
||||
return <Text> </Text>
|
||||
return <Text> </Text>;
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
return <Text color="suggestion">{figures.pointer}</Text>
|
||||
return <Text color="suggestion">{figures.pointer}</Text>;
|
||||
}
|
||||
|
||||
if (showScrollDown) {
|
||||
return <Text dimColor>{figures.arrowDown}</Text>
|
||||
return <Text dimColor>{figures.arrowDown}</Text>;
|
||||
}
|
||||
|
||||
if (showScrollUp) {
|
||||
return <Text dimColor>{figures.arrowUp}</Text>
|
||||
return <Text dimColor>{figures.arrowUp}</Text>;
|
||||
}
|
||||
|
||||
return <Text> </Text>
|
||||
return <Text> </Text>;
|
||||
}
|
||||
|
||||
// Determine text color based on state
|
||||
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
|
||||
if (disabled) {
|
||||
return 'inactive'
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
if (!styled) {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return 'success'
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
return 'suggestion'
|
||||
return 'suggestion';
|
||||
}
|
||||
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textColor = getTextColor()
|
||||
const textColor = getTextColor();
|
||||
|
||||
// Park the native terminal cursor on the pointer indicator so screen
|
||||
// readers / magnifiers track the focused item. (0,0) is the top-left of
|
||||
@@ -163,7 +163,7 @@ export function ListItem({
|
||||
line: 0,
|
||||
column: 0,
|
||||
active: isFocused && !disabled && declareCursor !== false,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Box ref={cursorRef} flexDirection="column">
|
||||
@@ -184,5 +184,5 @@ export function ListItem({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '../index.js'
|
||||
import { Spinner } from './Spinner.js'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '../index.js';
|
||||
import { Spinner } from './Spinner.js';
|
||||
|
||||
type LoadingStateProps = {
|
||||
/**
|
||||
* The loading message to display next to the spinner.
|
||||
*/
|
||||
message: string
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* Display the message in bold.
|
||||
* @default false
|
||||
*/
|
||||
bold?: boolean
|
||||
bold?: boolean;
|
||||
|
||||
/**
|
||||
* Display the message in dimmed color.
|
||||
* @default false
|
||||
*/
|
||||
dimColor?: boolean
|
||||
dimColor?: boolean;
|
||||
|
||||
/**
|
||||
* Optional subtitle displayed below the main message.
|
||||
*/
|
||||
subtitle?: string
|
||||
}
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A spinner with loading message for async operations.
|
||||
@@ -62,5 +62,5 @@ export function LoadingState({
|
||||
</Box>
|
||||
{subtitle && <Text dimColor>{subtitle}</Text>}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react'
|
||||
import { useIsInsideModal } from './modalContext.js'
|
||||
import { Box } from '../index.js'
|
||||
import type { Theme } from './theme-types.js'
|
||||
import { Divider } from './Divider.js'
|
||||
import React from 'react';
|
||||
import { useIsInsideModal } from './modalContext.js';
|
||||
import { Box } from '../index.js';
|
||||
import type { Theme } from './theme-types.js';
|
||||
import { Divider } from './Divider.js';
|
||||
|
||||
type PaneProps = {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Theme color for the top border line.
|
||||
*/
|
||||
color?: keyof Theme
|
||||
}
|
||||
color?: keyof Theme;
|
||||
};
|
||||
|
||||
/**
|
||||
* A pane — a region of the terminal that appears below the REPL prompt,
|
||||
@@ -44,7 +44,7 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
|
||||
<Box flexDirection="column" paddingX={1} flexShrink={0}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
@@ -53,5 +53,5 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user