mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
74 Commits
codex/memo
...
memory-lea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
52b61c2c06 | ||
|
|
3cb4828de6 | ||
|
|
f5c3ee5b5d |
@@ -1,8 +1,8 @@
|
|||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
indent_style = tab
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 2
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
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"
|
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint and format check
|
||||||
|
run: bunx biome ci .
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,7 +5,8 @@ coverage
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
*.suo
|
*.suo
|
||||||
*.lock
|
*.lock
|
||||||
src/utils/vendor/
|
src/utils/vendor/
|
||||||
|
|||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
bunx 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
|
## 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
|
## 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 src/utils/__tests__/hash.test.ts # run single file
|
||||||
bun test --coverage # with coverage report
|
bun test --coverage # with coverage report
|
||||||
|
|
||||||
# Lint & Format (Biome)
|
# Lint & Format (Biome) — 日常开发用 precheck 代替单独调用
|
||||||
bun run lint # check only
|
bun run lint # lint check (全项目)
|
||||||
bun run lint:fix # auto-fix
|
bun run lint:fix # auto-fix lint issues
|
||||||
bun run format # format all src/
|
bun run format # format all (全项目)
|
||||||
|
bun run check # lint + format check (全项目)
|
||||||
|
bun run check:fix # lint + format auto-fix
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
bun run health
|
bun run health
|
||||||
@@ -58,9 +60,8 @@ bun run health
|
|||||||
# Check unused exports
|
# Check unused exports
|
||||||
bun run check:unused
|
bun run check:unused
|
||||||
|
|
||||||
# Full check (typecheck + lint + test) — run after completing any task
|
# Full check (typecheck + lint fix + test) — 任务完成后必须运行
|
||||||
bun run test:all
|
bun run precheck
|
||||||
bun run typecheck
|
|
||||||
|
|
||||||
# Remote Control Server
|
# Remote Control Server
|
||||||
bun run rcs
|
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。
|
- **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.
|
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
- **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`。
|
- **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
|
### Entry & Bootstrap
|
||||||
|
|
||||||
@@ -308,7 +311,7 @@ mock.module("src/utils/debug.ts", debugMock);
|
|||||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run typecheck
|
bun run precheck
|
||||||
```
|
```
|
||||||
|
|
||||||
**类型规范**:
|
**类型规范**:
|
||||||
@@ -321,14 +324,16 @@ bun run typecheck
|
|||||||
|
|
||||||
## Working with This Codebase
|
## 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` 函数。
|
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
- **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`。
|
- **`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.
|
- **`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 })` 注入。修改版本号等常量只改这个文件。
|
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
- **构建产物兼容 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 中。
|
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||||
|
|
||||||
- 🚀 [想要启动项目](#快速开始源码版)
|
- 🚀 [想要启动项目](#-快速开始源码版)
|
||||||
- 🐛 [想要调试项目](#vs-code-调试)
|
- 🐛 [想要调试项目](#vs-code-调试)
|
||||||
- 📖 [想要学习项目](#teach-me-学习项目)
|
- 📖 [想要学习项目](#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 # 我们有自部署的远程控制
|
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",
|
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!!**/dist", "!!**/packages/@ant"]
|
"includes": ["**", "!!**/dist"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"lineWidth": 80
|
"lineWidth": 80
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "off",
|
"noExplicitAny": "off",
|
||||||
"noAssignInExpressions": "off",
|
"noAssignInExpressions": "off",
|
||||||
"noDoubleEquals": "off",
|
"noDoubleEquals": "off",
|
||||||
"noRedeclare": "off",
|
"noRedeclare": "off",
|
||||||
"noImplicitAnyLet": "off",
|
"noImplicitAnyLet": "off",
|
||||||
"noGlobalIsNan": "off",
|
"noGlobalIsNan": "off",
|
||||||
"noFallthroughSwitchClause": "off",
|
"noFallthroughSwitchClause": "off",
|
||||||
"noShadowRestrictedNames": "off",
|
"noShadowRestrictedNames": "off",
|
||||||
"noArrayIndexKey": "off",
|
"noArrayIndexKey": "off",
|
||||||
"noConsole": "off",
|
"noConsole": "off",
|
||||||
"noConfusingLabels": "off",
|
"noConfusingLabels": "off",
|
||||||
"useIterableCallbackReturn": "off"
|
"useIterableCallbackReturn": "off"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"useConst": "off",
|
"useConst": "off",
|
||||||
"noNonNullAssertion": "off",
|
"noNonNullAssertion": "off",
|
||||||
"noParameterAssign": "off",
|
"noParameterAssign": "off",
|
||||||
"useDefaultParameterLast": "off",
|
"useDefaultParameterLast": "off",
|
||||||
"noUnusedTemplateLiteral": "off",
|
"noUnusedTemplateLiteral": "off",
|
||||||
"useTemplate": "off",
|
"useTemplate": "off",
|
||||||
"useNumberNamespace": "off",
|
"useNumberNamespace": "off",
|
||||||
"useNodejsImportProtocol": "off",
|
"useNodejsImportProtocol": "off",
|
||||||
"useImportType": "off"
|
"useImportType": "off"
|
||||||
},
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off",
|
"noForEach": "off",
|
||||||
"noBannedTypes": "off",
|
"noBannedTypes": "off",
|
||||||
"noUselessConstructor": "off",
|
"noUselessConstructor": "off",
|
||||||
"noStaticOnlyClass": "off",
|
"noStaticOnlyClass": "off",
|
||||||
"useOptionalChain": "off",
|
"useOptionalChain": "off",
|
||||||
"noUselessSwitchCase": "off",
|
"noUselessSwitchCase": "off",
|
||||||
"noUselessFragments": "off",
|
"noUselessFragments": "off",
|
||||||
"noUselessTernary": "off",
|
"noUselessTernary": "off",
|
||||||
"noUselessLoneBlockStatements": "off",
|
"noUselessLoneBlockStatements": "off",
|
||||||
"noUselessEmptyExport": "off",
|
"noUselessEmptyExport": "off",
|
||||||
"useArrowFunction": "off",
|
"useArrowFunction": "off",
|
||||||
"useLiteralKeys": "off"
|
"useLiteralKeys": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noUnusedVariables": "off",
|
"noUnusedVariables": "off",
|
||||||
"noUnusedImports": "off",
|
"noUnusedImports": "off",
|
||||||
"useExhaustiveDependencies": "off",
|
"useExhaustiveDependencies": "off",
|
||||||
"noSwitchDeclarations": "off",
|
"noSwitchDeclarations": "off",
|
||||||
"noUnreachable": "off",
|
"noUnreachable": "off",
|
||||||
"useHookAtTopLevel": "off",
|
"useHookAtTopLevel": "off",
|
||||||
"noVoidTypeReturn": "off",
|
"noVoidTypeReturn": "off",
|
||||||
"noConstantCondition": "off",
|
"noConstantCondition": "off",
|
||||||
"noUnusedFunctionParameters": "off"
|
"noUnusedFunctionParameters": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"recommended": false
|
"recommended": false
|
||||||
},
|
},
|
||||||
"nursery": {
|
"nursery": {
|
||||||
"recommended": false
|
"recommended": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"json": {
|
"json": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": false
|
"enabled": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"css": {
|
||||||
"formatter": {
|
"parser": {
|
||||||
"quoteStyle": "single",
|
"tailwindDirectives": true
|
||||||
"semicolons": "asNeeded",
|
}
|
||||||
"arrowParentheses": "asNeeded",
|
},
|
||||||
"trailingCommas": "all"
|
"javascript": {
|
||||||
}
|
"formatter": {
|
||||||
},
|
"quoteStyle": "single",
|
||||||
"overrides": [
|
"semicolons": "asNeeded",
|
||||||
{
|
"arrowParentheses": "asNeeded",
|
||||||
"includes": ["**/*.tsx"],
|
"trailingCommas": "all"
|
||||||
"javascript": {
|
}
|
||||||
"formatter": {
|
},
|
||||||
"semicolons": "always"
|
"overrides": [
|
||||||
}
|
{
|
||||||
},
|
"includes": ["**/*.tsx"],
|
||||||
"formatter": {
|
"javascript": {
|
||||||
"lineWidth": 120
|
"formatter": {
|
||||||
}
|
"semicolons": "always"
|
||||||
},
|
}
|
||||||
{
|
},
|
||||||
"includes": ["scripts/**", "packages/**", "**/*.js", "**/*.mjs", "**/*.jsx"],
|
"formatter": {
|
||||||
"formatter": {
|
"lineWidth": 120
|
||||||
"enabled": false
|
}
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
],
|
"assist": {
|
||||||
"assist": {
|
"enabled": false
|
||||||
"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.
|
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
|
||||||
let bunPatched = 0
|
let bunPatched = 0
|
||||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
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) {
|
for (const file of files) {
|
||||||
if (!file.endsWith('.js')) continue
|
if (!file.endsWith('.js')) continue
|
||||||
const filePath = join(outdir, file)
|
const filePath = join(outdir, file)
|
||||||
|
|||||||
44
bun.lock
44
bun.lock
@@ -103,11 +103,13 @@
|
|||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"https-proxy-agent": "^8.0.0",
|
"https-proxy-agent": "^8.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"image-processor-napi": "workspace:*",
|
"image-processor-napi": "workspace:*",
|
||||||
"indent-string": "^5.0.0",
|
"indent-string": "^5.0.0",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"knip": "^6.4.1",
|
"knip": "^6.4.1",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"lru-cache": "^11.3.5",
|
"lru-cache": "^11.3.5",
|
||||||
"marked": "^17.0.6",
|
"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=="],
|
"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-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=="],
|
"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-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-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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/@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=="],
|
"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=="],
|
"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/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=="],
|
"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"
|
"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` |
|
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||||
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
|
Read 的 `maxResultSizeChars` 为 100,000(100KB)。超出此阈值的结果会被持久化到磁盘,减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
## FileRead:多模态文件读取引擎
|
## FileRead:多模态文件读取引擎
|
||||||
|
|||||||
40
knip.json
40
knip.json
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||||
"entry": ["src/entrypoints/cli.tsx"],
|
"entry": ["src/entrypoints/cli.tsx"],
|
||||||
"project": ["src/**/*.{ts,tsx}"],
|
"project": ["src/**/*.{ts,tsx}"],
|
||||||
"ignore": ["src/types/**", "src/**/*.d.ts"],
|
"ignore": ["src/types/**", "src/**/*.d.ts"],
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
"@ant/*",
|
"@ant/*",
|
||||||
"react-compiler-runtime",
|
"react-compiler-runtime",
|
||||||
"@anthropic-ai/mcpb",
|
"@anthropic-ai/mcpb",
|
||||||
"@anthropic-ai/sandbox-runtime"
|
"@anthropic-ai/sandbox-runtime"
|
||||||
],
|
],
|
||||||
"ignoreBinaries": ["bun"],
|
"ignoreBinaries": ["bun"],
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages/*": {
|
"packages/*": {
|
||||||
"entry": ["src/index.ts"],
|
"entry": ["src/index.ts"],
|
||||||
"project": ["src/**/*.ts"]
|
"project": ["src/**/*.ts"]
|
||||||
},
|
},
|
||||||
"packages/@ant/*": {
|
"packages/@ant/*": {
|
||||||
"ignore": ["**"]
|
"ignore": ["**"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.10.2",
|
"version": "2.0.4",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"repl"
|
"repl"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.2.0"
|
"bun": ">=1.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ccb": "dist/cli-node.js",
|
"ccb": "dist/cli-node.js",
|
||||||
@@ -48,9 +48,12 @@
|
|||||||
"dev": "bun run scripts/dev.ts",
|
"dev": "bun run scripts/dev.ts",
|
||||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||||
"prepublishOnly": "bun run build:vite",
|
"prepublishOnly": "bun run build:vite",
|
||||||
"lint": "biome lint src/",
|
"lint": "biome lint .",
|
||||||
"lint:fix": "biome lint --fix src/",
|
"lint:fix": "biome lint --fix .",
|
||||||
"format": "biome format --write src/",
|
"format": "biome format --write .",
|
||||||
|
"check": "biome check .",
|
||||||
|
"check:fix": "biome check --fix .",
|
||||||
|
"prepare": "husky",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:production": "bun run scripts/production-test.ts",
|
"test:production": "bun run scripts/production-test.ts",
|
||||||
"test:production:offline": "bun run scripts/production-test.ts --offline",
|
"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",
|
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||||
"docs:dev": "npx mintlify dev",
|
"docs:dev": "npx mintlify dev",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test:all": "bun run typecheck && bun test",
|
"precheck": "bun run typecheck && bun run check:fix && bun test",
|
||||||
"rcs": "bun run scripts/rcs.ts"
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -73,11 +76,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||||
"@ant/model-provider": "workspace:*",
|
|
||||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||||
"@ant/computer-use-input": "workspace:*",
|
"@ant/computer-use-input": "workspace:*",
|
||||||
"@ant/computer-use-mcp": "workspace:*",
|
"@ant/computer-use-mcp": "workspace:*",
|
||||||
"@ant/computer-use-swift": "workspace:*",
|
"@ant/computer-use-swift": "workspace:*",
|
||||||
|
"@ant/model-provider": "workspace:*",
|
||||||
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||||
@@ -164,11 +167,13 @@
|
|||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"https-proxy-agent": "^8.0.0",
|
"https-proxy-agent": "^8.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"image-processor-napi": "workspace:*",
|
"image-processor-napi": "workspace:*",
|
||||||
"indent-string": "^5.0.0",
|
"indent-string": "^5.0.0",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"knip": "^6.4.1",
|
"knip": "^6.4.1",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"lru-cache": "^11.3.5",
|
"lru-cache": "^11.3.5",
|
||||||
"marked": "^17.0.6",
|
"marked": "^17.0.6",
|
||||||
@@ -216,5 +221,13 @@
|
|||||||
"hono": "4.12.15",
|
"hono": "4.12.15",
|
||||||
"postcss": "8.5.10",
|
"postcss": "8.5.10",
|
||||||
"uuid": "14.0.0"
|
"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",
|
"name": "@ant/claude-for-chrome-mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./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 = [
|
export const BROWSER_TOOLS = [
|
||||||
{
|
{
|
||||||
name: "javascript_tool",
|
name: 'javascript_tool',
|
||||||
description:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
action: {
|
action: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description: "Must be set to 'javascript_exec'",
|
description: "Must be set to 'javascript_exec'",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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.",
|
"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: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
filter: {
|
filter: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
enum: ["interactive", "all"],
|
enum: ['interactive', 'all'],
|
||||||
description:
|
description:
|
||||||
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
|
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
|
||||||
},
|
},
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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: {
|
depth: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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: {
|
ref_id: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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: {
|
max_chars: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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:
|
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.',
|
'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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
|
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
|
||||||
},
|
},
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
ref: {
|
ref: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
|
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
type: ["string", "boolean", "number"],
|
type: ['string', 'boolean', 'number'],
|
||||||
description:
|
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: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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.`,
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
action: {
|
action: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
enum: [
|
enum: [
|
||||||
"left_click",
|
'left_click',
|
||||||
"right_click",
|
'right_click',
|
||||||
"type",
|
'type',
|
||||||
"screenshot",
|
'screenshot',
|
||||||
"wait",
|
'wait',
|
||||||
"scroll",
|
'scroll',
|
||||||
"key",
|
'key',
|
||||||
"left_click_drag",
|
'left_click_drag',
|
||||||
"double_click",
|
'double_click',
|
||||||
"triple_click",
|
'triple_click',
|
||||||
"zoom",
|
'zoom',
|
||||||
"scroll_to",
|
'scroll_to',
|
||||||
"hover",
|
'hover',
|
||||||
],
|
],
|
||||||
description:
|
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: {
|
coordinate: {
|
||||||
type: "array",
|
type: 'array',
|
||||||
items: { type: "number" },
|
items: { type: 'number' },
|
||||||
minItems: 2,
|
minItems: 2,
|
||||||
maxItems: 2,
|
maxItems: 2,
|
||||||
description:
|
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: {
|
text: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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).',
|
'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: {
|
duration: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
maximum: 30,
|
maximum: 30,
|
||||||
description:
|
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: {
|
scroll_direction: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
enum: ["up", "down", "left", "right"],
|
enum: ['up', 'down', 'left', 'right'],
|
||||||
description: "The direction to scroll. Required for `scroll`.",
|
description: 'The direction to scroll. Required for `scroll`.',
|
||||||
},
|
},
|
||||||
scroll_amount: {
|
scroll_amount: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
minimum: 1,
|
minimum: 1,
|
||||||
maximum: 10,
|
maximum: 10,
|
||||||
description:
|
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: {
|
start_coordinate: {
|
||||||
type: "array",
|
type: 'array',
|
||||||
items: { type: "number" },
|
items: { type: 'number' },
|
||||||
minItems: 2,
|
minItems: 2,
|
||||||
maxItems: 2,
|
maxItems: 2,
|
||||||
description:
|
description:
|
||||||
"(x, y): The starting coordinates for `left_click_drag`.",
|
'(x, y): The starting coordinates for `left_click_drag`.',
|
||||||
},
|
},
|
||||||
region: {
|
region: {
|
||||||
type: "array",
|
type: 'array',
|
||||||
items: { type: "number" },
|
items: { type: 'number' },
|
||||||
minItems: 4,
|
minItems: 4,
|
||||||
maxItems: 4,
|
maxItems: 4,
|
||||||
description:
|
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: {
|
repeat: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
minimum: 1,
|
minimum: 1,
|
||||||
maximum: 100,
|
maximum: 100,
|
||||||
description:
|
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: {
|
ref: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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.',
|
'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: {
|
modifiers: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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.',
|
'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: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
url: {
|
url: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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.',
|
'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: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
width: {
|
width: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description: "Target window width in pixels",
|
description: 'Target window width in pixels',
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description: "Target window height in pixels",
|
description: 'Target window height in pixels',
|
||||||
},
|
},
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
action: {
|
action: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
enum: ["start_recording", "stop_recording", "export", "clear"],
|
enum: ['start_recording', 'stop_recording', 'export', 'clear'],
|
||||||
description:
|
description:
|
||||||
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
|
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
|
||||||
},
|
},
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
description:
|
||||||
"Tab ID to identify which tab group this operation applies to",
|
'Tab ID to identify which tab group this operation applies to',
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
|
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
|
||||||
},
|
},
|
||||||
filename: {
|
filename: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
|
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
description:
|
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).",
|
"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: {
|
properties: {
|
||||||
showClickIndicators: {
|
showClickIndicators: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
"Show orange circles at click locations (default: true)",
|
'Show orange circles at click locations (default: true)',
|
||||||
},
|
},
|
||||||
showDragPaths: {
|
showDragPaths: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description: "Show red arrows for drag actions (default: true)",
|
description: 'Show red arrows for drag actions (default: true)',
|
||||||
},
|
},
|
||||||
showActionLabels: {
|
showActionLabels: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
"Show black labels describing actions (default: true)",
|
'Show black labels describing actions (default: true)',
|
||||||
},
|
},
|
||||||
showProgressBar: {
|
showProgressBar: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description: "Show orange progress bar at bottom (default: true)",
|
description: 'Show orange progress bar at bottom (default: true)',
|
||||||
},
|
},
|
||||||
showWatermark: {
|
showWatermark: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description: "Show Claude logo watermark (default: true)",
|
description: 'Show Claude logo watermark (default: true)',
|
||||||
},
|
},
|
||||||
quality: {
|
quality: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
imageId: {
|
imageId: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
|
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
|
||||||
},
|
},
|
||||||
ref: {
|
ref: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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.',
|
'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: {
|
coordinate: {
|
||||||
type: "array",
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
},
|
},
|
||||||
description:
|
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: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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: {
|
filename: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
'Optional filename for the uploaded file (default: "image.png")',
|
'Optional filename for the uploaded file (default: "image.png")',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["imageId", "tabId"],
|
required: ['imageId', 'tabId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_page_text",
|
name: 'get_page_text',
|
||||||
description:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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",
|
name: 'tabs_context_mcp',
|
||||||
title: "Tabs Context",
|
title: 'Tabs Context',
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
createIfEmpty: {
|
createIfEmpty: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
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: [],
|
required: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tabs_create_mcp",
|
name: 'tabs_create_mcp',
|
||||||
title: "Tabs Create",
|
title: 'Tabs Create',
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
required: [],
|
required: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_plan",
|
name: 'update_plan',
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: 'object' as const,
|
||||||
properties: {
|
properties: {
|
||||||
domains: {
|
domains: {
|
||||||
type: "array" as const,
|
type: 'array' as const,
|
||||||
items: { type: "string" as const },
|
items: { type: 'string' as const },
|
||||||
description:
|
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.",
|
"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: {
|
approach: {
|
||||||
type: "array" as const,
|
type: 'array' as const,
|
||||||
items: { type: "string" as const },
|
items: { type: 'string' as const },
|
||||||
description:
|
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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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: {
|
onlyErrors: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
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: {
|
clear: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
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: {
|
pattern: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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.",
|
"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: {
|
limit: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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: {
|
urlPattern: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
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).",
|
"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: {
|
clear: {
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
description:
|
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: {
|
limit: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
tabId: {
|
tabId: {
|
||||||
type: "number",
|
type: 'number',
|
||||||
description:
|
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.",
|
"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: {
|
shortcutId: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description: "The ID of the shortcut to execute",
|
description: 'The ID of the shortcut to execute',
|
||||||
},
|
},
|
||||||
command: {
|
command: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
|
"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:
|
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.",
|
"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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
required: [],
|
required: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
|
export { BridgeClient, createBridgeClient } from './bridgeClient.js'
|
||||||
export { BROWSER_TOOLS } from "./browserTools.js";
|
export { BROWSER_TOOLS } from './browserTools.js'
|
||||||
export {
|
export {
|
||||||
createChromeSocketClient,
|
createChromeSocketClient,
|
||||||
createClaudeForChromeMcpServer,
|
createClaudeForChromeMcpServer,
|
||||||
} from "./mcpServer.js";
|
} from './mcpServer.js'
|
||||||
export { localPlatformLabel } from "./types.js";
|
export { localPlatformLabel } from './types.js'
|
||||||
export type {
|
export type {
|
||||||
BridgeConfig,
|
BridgeConfig,
|
||||||
ChromeExtensionInfo,
|
ChromeExtensionInfo,
|
||||||
@@ -12,4 +12,4 @@ export type {
|
|||||||
Logger,
|
Logger,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
SocketClient,
|
SocketClient,
|
||||||
} from "./types.js";
|
} from './types.js'
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
|
||||||
import { createBridgeClient } from "./bridgeClient.js";
|
import { createBridgeClient } from './bridgeClient.js'
|
||||||
import { BROWSER_TOOLS } from "./browserTools.js";
|
import { BROWSER_TOOLS } from './browserTools.js'
|
||||||
import { createMcpSocketClient } from "./mcpSocketClient.js";
|
import { createMcpSocketClient } from './mcpSocketClient.js'
|
||||||
import { createMcpSocketPool } from "./mcpSocketPool.js";
|
import { createMcpSocketPool } from './mcpSocketPool.js'
|
||||||
import { handleToolCall } from "./toolCalls.js";
|
import { handleToolCall } from './toolCalls.js'
|
||||||
import type { ClaudeForChromeContext, SocketClient } from "./types.js";
|
import type { ClaudeForChromeContext, SocketClient } from './types.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the socket/bridge client for the Chrome extension MCP server.
|
* Create the socket/bridge client for the Chrome extension MCP server.
|
||||||
@@ -24,23 +24,22 @@ export function createChromeSocketClient(
|
|||||||
? createBridgeClient(context)
|
? createBridgeClient(context)
|
||||||
: context.getSocketPaths
|
: context.getSocketPaths
|
||||||
? createMcpSocketPool(context)
|
? createMcpSocketPool(context)
|
||||||
: createMcpSocketClient(context);
|
: createMcpSocketClient(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createClaudeForChromeMcpServer(
|
export function createClaudeForChromeMcpServer(
|
||||||
context: ClaudeForChromeContext,
|
context: ClaudeForChromeContext,
|
||||||
existingSocketClient?: SocketClient,
|
existingSocketClient?: SocketClient,
|
||||||
): Server {
|
): Server {
|
||||||
const { serverName, logger } = context;
|
const { serverName, logger } = context
|
||||||
|
|
||||||
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
|
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
|
||||||
const socketClient =
|
const socketClient = existingSocketClient ?? createChromeSocketClient(context)
|
||||||
existingSocketClient ?? createChromeSocketClient(context);
|
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{
|
||||||
name: serverName,
|
name: serverName,
|
||||||
version: "1.0.0",
|
version: '1.0.0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -48,49 +47,49 @@ export function createClaudeForChromeMcpServer(
|
|||||||
logging: {},
|
logging: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
if (context.isDisabled?.()) {
|
if (context.isDisabled?.()) {
|
||||||
return { tools: [] };
|
return { tools: [] }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
tools: context.bridgeConfig
|
tools: context.bridgeConfig
|
||||||
? BROWSER_TOOLS
|
? BROWSER_TOOLS
|
||||||
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
|
: BROWSER_TOOLS.filter(t => t.name !== 'switch_browser'),
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
server.setRequestHandler(
|
server.setRequestHandler(
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
async (request): Promise<CallToolResult> => {
|
async (request): Promise<CallToolResult> => {
|
||||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
|
logger.info(`[${serverName}] Executing tool: ${request.params.name}`)
|
||||||
|
|
||||||
return handleToolCall(
|
return handleToolCall(
|
||||||
context,
|
context,
|
||||||
socketClient,
|
socketClient,
|
||||||
request.params.name,
|
request.params.name,
|
||||||
request.params.arguments || {},
|
request.params.arguments || {},
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
socketClient.setNotificationHandler((notification) => {
|
socketClient.setNotificationHandler(notification => {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
|
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
|
||||||
);
|
)
|
||||||
server
|
server
|
||||||
.notification({
|
.notification({
|
||||||
method: notification.method,
|
method: notification.method,
|
||||||
params: notification.params,
|
params: notification.params,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
// Server may not be connected yet (e.g., during startup or after disconnect)
|
// Server may not be connected yet (e.g., during startup or after disconnect)
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
|
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
return server;
|
return server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,327 +1,324 @@
|
|||||||
import { promises as fsPromises } from "fs";
|
import { promises as fsPromises } from 'fs'
|
||||||
import { createConnection } from "net";
|
import { createConnection } from 'net'
|
||||||
import type { Socket } from "net";
|
import type { Socket } from 'net'
|
||||||
import { platform } from "os";
|
import { platform } from 'os'
|
||||||
import { dirname } from "path";
|
import { dirname } from 'path'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ClaudeForChromeContext,
|
ClaudeForChromeContext,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
PermissionOverrides,
|
PermissionOverrides,
|
||||||
} from "./types.js";
|
} from './types.js'
|
||||||
|
|
||||||
export class SocketConnectionError extends Error {
|
export class SocketConnectionError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message)
|
||||||
this.name = "SocketConnectionError";
|
this.name = 'SocketConnectionError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolRequest {
|
interface ToolRequest {
|
||||||
method: string; // "execute_tool"
|
method: string // "execute_tool"
|
||||||
params?: {
|
params?: {
|
||||||
client_id?: string; // "desktop" | "claude-code"
|
client_id?: string // "desktop" | "claude-code"
|
||||||
tool?: string;
|
tool?: string
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolResponse {
|
interface ToolResponse {
|
||||||
result?: unknown;
|
result?: unknown
|
||||||
error?: string;
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
method: string;
|
method: string
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
type SocketMessage = ToolResponse | Notification;
|
type SocketMessage = ToolResponse | Notification
|
||||||
|
|
||||||
function isToolResponse(message: SocketMessage): message is ToolResponse {
|
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 {
|
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 {
|
class McpSocketClient {
|
||||||
private socket: Socket | null = null;
|
private socket: Socket | null = null
|
||||||
private connected = false;
|
private connected = false
|
||||||
private connecting = false;
|
private connecting = false
|
||||||
private responseCallback: ((response: ToolResponse) => void) | null = null;
|
private responseCallback: ((response: ToolResponse) => void) | null = null
|
||||||
private notificationHandler: ((notification: Notification) => void) | null =
|
private notificationHandler: ((notification: Notification) => void) | null =
|
||||||
null;
|
null
|
||||||
private responseBuffer = Buffer.alloc(0);
|
private responseBuffer = Buffer.alloc(0)
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0
|
||||||
private maxReconnectAttempts = 10;
|
private maxReconnectAttempts = 10
|
||||||
private reconnectDelay = 1000;
|
private reconnectDelay = 1000
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null
|
||||||
private context: ClaudeForChromeContext;
|
private context: ClaudeForChromeContext
|
||||||
// When true, disables automatic reconnection. Used by McpSocketPool which
|
// When true, disables automatic reconnection. Used by McpSocketPool which
|
||||||
// manages reconnection externally by rescanning available sockets.
|
// manages reconnection externally by rescanning available sockets.
|
||||||
public disableAutoReconnect = false;
|
public disableAutoReconnect = false
|
||||||
|
|
||||||
constructor(context: ClaudeForChromeContext) {
|
constructor(context: ClaudeForChromeContext) {
|
||||||
this.context = context;
|
this.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connect(): Promise<void> {
|
private async connect(): Promise<void> {
|
||||||
const { serverName, logger } = this.context;
|
const { serverName, logger } = this.context
|
||||||
|
|
||||||
if (this.connecting) {
|
if (this.connecting) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Already connecting, skipping duplicate attempt`,
|
`[${serverName}] Already connecting, skipping duplicate attempt`,
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closeSocket();
|
this.closeSocket()
|
||||||
this.connecting = true;
|
this.connecting = true
|
||||||
|
|
||||||
const socketPath =
|
const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath
|
||||||
this.context.getSocketPath?.() ?? this.context.socketPath;
|
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`)
|
||||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.validateSocketSecurity(socketPath);
|
await this.validateSocketSecurity(socketPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.connecting = false;
|
this.connecting = false
|
||||||
logger.info(`[${serverName}] Security validation failed:`, error);
|
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||||
// self-resolve. Only the error handler retries on transient errors.
|
// 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
|
// Timeout the initial connection attempt - if socket file exists but native
|
||||||
// host is dead, the connect can hang indefinitely
|
// host is dead, the connect can hang indefinitely
|
||||||
const connectTimeout = setTimeout(() => {
|
const connectTimeout = setTimeout(() => {
|
||||||
if (!this.connected) {
|
if (!this.connected) {
|
||||||
logger.info(
|
logger.info(`[${serverName}] Connection attempt timed out after 5000ms`)
|
||||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
this.closeSocket()
|
||||||
);
|
this.scheduleReconnect()
|
||||||
this.closeSocket();
|
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000)
|
||||||
|
|
||||||
this.socket.on("connect", () => {
|
this.socket.on('connect', () => {
|
||||||
clearTimeout(connectTimeout);
|
clearTimeout(connectTimeout)
|
||||||
this.connected = true;
|
this.connected = true
|
||||||
this.connecting = false;
|
this.connecting = false
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0
|
||||||
logger.info(`[${serverName}] Successfully connected to bridge server`);
|
logger.info(`[${serverName}] Successfully connected to bridge server`)
|
||||||
});
|
})
|
||||||
|
|
||||||
this.socket.on("data", (data: Buffer) => {
|
this.socket.on('data', (data: Buffer) => {
|
||||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
this.responseBuffer = Buffer.concat([this.responseBuffer, data])
|
||||||
|
|
||||||
while (this.responseBuffer.length >= 4) {
|
while (this.responseBuffer.length >= 4) {
|
||||||
const length = this.responseBuffer.readUInt32LE(0);
|
const length = this.responseBuffer.readUInt32LE(0)
|
||||||
|
|
||||||
if (this.responseBuffer.length < 4 + length) {
|
if (this.responseBuffer.length < 4 + length) {
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageBytes = this.responseBuffer.slice(4, 4 + length);
|
const messageBytes = this.responseBuffer.slice(4, 4 + length)
|
||||||
this.responseBuffer = this.responseBuffer.slice(4 + length);
|
this.responseBuffer = this.responseBuffer.slice(4 + length)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(
|
const message = JSON.parse(
|
||||||
messageBytes.toString("utf-8"),
|
messageBytes.toString('utf-8'),
|
||||||
) as SocketMessage;
|
) as SocketMessage
|
||||||
|
|
||||||
if (isNotification(message)) {
|
if (isNotification(message)) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Received notification: ${message.method}`,
|
`[${serverName}] Received notification: ${message.method}`,
|
||||||
);
|
)
|
||||||
if (this.notificationHandler) {
|
if (this.notificationHandler) {
|
||||||
this.notificationHandler(message);
|
this.notificationHandler(message)
|
||||||
}
|
}
|
||||||
} else if (isToolResponse(message)) {
|
} else if (isToolResponse(message)) {
|
||||||
logger.info(`[${serverName}] Received tool response: ${message}`);
|
logger.info(`[${serverName}] Received tool response: ${message}`)
|
||||||
this.handleResponse(message);
|
this.handleResponse(message)
|
||||||
} else {
|
} else {
|
||||||
logger.info(`[${serverName}] Received unknown message: ${message}`);
|
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 }) => {
|
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||||
clearTimeout(connectTimeout);
|
clearTimeout(connectTimeout)
|
||||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error);
|
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||||
this.connected = false;
|
this.connected = false
|
||||||
this.connecting = false;
|
this.connecting = false
|
||||||
|
|
||||||
if (
|
if (
|
||||||
error.code &&
|
error.code &&
|
||||||
[
|
[
|
||||||
"ECONNREFUSED", // Native host not listening (stale socket)
|
'ECONNREFUSED', // Native host not listening (stale socket)
|
||||||
"ECONNRESET", // Connection reset by peer
|
'ECONNRESET', // Connection reset by peer
|
||||||
"EPIPE", // Broken pipe (native host died mid-write)
|
'EPIPE', // Broken pipe (native host died mid-write)
|
||||||
"ENOENT", // Socket file was deleted
|
'ENOENT', // Socket file was deleted
|
||||||
"EOPNOTSUPP", // Socket file exists but is not a valid socket
|
'EOPNOTSUPP', // Socket file exists but is not a valid socket
|
||||||
"ECONNABORTED", // Connection aborted
|
'ECONNABORTED', // Connection aborted
|
||||||
].includes(error.code)
|
].includes(error.code)
|
||||||
) {
|
) {
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
this.socket.on("close", () => {
|
this.socket.on('close', () => {
|
||||||
clearTimeout(connectTimeout);
|
clearTimeout(connectTimeout)
|
||||||
this.connected = false;
|
this.connected = false
|
||||||
this.connecting = false;
|
this.connecting = false
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect()
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
const { serverName, logger } = this.context;
|
const { serverName, logger } = this.context
|
||||||
|
|
||||||
if (this.disableAutoReconnect) {
|
if (this.disableAutoReconnect) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
|
logger.info(`[${serverName}] Reconnect already scheduled, skipping`)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++
|
||||||
|
|
||||||
// Give up after extended polling (~50 min). A new ensureConnected() call
|
// Give up after extended polling (~50 min). A new ensureConnected() call
|
||||||
// from a tool request will restart the cycle if needed.
|
// from a tool request will restart the cycle if needed.
|
||||||
const maxTotalAttempts = 100;
|
const maxTotalAttempts = 100
|
||||||
if (this.reconnectAttempts > maxTotalAttempts) {
|
if (this.reconnectAttempts > maxTotalAttempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
|
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
|
||||||
);
|
)
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
|
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
|
this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1),
|
||||||
30000,
|
30000,
|
||||||
);
|
)
|
||||||
|
|
||||||
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
|
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
|
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
|
||||||
this.reconnectAttempts
|
this.reconnectAttempts
|
||||||
})`,
|
})`,
|
||||||
);
|
)
|
||||||
} else if (this.reconnectAttempts % 10 === 0) {
|
} else if (this.reconnectAttempts % 10 === 0) {
|
||||||
// Log every 10th slow-poll attempt to avoid log spam
|
// Log every 10th slow-poll attempt to avoid log spam
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
|
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null
|
||||||
void this.connect();
|
void this.connect()
|
||||||
}, delay);
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleResponse(response: ToolResponse): void {
|
private handleResponse(response: ToolResponse): void {
|
||||||
if (this.responseCallback) {
|
if (this.responseCallback) {
|
||||||
const callback = this.responseCallback;
|
const callback = this.responseCallback
|
||||||
this.responseCallback = null;
|
this.responseCallback = null
|
||||||
callback(response);
|
callback(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setNotificationHandler(
|
public setNotificationHandler(
|
||||||
handler: (notification: Notification) => void,
|
handler: (notification: Notification) => void,
|
||||||
): void {
|
): void {
|
||||||
this.notificationHandler = handler;
|
this.notificationHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ensureConnected(): Promise<boolean> {
|
public async ensureConnected(): Promise<boolean> {
|
||||||
const { serverName } = this.context;
|
const { serverName } = this.context
|
||||||
|
|
||||||
if (this.connected && this.socket) {
|
if (this.connected && this.socket) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.socket && !this.connecting) {
|
if (!this.socket && !this.connecting) {
|
||||||
await this.connect();
|
await this.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for connection with timeout
|
// Wait for connection with timeout
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let checkTimeoutId: NodeJS.Timeout | null = null;
|
let checkTimeoutId: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (checkTimeoutId) {
|
if (checkTimeoutId) {
|
||||||
clearTimeout(checkTimeoutId);
|
clearTimeout(checkTimeoutId)
|
||||||
}
|
}
|
||||||
reject(
|
reject(
|
||||||
new SocketConnectionError(
|
new SocketConnectionError(
|
||||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
}, 5000);
|
}, 5000)
|
||||||
|
|
||||||
const checkConnection = () => {
|
const checkConnection = () => {
|
||||||
if (this.connected) {
|
if (this.connected) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout)
|
||||||
resolve(true);
|
resolve(true)
|
||||||
} else {
|
} else {
|
||||||
checkTimeoutId = setTimeout(checkConnection, 500);
|
checkTimeoutId = setTimeout(checkConnection, 500)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
checkConnection();
|
checkConnection()
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendRequest(
|
private async sendRequest(
|
||||||
request: ToolRequest,
|
request: ToolRequest,
|
||||||
timeoutMs = 30000,
|
timeoutMs = 30000,
|
||||||
): Promise<ToolResponse> {
|
): Promise<ToolResponse> {
|
||||||
const { serverName } = this.context;
|
const { serverName } = this.context
|
||||||
|
|
||||||
if (!this.socket) {
|
if (!this.socket) {
|
||||||
throw new SocketConnectionError(
|
throw new SocketConnectionError(
|
||||||
`[${serverName}] Cannot send request: not connected`,
|
`[${serverName}] Cannot send request: not connected`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = this.socket;
|
const socket = this.socket
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
this.responseCallback = null;
|
this.responseCallback = null
|
||||||
reject(
|
reject(
|
||||||
new SocketConnectionError(
|
new SocketConnectionError(
|
||||||
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
|
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
}, timeoutMs);
|
}, timeoutMs)
|
||||||
|
|
||||||
this.responseCallback = (response) => {
|
this.responseCallback = response => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout)
|
||||||
resolve(response);
|
resolve(response)
|
||||||
};
|
}
|
||||||
|
|
||||||
const requestJson = JSON.stringify(request);
|
const requestJson = JSON.stringify(request)
|
||||||
const requestBytes = Buffer.from(requestJson, "utf-8");
|
const requestBytes = Buffer.from(requestJson, 'utf-8')
|
||||||
|
|
||||||
const lengthPrefix = Buffer.allocUnsafe(4);
|
const lengthPrefix = Buffer.allocUnsafe(4)
|
||||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
|
lengthPrefix.writeUInt32LE(requestBytes.length, 0)
|
||||||
|
|
||||||
const message = Buffer.concat([lengthPrefix, requestBytes]);
|
const message = Buffer.concat([lengthPrefix, requestBytes])
|
||||||
socket.write(message);
|
socket.write(message)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public async callTool(
|
public async callTool(
|
||||||
@@ -330,15 +327,15 @@ class McpSocketClient {
|
|||||||
_permissionOverrides?: PermissionOverrides,
|
_permissionOverrides?: PermissionOverrides,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const request: ToolRequest = {
|
const request: ToolRequest = {
|
||||||
method: "execute_tool",
|
method: 'execute_tool',
|
||||||
params: {
|
params: {
|
||||||
client_id: this.context.clientTypeId,
|
client_id: this.context.clientTypeId,
|
||||||
tool: name,
|
tool: name,
|
||||||
args,
|
args,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
return this.sendRequestWithRetry(request);
|
return this.sendRequestWithRetry(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,23 +346,23 @@ class McpSocketClient {
|
|||||||
* and retry once.
|
* and retry once.
|
||||||
*/
|
*/
|
||||||
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
|
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
|
||||||
const { serverName, logger } = this.context;
|
const { serverName, logger } = this.context
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.sendRequest(request);
|
return await this.sendRequest(request)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof SocketConnectionError)) {
|
if (!(error instanceof SocketConnectionError)) {
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
|
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
|
||||||
);
|
)
|
||||||
|
|
||||||
this.closeSocket();
|
this.closeSocket()
|
||||||
await this.ensureConnected();
|
await this.ensureConnected()
|
||||||
|
|
||||||
return await this.sendRequest(request);
|
return await this.sendRequest(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,109 +374,109 @@ class McpSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isConnected(): boolean {
|
public isConnected(): boolean {
|
||||||
return this.connected;
|
return this.connected
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeSocket(): void {
|
private closeSocket(): void {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.removeAllListeners();
|
this.socket.removeAllListeners()
|
||||||
this.socket.end();
|
this.socket.end()
|
||||||
this.socket.destroy();
|
this.socket.destroy()
|
||||||
this.socket = null;
|
this.socket = null
|
||||||
}
|
}
|
||||||
this.connected = false;
|
this.connected = false
|
||||||
this.connecting = false;
|
this.connecting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanup(): void {
|
private cleanup(): void {
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer)
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closeSocket();
|
this.closeSocket()
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0
|
||||||
this.responseBuffer = Buffer.alloc(0);
|
this.responseBuffer = Buffer.alloc(0)
|
||||||
this.responseCallback = null;
|
this.responseCallback = null
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
this.cleanup();
|
this.cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateSocketSecurity(socketPath: string): Promise<void> {
|
private async validateSocketSecurity(socketPath: string): Promise<void> {
|
||||||
const { serverName, logger } = this.context;
|
const { serverName, logger } = this.context
|
||||||
if (platform() === "win32") {
|
if (platform() === 'win32') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Validate the parent directory permissions if it's the socket directory
|
// Validate the parent directory permissions if it's the socket directory
|
||||||
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
|
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
|
||||||
const dirPath = dirname(socketPath);
|
const dirPath = dirname(socketPath)
|
||||||
const dirBasename = dirPath.split("/").pop() || "";
|
const dirBasename = dirPath.split('/').pop() || ''
|
||||||
const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-");
|
const isSocketDir = dirBasename.startsWith('claude-mcp-browser-bridge-')
|
||||||
if (isSocketDir) {
|
if (isSocketDir) {
|
||||||
try {
|
try {
|
||||||
const dirStats = await fsPromises.stat(dirPath);
|
const dirStats = await fsPromises.stat(dirPath)
|
||||||
if (dirStats.isDirectory()) {
|
if (dirStats.isDirectory()) {
|
||||||
const dirMode = dirStats.mode & 0o777;
|
const dirMode = dirStats.mode & 0o777
|
||||||
if (dirMode !== 0o700) {
|
if (dirMode !== 0o700) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
|
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
|
||||||
8,
|
8,
|
||||||
)} (expected 0700). Directory may have been tampered with.`,
|
)} (expected 0700). Directory may have been tampered with.`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
const currentUid = process.getuid?.();
|
const currentUid = process.getuid?.()
|
||||||
if (currentUid !== undefined && dirStats.uid !== currentUid) {
|
if (currentUid !== undefined && dirStats.uid !== currentUid) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
|
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
|
||||||
`Potential security risk.`,
|
`Potential security risk.`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (dirError) {
|
} catch (dirError) {
|
||||||
if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") {
|
if ((dirError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
throw dirError;
|
throw dirError
|
||||||
}
|
}
|
||||||
// Directory doesn't exist yet - native host will create it
|
// 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()) {
|
if (!stats.isSocket()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
|
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = stats.mode & 0o777;
|
const mode = stats.mode & 0o777
|
||||||
if (mode !== 0o600) {
|
if (mode !== 0o600) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${serverName}] Insecure socket permissions: ${mode.toString(
|
`[${serverName}] Insecure socket permissions: ${mode.toString(
|
||||||
8,
|
8,
|
||||||
)} (expected 0600). Socket may have been tampered with.`,
|
)} (expected 0600). Socket may have been tampered with.`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUid = process.getuid?.();
|
const currentUid = process.getuid?.()
|
||||||
if (currentUid !== undefined && stats.uid !== currentUid) {
|
if (currentUid !== undefined && stats.uid !== currentUid) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
|
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
|
||||||
`Potential security risk.`,
|
`Potential security risk.`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[${serverName}] Socket security validation passed`);
|
logger.info(`[${serverName}] Socket security validation passed`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] Socket not found, will be created by server`,
|
`[${serverName}] Socket not found, will be created by server`,
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,7 +484,7 @@ class McpSocketClient {
|
|||||||
export function createMcpSocketClient(
|
export function createMcpSocketClient(
|
||||||
context: ClaudeForChromeContext,
|
context: ClaudeForChromeContext,
|
||||||
): McpSocketClient {
|
): McpSocketClient {
|
||||||
return new McpSocketClient(context);
|
return new McpSocketClient(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { McpSocketClient };
|
export type { McpSocketClient }
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
createMcpSocketClient,
|
createMcpSocketClient,
|
||||||
SocketConnectionError,
|
SocketConnectionError,
|
||||||
} from "./mcpSocketClient.js";
|
} from './mcpSocketClient.js'
|
||||||
import type { McpSocketClient } from "./mcpSocketClient.js";
|
import type { McpSocketClient } from './mcpSocketClient.js'
|
||||||
import type {
|
import type {
|
||||||
ClaudeForChromeContext,
|
ClaudeForChromeContext,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
PermissionOverrides,
|
PermissionOverrides,
|
||||||
} from "./types.js";
|
} from './types.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
|
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
|
||||||
@@ -18,26 +18,29 @@ import type {
|
|||||||
* built from tabs_context_mcp responses.
|
* built from tabs_context_mcp responses.
|
||||||
*/
|
*/
|
||||||
export class McpSocketPool {
|
export class McpSocketPool {
|
||||||
private clients: Map<string, McpSocketClient> = new Map();
|
private clients: Map<string, McpSocketClient> = new Map()
|
||||||
private tabRoutes: Map<number, string> = new Map();
|
private tabRoutes: Map<number, string> = new Map()
|
||||||
private context: ClaudeForChromeContext;
|
private context: ClaudeForChromeContext
|
||||||
private notificationHandler:
|
private notificationHandler:
|
||||||
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
|
| ((notification: {
|
||||||
| null = null;
|
method: string
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}) => void)
|
||||||
|
| null = null
|
||||||
|
|
||||||
constructor(context: ClaudeForChromeContext) {
|
constructor(context: ClaudeForChromeContext) {
|
||||||
this.context = context;
|
this.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
public setNotificationHandler(
|
public setNotificationHandler(
|
||||||
handler: (notification: {
|
handler: (notification: {
|
||||||
method: string;
|
method: string
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>
|
||||||
}) => void,
|
}) => void,
|
||||||
): void {
|
): void {
|
||||||
this.notificationHandler = handler;
|
this.notificationHandler = handler
|
||||||
for (const client of this.clients.values()) {
|
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.
|
* Discover available sockets and ensure at least one is connected.
|
||||||
*/
|
*/
|
||||||
public async ensureConnected(): Promise<boolean> {
|
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
|
// Try to connect any disconnected clients
|
||||||
const connectPromises: Promise<boolean>[] = [];
|
const connectPromises: Promise<boolean>[] = []
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
if (!client.isConnected()) {
|
if (!client.isConnected()) {
|
||||||
connectPromises.push(
|
connectPromises.push(client.ensureConnected().catch(() => false))
|
||||||
client.ensureConnected().catch(() => false),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectPromises.length > 0) {
|
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) {
|
if (connectedCount === 0) {
|
||||||
logger.info(`[${serverName}] No connected sockets in pool`);
|
logger.info(`[${serverName}] No connected sockets in pool`)
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
|
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`)
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,57 +83,57 @@ export class McpSocketPool {
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
_permissionOverrides?: PermissionOverrides,
|
_permissionOverrides?: PermissionOverrides,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
if (name === "tabs_context_mcp") {
|
if (name === 'tabs_context_mcp') {
|
||||||
return this.callTabsContext(args);
|
return this.callTabsContext(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route by tabId if present
|
// Route by tabId if present
|
||||||
const tabId = args.tabId as number | undefined;
|
const tabId = args.tabId as number | undefined
|
||||||
if (tabId !== undefined) {
|
if (tabId !== undefined) {
|
||||||
const socketPath = this.tabRoutes.get(tabId);
|
const socketPath = this.tabRoutes.get(tabId)
|
||||||
if (socketPath) {
|
if (socketPath) {
|
||||||
const client = this.clients.get(socketPath);
|
const client = this.clients.get(socketPath)
|
||||||
if (client?.isConnected()) {
|
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
|
// Tab route not found or client disconnected — fall through to any connected
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: use first connected client
|
// Fallback: use first connected client
|
||||||
const connected = this.getConnectedClients();
|
const connected = this.getConnectedClients()
|
||||||
if (connected.length === 0) {
|
if (connected.length === 0) {
|
||||||
throw new SocketConnectionError(
|
throw new SocketConnectionError(
|
||||||
`[${this.context.serverName}] No connected sockets available`,
|
`[${this.context.serverName}] No connected sockets available`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
return connected[0]!.callTool(name, args);
|
return connected[0]!.callTool(name, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setPermissionMode(
|
public async setPermissionMode(
|
||||||
mode: PermissionMode,
|
mode: PermissionMode,
|
||||||
allowedDomains?: string[],
|
allowedDomains?: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const connected = this.getConnectedClients();
|
const connected = this.getConnectedClients()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
|
connected.map(client => client.setPermissionMode(mode, allowedDomains)),
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public isConnected(): boolean {
|
public isConnected(): boolean {
|
||||||
return this.getConnectedClients().length > 0;
|
return this.getConnectedClients().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
client.disconnect();
|
client.disconnect()
|
||||||
}
|
}
|
||||||
this.clients.clear();
|
this.clients.clear()
|
||||||
this.tabRoutes.clear();
|
this.tabRoutes.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private getConnectedClients(): McpSocketClient[] {
|
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(
|
private async callTabsContext(
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const { logger, serverName } = this.context;
|
const { logger, serverName } = this.context
|
||||||
const connected = this.getConnectedClients();
|
const connected = this.getConnectedClients()
|
||||||
|
|
||||||
if (connected.length === 0) {
|
if (connected.length === 0) {
|
||||||
throw new SocketConnectionError(
|
throw new SocketConnectionError(
|
||||||
`[${serverName}] No connected sockets available`,
|
`[${serverName}] No connected sockets available`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If only one client, skip merging overhead
|
// If only one client, skip merging overhead
|
||||||
if (connected.length === 1) {
|
if (connected.length === 1) {
|
||||||
const result = await connected[0]!.callTool("tabs_context_mcp", args);
|
const result = await connected[0]!.callTool('tabs_context_mcp', args)
|
||||||
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!));
|
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!))
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query all connected clients in parallel
|
// Query all connected clients in parallel
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
connected.map(async (client) => {
|
connected.map(async client => {
|
||||||
const result = await client.callTool("tabs_context_mcp", args);
|
const result = await client.callTool('tabs_context_mcp', args)
|
||||||
const socketPath = this.getSocketPathForClient(client);
|
const socketPath = this.getSocketPathForClient(client)
|
||||||
return { result, socketPath };
|
return { result, socketPath }
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
// Merge tab results
|
// Merge tab results
|
||||||
const mergedTabs: unknown[] = [];
|
const mergedTabs: unknown[] = []
|
||||||
this.tabRoutes.clear();
|
this.tabRoutes.clear()
|
||||||
|
|
||||||
for (const settledResult of results) {
|
for (const settledResult of results) {
|
||||||
if (settledResult.status !== "fulfilled") {
|
if (settledResult.status !== 'fulfilled') {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
|
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
|
||||||
);
|
)
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result, socketPath } = settledResult.value;
|
const { result, socketPath } = settledResult.value
|
||||||
this.updateTabRoutes(result, socketPath);
|
this.updateTabRoutes(result, socketPath)
|
||||||
|
|
||||||
const tabs = this.extractTabs(result);
|
const tabs = this.extractTabs(result)
|
||||||
if (tabs) {
|
if (tabs) {
|
||||||
mergedTabs.push(...tabs);
|
mergedTabs.push(...tabs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return merged result in the same format as the extension response
|
// Return merged result in the same format as the extension response
|
||||||
if (mergedTabs.length > 0) {
|
if (mergedTabs.length > 0) {
|
||||||
const tabListText = mergedTabs
|
const tabListText = mergedTabs
|
||||||
.map((t) => {
|
.map(t => {
|
||||||
const tab = t as { tabId: number; title: string; url: string };
|
const tab = t as { tabId: number; title: string; url: string }
|
||||||
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
|
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join('\n')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: {
|
result: {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
text: JSON.stringify({ availableTabs: mergedTabs }),
|
text: JSON.stringify({ availableTabs: mergedTabs }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return first successful result as-is
|
// Fallback: return first successful result as-is
|
||||||
for (const settledResult of results) {
|
for (const settledResult of results) {
|
||||||
if (settledResult.status === "fulfilled") {
|
if (settledResult.status === 'fulfilled') {
|
||||||
return settledResult.value.result;
|
return settledResult.value.result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new SocketConnectionError(
|
throw new SocketConnectionError(
|
||||||
`[${serverName}] All sockets failed for tabs_context_mcp`,
|
`[${serverName}] All sockets failed for tabs_context_mcp`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract tab objects from a tool response to update routing table.
|
* Extract tab objects from a tool response to update routing table.
|
||||||
*/
|
*/
|
||||||
private updateTabRoutes(result: unknown, socketPath: string): void {
|
private updateTabRoutes(result: unknown, socketPath: string): void {
|
||||||
const tabs = this.extractTabs(result);
|
const tabs = this.extractTabs(result)
|
||||||
if (!tabs) return;
|
if (!tabs) return
|
||||||
|
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
if (typeof tab === "object" && tab !== null && "tabId" in tab) {
|
if (typeof tab === 'object' && tab !== null && 'tabId' in tab) {
|
||||||
const tabId = (tab as { tabId: number }).tabId;
|
const tabId = (tab as { tabId: number }).tabId
|
||||||
this.tabRoutes.set(tabId, socketPath);
|
this.tabRoutes.set(tabId, socketPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTabs(result: unknown): unknown[] | null {
|
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\":...}" }] } }
|
// Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } }
|
||||||
const asResponse = result as {
|
const asResponse = result as {
|
||||||
result?: { content?: Array<{ type: string; text?: string }> };
|
result?: { content?: Array<{ type: string; text?: string }> }
|
||||||
};
|
}
|
||||||
const content = asResponse.result?.content;
|
const content = asResponse.result?.content
|
||||||
if (!content || !Array.isArray(content)) return null;
|
if (!content || !Array.isArray(content)) return null
|
||||||
|
|
||||||
for (const item of content) {
|
for (const item of content) {
|
||||||
if (item.type === "text" && item.text) {
|
if (item.type === 'text' && item.text) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(item.text);
|
const parsed = JSON.parse(item.text)
|
||||||
if (Array.isArray(parsed)) return parsed;
|
if (Array.isArray(parsed)) return parsed
|
||||||
// Handle { availableTabs: [...] } format
|
// Handle { availableTabs: [...] } format
|
||||||
if (parsed && Array.isArray(parsed.availableTabs)) {
|
if (parsed && Array.isArray(parsed.availableTabs)) {
|
||||||
return parsed.availableTabs;
|
return parsed.availableTabs
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not JSON, skip
|
// Not JSON, skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSocketPathForClient(client: McpSocketClient): string {
|
private getSocketPathForClient(client: McpSocketClient): string {
|
||||||
for (const [path, c] of this.clients.entries()) {
|
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.
|
* Scan for available sockets and create/remove clients as needed.
|
||||||
*/
|
*/
|
||||||
private refreshClients(): void {
|
private refreshClients(): void {
|
||||||
const socketPaths = this.getAvailableSocketPaths();
|
const socketPaths = this.getAvailableSocketPaths()
|
||||||
const { logger, serverName } = this.context;
|
const { logger, serverName } = this.context
|
||||||
|
|
||||||
// Add new clients for newly discovered sockets
|
// Add new clients for newly discovered sockets
|
||||||
for (const path of socketPaths) {
|
for (const path of socketPaths) {
|
||||||
if (!this.clients.has(path)) {
|
if (!this.clients.has(path)) {
|
||||||
logger.info(`[${serverName}] Adding socket to pool: ${path}`);
|
logger.info(`[${serverName}] Adding socket to pool: ${path}`)
|
||||||
const clientContext: ClaudeForChromeContext = {
|
const clientContext: ClaudeForChromeContext = {
|
||||||
...this.context,
|
...this.context,
|
||||||
socketPath: path,
|
socketPath: path,
|
||||||
getSocketPath: undefined,
|
getSocketPath: undefined,
|
||||||
getSocketPaths: 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
|
// Remove clients for sockets that no longer exist
|
||||||
for (const [path, client] of this.clients.entries()) {
|
for (const [path, client] of this.clients.entries()) {
|
||||||
if (!socketPaths.includes(path)) {
|
if (!socketPaths.includes(path)) {
|
||||||
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`);
|
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`)
|
||||||
client.disconnect();
|
client.disconnect()
|
||||||
this.clients.delete(path);
|
this.clients.delete(path)
|
||||||
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
|
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
|
||||||
if (socketPath === path) {
|
if (socketPath === path) {
|
||||||
this.tabRoutes.delete(tabId);
|
this.tabRoutes.delete(tabId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,12 +317,12 @@ export class McpSocketPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getAvailableSocketPaths(): string[] {
|
private getAvailableSocketPaths(): string[] {
|
||||||
return this.context.getSocketPaths?.() ?? [];
|
return this.context.getSocketPaths?.() ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMcpSocketPool(
|
export function createMcpSocketPool(
|
||||||
context: ClaudeForChromeContext,
|
context: ClaudeForChromeContext,
|
||||||
): McpSocketPool {
|
): 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 {
|
import type {
|
||||||
ClaudeForChromeContext,
|
ClaudeForChromeContext,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
PermissionOverrides,
|
PermissionOverrides,
|
||||||
SocketClient,
|
SocketClient,
|
||||||
} from "./types.js";
|
} from './types.js'
|
||||||
|
|
||||||
export const handleToolCall = async (
|
export const handleToolCall = async (
|
||||||
context: ClaudeForChromeContext,
|
context: ClaudeForChromeContext,
|
||||||
@@ -16,21 +16,21 @@ export const handleToolCall = async (
|
|||||||
permissionOverrides?: PermissionOverrides,
|
permissionOverrides?: PermissionOverrides,
|
||||||
): Promise<CallToolResult> => {
|
): Promise<CallToolResult> => {
|
||||||
// Handle permission mode changes locally (not forwarded to extension)
|
// Handle permission mode changes locally (not forwarded to extension)
|
||||||
if (name === "set_permission_mode") {
|
if (name === 'set_permission_mode') {
|
||||||
return handleSetPermissionMode(socketClient, args);
|
return handleSetPermissionMode(socketClient, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle switch_browser outside the normal tool call flow (manages its own connection)
|
// Handle switch_browser outside the normal tool call flow (manages its own connection)
|
||||||
if (name === "switch_browser") {
|
if (name === 'switch_browser') {
|
||||||
return handleSwitchBrowser(context, socketClient);
|
return handleSwitchBrowser(context, socketClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isConnected = await socketClient.ensureConnected();
|
const isConnected = await socketClient.ensureConnected()
|
||||||
|
|
||||||
context.logger.silly(
|
context.logger.silly(
|
||||||
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
|
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
|
||||||
);
|
)
|
||||||
|
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return await handleToolCallConnected(
|
return await handleToolCallConnected(
|
||||||
@@ -39,28 +39,28 @@ export const handleToolCall = async (
|
|||||||
name,
|
name,
|
||||||
args,
|
args,
|
||||||
permissionOverrides,
|
permissionOverrides,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleToolCallDisconnected(context);
|
return handleToolCallDisconnected(context)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
|
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||||
|
|
||||||
if (error instanceof SocketConnectionError) {
|
if (error instanceof SocketConnectionError) {
|
||||||
return handleToolCallDisconnected(context);
|
return handleToolCallDisconnected(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
|
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
async function handleToolCallConnected(
|
async function handleToolCallConnected(
|
||||||
context: ClaudeForChromeContext,
|
context: ClaudeForChromeContext,
|
||||||
@@ -69,119 +69,119 @@ async function handleToolCallConnected(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
permissionOverrides?: PermissionOverrides,
|
permissionOverrides?: PermissionOverrides,
|
||||||
): Promise<CallToolResult> {
|
): Promise<CallToolResult> {
|
||||||
const response = await socketClient.callTool(name, args, permissionOverrides);
|
const response = await socketClient.callTool(name, args, permissionOverrides)
|
||||||
|
|
||||||
context.logger.silly(
|
context.logger.silly(
|
||||||
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
|
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
|
||||||
);
|
)
|
||||||
|
|
||||||
if (response === null || response === undefined) {
|
if (response === null || response === undefined) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Tool execution completed" }],
|
content: [{ type: 'text', text: 'Tool execution completed' }],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response will have either result or error field
|
// Response will have either result or error field
|
||||||
const { result, error } = response as {
|
const { result, error } = response as {
|
||||||
result?: { content: unknown[] | string };
|
result?: { content: unknown[] | string }
|
||||||
error?: { content: unknown[] | string };
|
error?: { content: unknown[] | string }
|
||||||
};
|
}
|
||||||
|
|
||||||
// Determine which field has the content and whether it's an error
|
// Determine which field has the content and whether it's an error
|
||||||
const contentData = error || result;
|
const contentData = error || result
|
||||||
const isError = !!error;
|
const isError = !!error
|
||||||
|
|
||||||
if (!contentData) {
|
if (!contentData) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Tool execution completed" }],
|
content: [{ type: 'text', text: 'Tool execution completed' }],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError && isAuthenticationError(contentData.content)) {
|
if (isError && isAuthenticationError(contentData.content)) {
|
||||||
context.onAuthenticationError();
|
context.onAuthenticationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content } = contentData;
|
const { content } = contentData
|
||||||
|
|
||||||
if (content && Array.isArray(content)) {
|
if (content && Array.isArray(content)) {
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return {
|
return {
|
||||||
content: content.map((item: unknown) => {
|
content: content.map((item: unknown) => {
|
||||||
if (typeof item === "object" && item !== null && "type" in item) {
|
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||||
return item;
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: "text", text: String(item) };
|
return { type: 'text', text: String(item) }
|
||||||
}),
|
}),
|
||||||
isError: true,
|
isError: true,
|
||||||
} as CallToolResult;
|
} as CallToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertedContent = content.map((item: unknown) => {
|
const convertedContent = content.map((item: unknown) => {
|
||||||
if (
|
if (
|
||||||
typeof item === "object" &&
|
typeof item === 'object' &&
|
||||||
item !== null &&
|
item !== null &&
|
||||||
"type" in item &&
|
'type' in item &&
|
||||||
"source" in item
|
'source' in item
|
||||||
) {
|
) {
|
||||||
const typedItem = item;
|
const typedItem = item
|
||||||
if (
|
if (
|
||||||
typedItem.type === "image" &&
|
typedItem.type === 'image' &&
|
||||||
typeof typedItem.source === "object" &&
|
typeof typedItem.source === 'object' &&
|
||||||
typedItem.source !== null &&
|
typedItem.source !== null &&
|
||||||
"data" in typedItem.source
|
'data' in typedItem.source
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: 'image',
|
||||||
data: typedItem.source.data,
|
data: typedItem.source.data,
|
||||||
mimeType:
|
mimeType:
|
||||||
"media_type" in typedItem.source
|
'media_type' in typedItem.source
|
||||||
? typedItem.source.media_type || "image/png"
|
? typedItem.source.media_type || 'image/png'
|
||||||
: "image/png",
|
: 'image/png',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof item === "object" && item !== null && "type" in item) {
|
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||||
return item;
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: "text", text: String(item) };
|
return { type: 'text', text: String(item) }
|
||||||
});
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: convertedContent,
|
content: convertedContent,
|
||||||
isError,
|
isError,
|
||||||
} as CallToolResult;
|
} as CallToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle string content
|
// Handle string content
|
||||||
if (typeof content === "string") {
|
if (typeof content === 'string') {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: content }],
|
content: [{ type: 'text', text: content }],
|
||||||
isError,
|
isError,
|
||||||
} as CallToolResult;
|
} as CallToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for unexpected result format
|
// Fallback for unexpected result format
|
||||||
context.logger.warn(
|
context.logger.warn(
|
||||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||||
response,
|
response,
|
||||||
);
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(response) }],
|
content: [{ type: 'text', text: JSON.stringify(response) }],
|
||||||
isError,
|
isError,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToolCallDisconnected(
|
function handleToolCallDisconnected(
|
||||||
context: ClaudeForChromeContext,
|
context: ClaudeForChromeContext,
|
||||||
): CallToolResult {
|
): CallToolResult {
|
||||||
const text = context.onToolCallDisconnected();
|
const text = context.onToolCallDisconnected()
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: 'text', text }],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,28 +194,28 @@ async function handleSetPermissionMode(
|
|||||||
): Promise<CallToolResult> {
|
): Promise<CallToolResult> {
|
||||||
// Validate permission mode at runtime
|
// Validate permission mode at runtime
|
||||||
const validModes = [
|
const validModes = [
|
||||||
"ask",
|
'ask',
|
||||||
"skip_all_permission_checks",
|
'skip_all_permission_checks',
|
||||||
"follow_a_plan",
|
'follow_a_plan',
|
||||||
] as const;
|
] as const
|
||||||
const mode = args.mode as string | undefined;
|
const mode = args.mode as string | undefined
|
||||||
const permissionMode: PermissionMode =
|
const permissionMode: PermissionMode =
|
||||||
mode && validModes.includes(mode as PermissionMode)
|
mode && validModes.includes(mode as PermissionMode)
|
||||||
? (mode as PermissionMode)
|
? (mode as PermissionMode)
|
||||||
: "ask";
|
: 'ask'
|
||||||
|
|
||||||
if (socketClient.setPermissionMode) {
|
if (socketClient.setPermissionMode) {
|
||||||
await socketClient.setPermissionMode(
|
await socketClient.setPermissionMode(
|
||||||
permissionMode,
|
permissionMode,
|
||||||
args.allowed_domains as string[] | undefined,
|
args.allowed_domains as string[] | undefined,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
text: "Browser switching is only available with bridge connections.",
|
text: 'Browser switching is only available with bridge connections.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isConnected = await socketClient.ensureConnected();
|
const isConnected = await socketClient.ensureConnected()
|
||||||
if (!isConnected) {
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
text: "No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.",
|
text: 'No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: `Connected to browser "${result.name}".` },
|
{ type: 'text', text: `Connected to browser "${result.name}".` },
|
||||||
],
|
],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
text: "No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.",
|
text: 'No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -282,20 +282,20 @@ async function handleSwitchBrowser(
|
|||||||
function isAuthenticationError(content: unknown[] | string): boolean {
|
function isAuthenticationError(content: unknown[] | string): boolean {
|
||||||
const errorText = Array.isArray(content)
|
const errorText = Array.isArray(content)
|
||||||
? content
|
? content
|
||||||
.map((item) => {
|
.map(item => {
|
||||||
if (typeof item === "string") return item;
|
if (typeof item === 'string') return item
|
||||||
if (
|
if (
|
||||||
typeof item === "object" &&
|
typeof item === 'object' &&
|
||||||
item !== null &&
|
item !== null &&
|
||||||
"text" in item &&
|
'text' in item &&
|
||||||
typeof item.text === "string"
|
typeof item.text === 'string'
|
||||||
) {
|
) {
|
||||||
return item.text;
|
return item.text
|
||||||
}
|
}
|
||||||
return "";
|
return ''
|
||||||
})
|
})
|
||||||
.join(" ")
|
.join(' ')
|
||||||
: String(content);
|
: String(content)
|
||||||
|
|
||||||
return errorText.toLowerCase().includes("re-authenticated");
|
return errorText.toLowerCase().includes('re-authenticated')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
export interface Logger {
|
export interface Logger {
|
||||||
info: (message: string, ...args: unknown[]) => void;
|
info: (message: string, ...args: unknown[]) => void
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
error: (message: string, ...args: unknown[]) => void
|
||||||
warn: (message: string, ...args: unknown[]) => void;
|
warn: (message: string, ...args: unknown[]) => void
|
||||||
debug: (message: string, ...args: unknown[]) => void;
|
debug: (message: string, ...args: unknown[]) => void
|
||||||
silly: (message: string, ...args: unknown[]) => void;
|
silly: (message: string, ...args: unknown[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionMode =
|
export type PermissionMode =
|
||||||
| "ask"
|
| 'ask'
|
||||||
| "skip_all_permission_checks"
|
| 'skip_all_permission_checks'
|
||||||
| "follow_a_plan";
|
| 'follow_a_plan'
|
||||||
|
|
||||||
export interface BridgeConfig {
|
export interface BridgeConfig {
|
||||||
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
|
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
|
||||||
url: string;
|
url: string
|
||||||
/** Returns the user's account UUID for the connection path */
|
/** 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 */
|
/** 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) */
|
/** Optional dev user ID for local development (bypasses OAuth) */
|
||||||
devUserId?: string;
|
devUserId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Metadata about a connected Chrome extension instance. */
|
/** Metadata about a connected Chrome extension instance. */
|
||||||
export interface ChromeExtensionInfo {
|
export interface ChromeExtensionInfo {
|
||||||
deviceId: string;
|
deviceId: string
|
||||||
osPlatform?: string;
|
osPlatform?: string
|
||||||
connectedAt: number;
|
connectedAt: number
|
||||||
name?: string;
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeForChromeContext {
|
export interface ClaudeForChromeContext {
|
||||||
serverName: string;
|
serverName: string
|
||||||
logger: Logger;
|
logger: Logger
|
||||||
socketPath: string;
|
socketPath: string
|
||||||
// Optional dynamic resolver for socket path. When provided, called on each
|
// Optional dynamic resolver for socket path. When provided, called on each
|
||||||
// connection attempt to handle runtime conditions (e.g., TMPDIR mismatch).
|
// 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).
|
// 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.
|
// When provided, a socket pool connects to all sockets and routes by tab ID.
|
||||||
getSocketPaths?: () => string[];
|
getSocketPaths?: () => string[]
|
||||||
clientTypeId: string; // "desktop" | "claude-code"
|
clientTypeId: string // "desktop" | "claude-code"
|
||||||
onToolCallDisconnected: () => string;
|
onToolCallDisconnected: () => string
|
||||||
onAuthenticationError: () => void;
|
onAuthenticationError: () => void
|
||||||
isDisabled?: () => boolean;
|
isDisabled?: () => boolean
|
||||||
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
|
/** 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. */
|
/** 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 */
|
/** Optional callback to track telemetry events for bridge connections */
|
||||||
trackEvent?: <K extends string>(
|
trackEvent?: <K extends string>(
|
||||||
eventName: K,
|
eventName: K,
|
||||||
metadata: Record<string, unknown> | null,
|
metadata: Record<string, unknown> | null,
|
||||||
) => void;
|
) => void
|
||||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
/** 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. */
|
/** Returns the previously paired deviceId, if any. */
|
||||||
getPersistedDeviceId?: () => string | undefined;
|
getPersistedDeviceId?: () => string | undefined
|
||||||
/** Called when a remote extension is auto-selected (only option available). */
|
/** 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.
|
* via navigator.userAgentData.platform.
|
||||||
*/
|
*/
|
||||||
export function localPlatformLabel(): string {
|
export function localPlatformLabel(): string {
|
||||||
return process.platform === "darwin"
|
return process.platform === 'darwin'
|
||||||
? "macOS"
|
? 'macOS'
|
||||||
: process.platform === "win32"
|
: process.platform === 'win32'
|
||||||
? "Windows"
|
? 'Windows'
|
||||||
: "Linux";
|
: 'Linux'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Permission request forwarded from the extension to the desktop for user approval. */
|
/** Permission request forwarded from the extension to the desktop for user approval. */
|
||||||
export interface BridgePermissionRequest {
|
export interface BridgePermissionRequest {
|
||||||
/** Links to the pending tool_call */
|
/** Links to the pending tool_call */
|
||||||
toolUseId: string;
|
toolUseId: string
|
||||||
/** Unique ID for this permission request */
|
/** Unique ID for this permission request */
|
||||||
requestId: string;
|
requestId: string
|
||||||
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
|
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
|
||||||
toolType: string;
|
toolType: string
|
||||||
/** The URL/domain context */
|
/** The URL/domain context */
|
||||||
url: string;
|
url: string
|
||||||
/** Additional action data (click coordinates, text, etc.) */
|
/** Additional action data (click coordinates, text, etc.) */
|
||||||
actionData?: Record<string, unknown>;
|
actionData?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Desktop response to a bridge permission request. */
|
/** Desktop response to a bridge permission request. */
|
||||||
export interface BridgePermissionResponse {
|
export interface BridgePermissionResponse {
|
||||||
requestId: string;
|
requestId: string
|
||||||
allowed: boolean;
|
allowed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-call permission overrides, allowing each session to use its own permission state. */
|
/** Per-call permission overrides, allowing each session to use its own permission state. */
|
||||||
export interface PermissionOverrides {
|
export interface PermissionOverrides {
|
||||||
permissionMode: PermissionMode;
|
permissionMode: PermissionMode
|
||||||
allowedDomains?: string[];
|
allowedDomains?: string[]
|
||||||
/** Callback invoked when the extension requests user permission via the bridge. */
|
/** 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 */
|
/** Shared interface for McpSocketClient and McpSocketPool */
|
||||||
export interface SocketClient {
|
export interface SocketClient {
|
||||||
ensureConnected(): Promise<boolean>;
|
ensureConnected(): Promise<boolean>
|
||||||
callTool(
|
callTool(
|
||||||
name: string,
|
name: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
permissionOverrides?: PermissionOverrides,
|
permissionOverrides?: PermissionOverrides,
|
||||||
): Promise<unknown>;
|
): Promise<unknown>
|
||||||
isConnected(): boolean;
|
isConnected(): boolean
|
||||||
disconnect(): void;
|
disconnect(): void
|
||||||
setNotificationHandler(
|
setNotificationHandler(
|
||||||
handler: (notification: {
|
handler: (notification: {
|
||||||
method: string;
|
method: string
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>
|
||||||
}) => void,
|
}) => void,
|
||||||
): void;
|
): void
|
||||||
/** Set permission mode for the current session. Only effective on BridgeClient. */
|
/** Set permission mode for the current session. Only effective on BridgeClient. */
|
||||||
setPermissionMode?(
|
setPermissionMode?(
|
||||||
mode: PermissionMode,
|
mode: PermissionMode,
|
||||||
allowedDomains?: string[],
|
allowedDomains?: string[],
|
||||||
): Promise<void>;
|
): Promise<void>
|
||||||
/** Switch to a different browser. Only available on BridgeClient. */
|
/** Switch to a different browser. Only available on BridgeClient. */
|
||||||
switchBrowser?(): Promise<
|
switchBrowser?(): Promise<
|
||||||
| {
|
| {
|
||||||
deviceId: string;
|
deviceId: string
|
||||||
name: string;
|
name: string
|
||||||
}
|
}
|
||||||
| "no_other_browsers"
|
| 'no_other_browsers'
|
||||||
| null
|
| null
|
||||||
>;
|
>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ant/computer-use-input",
|
"name": "@ant/computer-use-input",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts"
|
"types": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,46 @@ import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
|||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
const KEY_MAP: Record<string, number> = {
|
const KEY_MAP: Record<string, number> = {
|
||||||
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
return: 36,
|
||||||
escape: 53, esc: 53,
|
enter: 36,
|
||||||
left: 123, right: 124, down: 125, up: 126,
|
tab: 48,
|
||||||
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
|
space: 49,
|
||||||
f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
|
delete: 51,
|
||||||
home: 115, end: 119, pageup: 116, pagedown: 121,
|
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> = {
|
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',
|
shift: 'shift down',
|
||||||
option: 'option down', alt: 'option down',
|
option: 'option down',
|
||||||
control: 'control down', ctrl: 'control down',
|
alt: 'option down',
|
||||||
|
control: 'control down',
|
||||||
|
ctrl: 'control down',
|
||||||
}
|
}
|
||||||
|
|
||||||
async function osascript(script: string): Promise<string> {
|
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> {
|
async function jxa(script: string): Promise<string> {
|
||||||
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
const { stdout } = await execFileAsync(
|
||||||
encoding: 'utf-8',
|
'osascript',
|
||||||
})
|
['-l', 'JavaScript', '-e', script],
|
||||||
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
},
|
||||||
|
)
|
||||||
return stdout.trim()
|
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});`
|
let script = `ObjC.import("CoreGraphics"); var p = $.CGPointMake(${x},${y}); var e = $.CGEventCreateMouseEvent(null, $.${eventType}, p, ${btn});`
|
||||||
if (clickState !== undefined) {
|
if (clickState !== undefined) {
|
||||||
script += ` $.CGEventSetIntegerValueField(e, $.kCGMouseEventClickState, ${clickState});`
|
script += ` $.CGEventSetIntegerValueField(e, $.kCGMouseEventClickState, ${clickState});`
|
||||||
@@ -61,11 +98,13 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
|||||||
if (keyCode !== undefined) {
|
if (keyCode !== undefined) {
|
||||||
await osascript(`tell application "System Events" to key code ${keyCode}`)
|
await osascript(`tell application "System Events" to key code ${keyCode}`)
|
||||||
} else {
|
} 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[] = []
|
const modifiers: string[] = []
|
||||||
let finalKey: string | null = null
|
let finalKey: string | null = null
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -78,23 +117,43 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
|||||||
const keyCode = KEY_MAP[lower]
|
const keyCode = KEY_MAP[lower]
|
||||||
const modStr = modifiers.length > 0 ? ` using {${modifiers.join(', ')}}` : ''
|
const modStr = modifiers.length > 0 ? ` using {${modifiers.join(', ')}}` : ''
|
||||||
if (keyCode !== undefined) {
|
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 {
|
} 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 () => {
|
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(',')
|
const [xStr, yStr] = result.split(',')
|
||||||
return { x: Math.round(Number(xStr)), y: Math.round(Number(yStr)) }
|
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 pos = await mouseLocation()
|
||||||
const btn = button === 'left' ? 0 : button === 'right' ? 1 : 2
|
const btn = button === 'left' ? 0 : button === 'right' ? 1 : 2
|
||||||
const downType = btn === 0 ? 'kCGEventLeftMouseDown' : btn === 1 ? 'kCGEventRightMouseDown' : 'kCGEventOtherMouseDown'
|
const downType =
|
||||||
const upType = btn === 0 ? 'kCGEventLeftMouseUp' : btn === 1 ? 'kCGEventRightMouseUp' : 'kCGEventOtherMouseUp'
|
btn === 0
|
||||||
|
? 'kCGEventLeftMouseDown'
|
||||||
|
: btn === 1
|
||||||
|
? 'kCGEventRightMouseDown'
|
||||||
|
: 'kCGEventOtherMouseDown'
|
||||||
|
const upType =
|
||||||
|
btn === 0
|
||||||
|
? 'kCGEventLeftMouseUp'
|
||||||
|
: btn === 1
|
||||||
|
? 'kCGEventRightMouseUp'
|
||||||
|
: 'kCGEventOtherMouseUp'
|
||||||
|
|
||||||
if (action === 'click') {
|
if (action === 'click') {
|
||||||
for (let i = 0; i < (count ?? 1); i++) {
|
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) => {
|
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||||
const script = direction === 'vertical'
|
amount,
|
||||||
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
direction,
|
||||||
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
) => {
|
||||||
|
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)
|
await jxa(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
export const typeText: InputBackend['typeText'] = async text => {
|
||||||
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||||
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
|
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||||
try {
|
try {
|
||||||
const output = execFileSync('osascript', ['-e', `
|
const output = execFileSync(
|
||||||
|
'osascript',
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`
|
||||||
tell application "System Events"
|
tell application "System Events"
|
||||||
set frontApp to first application process whose frontmost is true
|
set frontApp to first application process whose frontmost is true
|
||||||
set appName to name of frontApp
|
set appName to name of frontApp
|
||||||
set bundleId to bundle identifier of frontApp
|
set bundleId to bundle identifier of frontApp
|
||||||
return bundleId & "|" & appName
|
return bundleId & "|" & appName
|
||||||
end tell
|
end tell
|
||||||
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
|
`,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
|
||||||
|
).trim()
|
||||||
if (!output || !output.includes('|')) return null
|
if (!output || !output.includes('|')) return null
|
||||||
const [bundleId, appName] = output.split('|', 2)
|
const [bundleId, appName] = output.split('|', 2)
|
||||||
return { bundleId: bundleId!, appName: appName! }
|
return { bundleId: bundleId!, appName: appName! }
|
||||||
|
|||||||
@@ -32,23 +32,75 @@ async function runAsync(cmd: string[]): Promise<string> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const KEY_MAP: Record<string, string> = {
|
const KEY_MAP: Record<string, string> = {
|
||||||
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
|
return: 'Return',
|
||||||
backspace: 'BackSpace', delete: 'Delete', escape: 'Escape', esc: 'Escape',
|
enter: 'Return',
|
||||||
left: 'Left', up: 'Up', right: 'Right', down: 'Down',
|
tab: 'Tab',
|
||||||
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
space: 'space',
|
||||||
f1: 'F1', f2: 'F2', f3: 'F3', f4: 'F4', f5: 'F5', f6: 'F6',
|
backspace: 'BackSpace',
|
||||||
f7: 'F7', f8: 'F8', f9: 'F9', f10: 'F10', f11: 'F11', f12: 'F12',
|
delete: 'Delete',
|
||||||
shift: 'shift', lshift: 'shift', rshift: 'shift',
|
escape: 'Escape',
|
||||||
control: 'ctrl', ctrl: 'ctrl', lcontrol: 'ctrl', rcontrol: 'ctrl',
|
esc: 'Escape',
|
||||||
alt: 'alt', option: 'alt', lalt: 'alt', ralt: 'alt',
|
left: 'Left',
|
||||||
win: 'super', meta: 'super', command: 'super', cmd: 'super', super: 'super',
|
up: 'Up',
|
||||||
insert: 'Insert', printscreen: 'Print', pause: 'Pause',
|
right: 'Right',
|
||||||
numlock: 'Num_Lock', capslock: 'Caps_Lock', scrolllock: 'Scroll_Lock',
|
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([
|
const MODIFIER_KEYS = new Set([
|
||||||
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
|
'shift',
|
||||||
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
|
'lshift',
|
||||||
|
'rshift',
|
||||||
|
'control',
|
||||||
|
'ctrl',
|
||||||
|
'lcontrol',
|
||||||
|
'rcontrol',
|
||||||
|
'alt',
|
||||||
|
'option',
|
||||||
|
'lalt',
|
||||||
|
'ralt',
|
||||||
|
'win',
|
||||||
|
'meta',
|
||||||
|
'command',
|
||||||
|
'cmd',
|
||||||
|
'super',
|
||||||
])
|
])
|
||||||
|
|
||||||
function mapKey(name: string): string {
|
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) => {
|
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 () => {
|
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)
|
const btn = mouseButtonNum(button)
|
||||||
if (action === 'click') {
|
if (action === 'click') {
|
||||||
const n = count ?? 1
|
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
|
// xdotool click 4=scroll up, 5=scroll down, 6=scroll left, 7=scroll right
|
||||||
// Positive amount = down/right, negative = up/left
|
// Positive amount = down/right, negative = up/left
|
||||||
if (direction === 'vertical') {
|
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
|
// xdotool key accepts "modifier+modifier+key" format
|
||||||
const modifiers: string[] = []
|
const modifiers: string[] = []
|
||||||
let finalKey: string | null = null
|
let finalKey: string | null = null
|
||||||
@@ -139,7 +204,7 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
|||||||
run(['xdotool', 'key', combo])
|
run(['xdotool', 'key', combo])
|
||||||
}
|
}
|
||||||
|
|
||||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
export const typeText: InputBackend['typeText'] = async text => {
|
||||||
run(['xdotool', 'type', '--delay', '12', text])
|
run(['xdotool', 'type', '--delay', '12', text])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,16 +222,23 @@ export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
|||||||
let exePath = ''
|
let exePath = ''
|
||||||
try {
|
try {
|
||||||
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
// Read the process name from /proc/comm
|
// Read the process name from /proc/comm
|
||||||
let appName = ''
|
let appName = ''
|
||||||
try {
|
try {
|
||||||
appName = run(['cat', `/proc/${pid}/comm`])
|
appName = run(['cat', `/proc/${pid}/comm`])
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
if (!exePath && !appName) return null
|
if (!exePath && !appName) return null
|
||||||
return { bundleId: exePath || `/proc/${pid}/exe`, appName: appName || 'unknown' }
|
return {
|
||||||
|
bundleId: exePath || `/proc/${pid}/exe`,
|
||||||
|
appName: appName || 'unknown',
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,43 +92,112 @@ public class CuWin32 {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const VK_MAP: Record<string, number> = {
|
const VK_MAP: Record<string, number> = {
|
||||||
return: 0x0D, enter: 0x0D, tab: 0x09, space: 0x20,
|
return: 0x0d,
|
||||||
backspace: 0x08, delete: 0x2E, escape: 0x1B, esc: 0x1B,
|
enter: 0x0d,
|
||||||
left: 0x25, up: 0x26, right: 0x27, down: 0x28,
|
tab: 0x09,
|
||||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
space: 0x20,
|
||||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
backspace: 0x08,
|
||||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7A, f12: 0x7B,
|
delete: 0x2e,
|
||||||
shift: 0xA0, lshift: 0xA0, rshift: 0xA1,
|
escape: 0x1b,
|
||||||
control: 0xA2, ctrl: 0xA2, lcontrol: 0xA2, rcontrol: 0xA3,
|
esc: 0x1b,
|
||||||
alt: 0xA4, option: 0xA4, lalt: 0xA4, ralt: 0xA5,
|
left: 0x25,
|
||||||
win: 0x5B, meta: 0x5B, command: 0x5B, cmd: 0x5B, super: 0x5B,
|
up: 0x26,
|
||||||
insert: 0x2D, printscreen: 0x2C, pause: 0x13,
|
right: 0x27,
|
||||||
numlock: 0x90, capslock: 0x14, scrolllock: 0x91,
|
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
|
// Implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
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 () => {
|
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(',')
|
const [xStr, yStr] = out.split(',')
|
||||||
return { x: Number(xStr), y: Number(yStr) }
|
return { x: Number(xStr), y: Number(yStr) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||||
const downFlag = button === 'left' ? 'MOUSEEVENTF_LEFTDOWN'
|
button,
|
||||||
: button === 'right' ? 'MOUSEEVENTF_RIGHTDOWN'
|
action,
|
||||||
: 'MOUSEEVENTF_MIDDLEDOWN'
|
count,
|
||||||
const upFlag = button === 'left' ? 'MOUSEEVENTF_LEFTUP'
|
) => {
|
||||||
: button === 'right' ? 'MOUSEEVENTF_RIGHTUP'
|
const downFlag =
|
||||||
: 'MOUSEEVENTF_MIDDLEUP'
|
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') {
|
if (action === 'click') {
|
||||||
const n = count ?? 1
|
const n = count ?? 1
|
||||||
@@ -136,17 +205,29 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
|||||||
for (let i = 0; i < n; i++) {
|
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; `
|
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') {
|
} 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 {
|
} 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) => {
|
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||||
const flag = direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
|
amount,
|
||||||
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`)
|
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) => {
|
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 vk = VK_MAP[lower]
|
||||||
const flags = action === 'release' ? '2' : '0'
|
const flags = action === 'release' ? '2' : '0'
|
||||||
if (vk !== undefined) {
|
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) {
|
} else if (keyName.length === 1) {
|
||||||
// Single character — use VkKeyScan to resolve
|
// Single character — use VkKeyScan to resolve
|
||||||
const charCode = keyName.charCodeAt(0)
|
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[] = []
|
const modifiers: number[] = []
|
||||||
let finalKey: string | null = null
|
let finalKey: string | null = null
|
||||||
|
|
||||||
@@ -196,9 +281,11 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
|||||||
ps(script)
|
ps(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
export const typeText: InputBackend['typeText'] = async text => {
|
||||||
const escaped = text.replace(/'/g, "''")
|
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'] = () => {
|
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||||
|
|||||||
@@ -15,8 +15,15 @@ export interface InputBackend {
|
|||||||
key(key: string, action: 'press' | 'release'): Promise<void>
|
key(key: string, action: 'press' | 'release'): Promise<void>
|
||||||
keys(parts: string[]): Promise<void>
|
keys(parts: string[]): Promise<void>
|
||||||
mouseLocation(): Promise<{ x: number; y: number }>
|
mouseLocation(): Promise<{ x: number; y: number }>
|
||||||
mouseButton(button: 'left' | 'right' | 'middle', action: 'click' | 'press' | 'release', count?: number): Promise<void>
|
mouseButton(
|
||||||
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
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>
|
typeText(text: string): Promise<void>
|
||||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||||
}
|
}
|
||||||
@@ -60,5 +67,7 @@ export class ComputerUseInputAPI {
|
|||||||
declare isSupported: true
|
declare isSupported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComputerUseInputUnsupported { isSupported: false }
|
interface ComputerUseInputUnsupported {
|
||||||
|
isSupported: false
|
||||||
|
}
|
||||||
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported
|
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export interface FrontmostAppInfo {
|
export interface FrontmostAppInfo {
|
||||||
bundleId: string // macOS: bundle ID, Windows: exe path
|
bundleId: string // macOS: bundle ID, Windows: exe path
|
||||||
appName: string
|
appName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ export interface InputBackend {
|
|||||||
action: 'click' | 'press' | 'release',
|
action: 'click' | 'press' | 'release',
|
||||||
count?: number,
|
count?: number,
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
mouseScroll(
|
||||||
|
amount: number,
|
||||||
|
direction: 'vertical' | 'horizontal',
|
||||||
|
): Promise<void>
|
||||||
typeText(text: string): Promise<void>
|
typeText(text: string): Promise<void>
|
||||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@ant/computer-use-mcp",
|
"name": "@ant/computer-use-mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./sentinelApps": "./src/sentinelApps.ts",
|
"./sentinelApps": "./src/sentinelApps.ts",
|
||||||
"./types": "./src/types.ts"
|
"./types": "./src/types.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
* duplicated as a string literal below rather than imported.
|
* 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
|
* 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(
|
export function categoryToTier(
|
||||||
category: DeniedCategory | null,
|
category: DeniedCategory | null,
|
||||||
): "read" | "click" | "full" {
|
): 'read' | 'click' | 'full' {
|
||||||
if (category === "browser" || category === "trading") return "read";
|
if (category === 'browser' || category === 'trading') return 'read'
|
||||||
if (category === "terminal") return "click";
|
if (category === 'terminal') return 'click'
|
||||||
return "full";
|
return 'full'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Bundle-ID deny sets (macOS) ─────────────────────────────────────────
|
// ─── Bundle-ID deny sets (macOS) ─────────────────────────────────────────
|
||||||
|
|
||||||
const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||||
// Apple
|
// Apple
|
||||||
"com.apple.Safari",
|
'com.apple.Safari',
|
||||||
"com.apple.SafariTechnologyPreview",
|
'com.apple.SafariTechnologyPreview',
|
||||||
// Google
|
// Google
|
||||||
"com.google.Chrome",
|
'com.google.Chrome',
|
||||||
"com.google.Chrome.beta",
|
'com.google.Chrome.beta',
|
||||||
"com.google.Chrome.dev",
|
'com.google.Chrome.dev',
|
||||||
"com.google.Chrome.canary",
|
'com.google.Chrome.canary',
|
||||||
// Microsoft
|
// Microsoft
|
||||||
"com.microsoft.edgemac",
|
'com.microsoft.edgemac',
|
||||||
"com.microsoft.edgemac.Beta",
|
'com.microsoft.edgemac.Beta',
|
||||||
"com.microsoft.edgemac.Dev",
|
'com.microsoft.edgemac.Dev',
|
||||||
"com.microsoft.edgemac.Canary",
|
'com.microsoft.edgemac.Canary',
|
||||||
// Mozilla
|
// Mozilla
|
||||||
"org.mozilla.firefox",
|
'org.mozilla.firefox',
|
||||||
"org.mozilla.firefoxdeveloperedition",
|
'org.mozilla.firefoxdeveloperedition',
|
||||||
"org.mozilla.nightly",
|
'org.mozilla.nightly',
|
||||||
// Chromium-based
|
// Chromium-based
|
||||||
"org.chromium.Chromium",
|
'org.chromium.Chromium',
|
||||||
"com.brave.Browser",
|
'com.brave.Browser',
|
||||||
"com.brave.Browser.beta",
|
'com.brave.Browser.beta',
|
||||||
"com.brave.Browser.nightly",
|
'com.brave.Browser.nightly',
|
||||||
"com.operasoftware.Opera",
|
'com.operasoftware.Opera',
|
||||||
"com.operasoftware.OperaGX",
|
'com.operasoftware.OperaGX',
|
||||||
"com.operasoftware.OperaDeveloper",
|
'com.operasoftware.OperaDeveloper',
|
||||||
"com.vivaldi.Vivaldi",
|
'com.vivaldi.Vivaldi',
|
||||||
// The Browser Company
|
// The Browser Company
|
||||||
"company.thebrowser.Browser", // Arc
|
'company.thebrowser.Browser', // Arc
|
||||||
"company.thebrowser.dia", // Dia (agentic)
|
'company.thebrowser.dia', // Dia (agentic)
|
||||||
// Privacy-focused
|
// Privacy-focused
|
||||||
"org.torproject.torbrowser",
|
'org.torproject.torbrowser',
|
||||||
"com.duckduckgo.macos.browser",
|
'com.duckduckgo.macos.browser',
|
||||||
"ru.yandex.desktop.yandex-browser",
|
'ru.yandex.desktop.yandex-browser',
|
||||||
// Agentic / AI browsers — newer entrants with LLM integrations
|
// Agentic / AI browsers — newer entrants with LLM integrations
|
||||||
"ai.perplexity.comet",
|
'ai.perplexity.comet',
|
||||||
"com.sigmaos.sigmaos.macos", // SigmaOS
|
'com.sigmaos.sigmaos.macos', // SigmaOS
|
||||||
// Webkit-based misc
|
// Webkit-based misc
|
||||||
"com.kagi.kagimacOS", // Orion
|
'com.kagi.kagimacOS', // Orion
|
||||||
]);
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminals + IDEs with integrated terminals. Supersets
|
* 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([
|
const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||||
// Dedicated terminals
|
// Dedicated terminals
|
||||||
"com.apple.Terminal",
|
'com.apple.Terminal',
|
||||||
"com.googlecode.iterm2",
|
'com.googlecode.iterm2',
|
||||||
"dev.warp.Warp-Stable",
|
'dev.warp.Warp-Stable',
|
||||||
"dev.warp.Warp-Beta",
|
'dev.warp.Warp-Beta',
|
||||||
"com.github.wez.wezterm",
|
'com.github.wez.wezterm',
|
||||||
"org.alacritty",
|
'org.alacritty',
|
||||||
"io.alacritty", // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
|
'io.alacritty', // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
|
||||||
"net.kovidgoyal.kitty",
|
'net.kovidgoyal.kitty',
|
||||||
"co.zeit.hyper",
|
'co.zeit.hyper',
|
||||||
"com.mitchellh.ghostty",
|
'com.mitchellh.ghostty',
|
||||||
"org.tabby",
|
'org.tabby',
|
||||||
"com.termius-dmg.mac", // Termius
|
'com.termius-dmg.mac', // Termius
|
||||||
// IDEs with integrated terminals — we can't distinguish "type in the
|
// IDEs with integrated terminals — we can't distinguish "type in the
|
||||||
// editor" from "type in the integrated terminal" via screenshot+click.
|
// editor" from "type in the integrated terminal" via screenshot+click.
|
||||||
// VS Code family
|
// VS Code family
|
||||||
"com.microsoft.VSCode",
|
'com.microsoft.VSCode',
|
||||||
"com.microsoft.VSCodeInsiders",
|
'com.microsoft.VSCodeInsiders',
|
||||||
"com.vscodium", // VSCodium
|
'com.vscodium', // VSCodium
|
||||||
"com.todesktop.230313mzl4w4u92", // Cursor
|
'com.todesktop.230313mzl4w4u92', // Cursor
|
||||||
"com.exafunction.windsurf", // Windsurf / Codeium
|
'com.exafunction.windsurf', // Windsurf / Codeium
|
||||||
"dev.zed.Zed",
|
'dev.zed.Zed',
|
||||||
"dev.zed.Zed-Preview",
|
'dev.zed.Zed-Preview',
|
||||||
// JetBrains family (all have integrated terminals)
|
// JetBrains family (all have integrated terminals)
|
||||||
"com.jetbrains.intellij",
|
'com.jetbrains.intellij',
|
||||||
"com.jetbrains.intellij.ce",
|
'com.jetbrains.intellij.ce',
|
||||||
"com.jetbrains.pycharm",
|
'com.jetbrains.pycharm',
|
||||||
"com.jetbrains.pycharm.ce",
|
'com.jetbrains.pycharm.ce',
|
||||||
"com.jetbrains.WebStorm",
|
'com.jetbrains.WebStorm',
|
||||||
"com.jetbrains.CLion",
|
'com.jetbrains.CLion',
|
||||||
"com.jetbrains.goland",
|
'com.jetbrains.goland',
|
||||||
"com.jetbrains.rubymine",
|
'com.jetbrains.rubymine',
|
||||||
"com.jetbrains.PhpStorm",
|
'com.jetbrains.PhpStorm',
|
||||||
"com.jetbrains.datagrip",
|
'com.jetbrains.datagrip',
|
||||||
"com.jetbrains.rider",
|
'com.jetbrains.rider',
|
||||||
"com.jetbrains.AppCode",
|
'com.jetbrains.AppCode',
|
||||||
"com.jetbrains.rustrover",
|
'com.jetbrains.rustrover',
|
||||||
"com.jetbrains.fleet",
|
'com.jetbrains.fleet',
|
||||||
"com.google.android.studio", // Android Studio (JetBrains-based)
|
'com.google.android.studio', // Android Studio (JetBrains-based)
|
||||||
// Other IDEs
|
// Other IDEs
|
||||||
"com.axosoft.gitkraken", // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
|
'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.4',
|
||||||
"com.sublimetext.3",
|
'com.sublimetext.3',
|
||||||
"org.vim.MacVim",
|
'org.vim.MacVim',
|
||||||
"com.neovim.neovim",
|
'com.neovim.neovim',
|
||||||
"org.gnu.Emacs",
|
'org.gnu.Emacs',
|
||||||
// Xcode's previous carve-out (full tier for Interface Builder / simulator)
|
// Xcode's previous carve-out (full tier for Interface Builder / simulator)
|
||||||
// was reversed — at tier "click" IB and simulator taps still work (both are
|
// was reversed — at tier "click" IB and simulator taps still work (both are
|
||||||
// plain clicks) while the integrated terminal is blocked from keyboard input.
|
// plain clicks) while the integrated terminal is blocked from keyboard input.
|
||||||
"com.apple.dt.Xcode",
|
'com.apple.dt.Xcode',
|
||||||
"org.eclipse.platform.ide",
|
'org.eclipse.platform.ide',
|
||||||
"org.netbeans.ide",
|
'org.netbeans.ide',
|
||||||
"com.microsoft.visual-studio", // Visual Studio for Mac
|
'com.microsoft.visual-studio', // Visual Studio for Mac
|
||||||
// AppleScript/automation execution surfaces — same threat as terminals:
|
// AppleScript/automation execution surfaces — same threat as terminals:
|
||||||
// type(script) → key("cmd+r") runs arbitrary code. Added after #28011
|
// type(script) → key("cmd+r") runs arbitrary code. Added after #28011
|
||||||
// removed the osascript MCP server, making CU the only tool-call route
|
// removed the osascript MCP server, making CU the only tool-call route
|
||||||
// to AppleScript.
|
// to AppleScript.
|
||||||
"com.apple.ScriptEditor2",
|
'com.apple.ScriptEditor2',
|
||||||
"com.apple.Automator",
|
'com.apple.Automator',
|
||||||
"com.apple.shortcuts",
|
'com.apple.shortcuts',
|
||||||
]);
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
|
* 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([
|
const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||||
// Verified via Homebrew quit/zap stanzas + mdls + electron-builder source.
|
// Verified via Homebrew quit/zap stanzas + mdls + electron-builder source.
|
||||||
// Trading
|
// Trading
|
||||||
"com.webull.desktop.v1", // Webull (direct download, Qt)
|
'com.webull.desktop.v1', // Webull (direct download, Qt)
|
||||||
"com.webull.trade.mac.v1", // Webull (Mac App Store)
|
'com.webull.trade.mac.v1', // Webull (Mac App Store)
|
||||||
"com.tastytrade.desktop",
|
'com.tastytrade.desktop',
|
||||||
"com.tradingview.tradingviewapp.desktop",
|
'com.tradingview.tradingviewapp.desktop',
|
||||||
"com.fidelity.activetrader", // Fidelity Trader+ (new)
|
'com.fidelity.activetrader', // Fidelity Trader+ (new)
|
||||||
"com.fmr.activetrader", // Fidelity Active Trader Pro (legacy)
|
'com.fmr.activetrader', // Fidelity Active Trader Pro (legacy)
|
||||||
// Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is
|
// Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is
|
||||||
// authoritative for this exact value but install4j IDs can drift across
|
// authoritative for this exact value but install4j IDs can drift across
|
||||||
// major versions — name-substring "trader workstation" is the fallback.
|
// major versions — name-substring "trader workstation" is the fallback.
|
||||||
"com.install4j.5889-6375-8446-2021",
|
'com.install4j.5889-6375-8446-2021',
|
||||||
// Crypto
|
// Crypto
|
||||||
"com.binance.BinanceDesktop",
|
'com.binance.BinanceDesktop',
|
||||||
"com.electron.exodus",
|
'com.electron.exodus',
|
||||||
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
|
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
|
||||||
// org.pythonmac.unspecified.<AppName>. Confirmed in spesmilo/electrum
|
// org.pythonmac.unspecified.<AppName>. Confirmed in spesmilo/electrum
|
||||||
// source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork.
|
// source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork.
|
||||||
"org.pythonmac.unspecified.Electrum",
|
'org.pythonmac.unspecified.Electrum',
|
||||||
"com.ledger.live",
|
'com.ledger.live',
|
||||||
"io.trezor.TrezorSuite",
|
'io.trezor.TrezorSuite',
|
||||||
// No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation,
|
// No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation,
|
||||||
// Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim
|
// Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim
|
||||||
// install4j ID drifts per-install — substring safer.
|
// install4j ID drifts per-install — substring safer.
|
||||||
]);
|
])
|
||||||
|
|
||||||
// ─── Policy-deny (not a tier — cannot be granted at all) ─────────────────
|
// ─── 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([
|
const POLICY_DENIED_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||||
// Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew.
|
// Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew.
|
||||||
// Apple built-ins
|
// Apple built-ins
|
||||||
"com.apple.TV",
|
'com.apple.TV',
|
||||||
"com.apple.Music",
|
'com.apple.Music',
|
||||||
"com.apple.iBooksX",
|
'com.apple.iBooksX',
|
||||||
"com.apple.podcasts",
|
'com.apple.podcasts',
|
||||||
// Music
|
// Music
|
||||||
"com.spotify.client",
|
'com.spotify.client',
|
||||||
"com.amazon.music",
|
'com.amazon.music',
|
||||||
"com.tidal.desktop",
|
'com.tidal.desktop',
|
||||||
"com.deezer.deezer-desktop",
|
'com.deezer.deezer-desktop',
|
||||||
"com.pandora.desktop",
|
'com.pandora.desktop',
|
||||||
"com.electron.pocket-casts", // direct-download Electron wrapper
|
'com.electron.pocket-casts', // direct-download Electron wrapper
|
||||||
"au.com.shiftyjelly.PocketCasts", // Mac App Store
|
'au.com.shiftyjelly.PocketCasts', // Mac App Store
|
||||||
// Video
|
// Video
|
||||||
"tv.plex.desktop",
|
'tv.plex.desktop',
|
||||||
"tv.plex.htpc",
|
'tv.plex.htpc',
|
||||||
"tv.plex.plexamp",
|
'tv.plex.plexamp',
|
||||||
"com.amazon.aiv.AIVApp", // Prime Video (iOS-on-Apple-Silicon)
|
'com.amazon.aiv.AIVApp', // Prime Video (iOS-on-Apple-Silicon)
|
||||||
// Ebooks
|
// Ebooks
|
||||||
"net.kovidgoyal.calibre",
|
'net.kovidgoyal.calibre',
|
||||||
"com.amazon.Kindle", // legacy desktop, discontinued
|
'com.amazon.Kindle', // legacy desktop, discontinued
|
||||||
"com.amazon.Lassen", // current Mac App Store (iOS-on-Mac)
|
'com.amazon.Lassen', // current Mac App Store (iOS-on-Mac)
|
||||||
"com.kobo.desktop.Kobo",
|
'com.kobo.desktop.Kobo',
|
||||||
// No native macOS app (name-substring only): Netflix, Disney+, Hulu,
|
// No native macOS app (name-substring only): Netflix, Disney+, Hulu,
|
||||||
// HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu,
|
// HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu,
|
||||||
// Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac.
|
// Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac.
|
||||||
]);
|
])
|
||||||
|
|
||||||
const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [
|
const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [
|
||||||
// Video streaming
|
// Video streaming
|
||||||
"netflix",
|
'netflix',
|
||||||
"disney+",
|
'disney+',
|
||||||
"hulu",
|
'hulu',
|
||||||
"prime video",
|
'prime video',
|
||||||
"apple tv",
|
'apple tv',
|
||||||
"peacock",
|
'peacock',
|
||||||
"paramount+",
|
'paramount+',
|
||||||
// "plex" is too generic — would match "Perplexity". Covered by
|
// "plex" is too generic — would match "Perplexity". Covered by
|
||||||
// tv.plex.* bundle IDs on macOS.
|
// tv.plex.* bundle IDs on macOS.
|
||||||
"tubi",
|
'tubi',
|
||||||
"crunchyroll",
|
'crunchyroll',
|
||||||
"vudu",
|
'vudu',
|
||||||
// E-readers / audiobooks
|
// E-readers / audiobooks
|
||||||
"kindle",
|
'kindle',
|
||||||
"apple books",
|
'apple books',
|
||||||
"kobo",
|
'kobo',
|
||||||
"play books",
|
'play books',
|
||||||
"calibre",
|
'calibre',
|
||||||
"libby",
|
'libby',
|
||||||
"readium",
|
'readium',
|
||||||
"audible",
|
'audible',
|
||||||
"libro.fm",
|
'libro.fm',
|
||||||
"speechify",
|
'speechify',
|
||||||
// Music
|
// Music
|
||||||
"spotify",
|
'spotify',
|
||||||
"apple music",
|
'apple music',
|
||||||
"amazon music",
|
'amazon music',
|
||||||
"youtube music",
|
'youtube music',
|
||||||
"tidal",
|
'tidal',
|
||||||
"deezer",
|
'deezer',
|
||||||
"pandora",
|
'pandora',
|
||||||
"pocket casts",
|
'pocket casts',
|
||||||
// Publisher / social apps (from the same blocklist tab)
|
// Publisher / social apps (from the same blocklist tab)
|
||||||
"naver",
|
'naver',
|
||||||
"reddit",
|
'reddit',
|
||||||
"sony music",
|
'sony music',
|
||||||
"vegas pro",
|
'vegas pro',
|
||||||
"pitchfork",
|
'pitchfork',
|
||||||
"economist",
|
'economist',
|
||||||
"nytimes",
|
'nytimes',
|
||||||
// Skipped (too generic for substring matching — need bundle ID):
|
// Skipped (too generic for substring matching — need bundle ID):
|
||||||
// HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired
|
// HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired
|
||||||
];
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings
|
* Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings
|
||||||
@@ -298,19 +298,19 @@ export function isPolicyDenied(
|
|||||||
bundleId: string | undefined,
|
bundleId: string | undefined,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true;
|
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true
|
||||||
const lower = displayName.toLowerCase();
|
const lower = displayName.toLowerCase()
|
||||||
for (const sub of POLICY_DENIED_NAME_SUBSTRINGS) {
|
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 {
|
export function getDeniedCategory(bundleId: string): DeniedCategory | null {
|
||||||
if (BROWSER_BUNDLE_IDS.has(bundleId)) return "browser";
|
if (BROWSER_BUNDLE_IDS.has(bundleId)) return 'browser'
|
||||||
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return "terminal";
|
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return 'terminal'
|
||||||
if (TRADING_BUNDLE_IDS.has(bundleId)) return "trading";
|
if (TRADING_BUNDLE_IDS.has(bundleId)) return 'trading'
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Display-name fallback (cross-platform) ──────────────────────────────
|
// ─── 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).
|
* first match, but groupings are by category for readability).
|
||||||
*/
|
*/
|
||||||
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
|
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
|
||||||
"safari",
|
'safari',
|
||||||
"chrome",
|
'chrome',
|
||||||
"firefox",
|
'firefox',
|
||||||
"microsoft edge",
|
'microsoft edge',
|
||||||
"brave",
|
'brave',
|
||||||
"opera",
|
'opera',
|
||||||
"vivaldi",
|
'vivaldi',
|
||||||
"chromium",
|
'chromium',
|
||||||
// Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for
|
// Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for
|
||||||
// substring matching (false-positives: "Arcade", "Diagram"). Covered by
|
// substring matching (false-positives: "Arcade", "Diagram"). Covered by
|
||||||
// bundle ID on macOS. The "... browser" entries below catch natural-language
|
// bundle ID on macOS. The "... browser" entries below catch natural-language
|
||||||
// phrasings ("the arc browser") but NOT the canonical short name.
|
// phrasings ("the arc browser") but NOT the canonical short name.
|
||||||
"arc browser",
|
'arc browser',
|
||||||
"tor browser",
|
'tor browser',
|
||||||
"duckduckgo",
|
'duckduckgo',
|
||||||
"yandex",
|
'yandex',
|
||||||
"orion browser",
|
'orion browser',
|
||||||
// Agentic / AI browsers
|
// 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
|
// but leaving for now; "comet" in an app name is rare
|
||||||
"sigmaos",
|
'sigmaos',
|
||||||
"dia browser",
|
'dia browser',
|
||||||
];
|
]
|
||||||
|
|
||||||
const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [
|
const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [
|
||||||
// macOS / cross-platform terminals
|
// macOS / cross-platform terminals
|
||||||
"terminal", // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
|
'terminal', // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
|
||||||
"iterm",
|
'iterm',
|
||||||
"wezterm",
|
'wezterm',
|
||||||
"alacritty",
|
'alacritty',
|
||||||
"kitty",
|
'kitty',
|
||||||
"ghostty",
|
'ghostty',
|
||||||
"tabby",
|
'tabby',
|
||||||
"termius",
|
'termius',
|
||||||
// AppleScript runners — see bundle-ID comment above. "shortcuts" is too
|
// AppleScript runners — see bundle-ID comment above. "shortcuts" is too
|
||||||
// generic for substring matching (many apps have "shortcuts" in the name);
|
// generic for substring matching (many apps have "shortcuts" in the name);
|
||||||
// covered by bundle ID only, like warp/hyper.
|
// covered by bundle ID only, like warp/hyper.
|
||||||
"script editor",
|
'script editor',
|
||||||
"automator",
|
'automator',
|
||||||
// NOTE: "warp" and "hyper" are too generic for substring matching —
|
// NOTE: "warp" and "hyper" are too generic for substring matching —
|
||||||
// they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID
|
// they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID
|
||||||
// (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name
|
// (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name
|
||||||
// matching can be added when Windows CU ships.
|
// matching can be added when Windows CU ships.
|
||||||
// Windows shells (activate when the darwin gate lifts)
|
// Windows shells (activate when the darwin gate lifts)
|
||||||
"powershell",
|
'powershell',
|
||||||
"cmd.exe",
|
'cmd.exe',
|
||||||
"command prompt",
|
'command prompt',
|
||||||
"git bash",
|
'git bash',
|
||||||
"conemu",
|
'conemu',
|
||||||
"cmder",
|
'cmder',
|
||||||
// IDEs (VS Code family)
|
// IDEs (VS Code family)
|
||||||
"visual studio code",
|
'visual studio code',
|
||||||
"visual studio", // catches VS for Mac + Windows
|
'visual studio', // catches VS for Mac + Windows
|
||||||
"vscode",
|
'vscode',
|
||||||
"vs code",
|
'vs code',
|
||||||
"vscodium",
|
'vscodium',
|
||||||
"cursor", // Cursor IDE — "cursor" is generic but IDE is the only common app
|
'cursor', // Cursor IDE — "cursor" is generic but IDE is the only common app
|
||||||
"windsurf",
|
'windsurf',
|
||||||
// Zed: display name is just "Zed" — too short for substring matching
|
// Zed: display name is just "Zed" — too short for substring matching
|
||||||
// (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS.
|
// (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS.
|
||||||
// IDEs (JetBrains family)
|
// IDEs (JetBrains family)
|
||||||
"intellij",
|
'intellij',
|
||||||
"pycharm",
|
'pycharm',
|
||||||
"webstorm",
|
'webstorm',
|
||||||
"clion",
|
'clion',
|
||||||
"goland",
|
'goland',
|
||||||
"rubymine",
|
'rubymine',
|
||||||
"phpstorm",
|
'phpstorm',
|
||||||
"datagrip",
|
'datagrip',
|
||||||
"rider",
|
'rider',
|
||||||
"appcode",
|
'appcode',
|
||||||
"rustrover",
|
'rustrover',
|
||||||
"fleet",
|
'fleet',
|
||||||
"android studio",
|
'android studio',
|
||||||
// Other IDEs
|
// Other IDEs
|
||||||
"sublime text",
|
'sublime text',
|
||||||
"macvim",
|
'macvim',
|
||||||
"neovim",
|
'neovim',
|
||||||
"emacs",
|
'emacs',
|
||||||
"xcode",
|
'xcode',
|
||||||
"eclipse",
|
'eclipse',
|
||||||
"netbeans",
|
'netbeans',
|
||||||
];
|
]
|
||||||
|
|
||||||
const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
||||||
// Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx
|
// Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx
|
||||||
// ("Read Only" tab). Name-substring safe for proper nouns below; generic
|
// ("Read Only" tab). Name-substring safe for proper nouns below; generic
|
||||||
// names (IG, Delta, HTX) are skipped and need bundle-ID matching once
|
// names (IG, Delta, HTX) are skipped and need bundle-ID matching once
|
||||||
// verified.
|
// verified.
|
||||||
"bloomberg",
|
'bloomberg',
|
||||||
"ameritrade",
|
'ameritrade',
|
||||||
"thinkorswim",
|
'thinkorswim',
|
||||||
"schwab",
|
'schwab',
|
||||||
"fidelity",
|
'fidelity',
|
||||||
"e*trade",
|
'e*trade',
|
||||||
"interactive brokers",
|
'interactive brokers',
|
||||||
"trader workstation", // Interactive Brokers TWS
|
'trader workstation', // Interactive Brokers TWS
|
||||||
"tradestation",
|
'tradestation',
|
||||||
"webull",
|
'webull',
|
||||||
"robinhood",
|
'robinhood',
|
||||||
"tastytrade",
|
'tastytrade',
|
||||||
"ninjatrader",
|
'ninjatrader',
|
||||||
"tradingview",
|
'tradingview',
|
||||||
"moomoo",
|
'moomoo',
|
||||||
"tradezero",
|
'tradezero',
|
||||||
"prorealtime",
|
'prorealtime',
|
||||||
"plus500",
|
'plus500',
|
||||||
"saxotrader",
|
'saxotrader',
|
||||||
"oanda",
|
'oanda',
|
||||||
"metatrader",
|
'metatrader',
|
||||||
"forex.com",
|
'forex.com',
|
||||||
"avaoptions",
|
'avaoptions',
|
||||||
"ctrader",
|
'ctrader',
|
||||||
"jforex",
|
'jforex',
|
||||||
"iq option",
|
'iq option',
|
||||||
"olymp trade",
|
'olymp trade',
|
||||||
"binomo",
|
'binomo',
|
||||||
"pocket option",
|
'pocket option',
|
||||||
"raceoption",
|
'raceoption',
|
||||||
"expertoption",
|
'expertoption',
|
||||||
"quotex",
|
'quotex',
|
||||||
"naga",
|
'naga',
|
||||||
"morgan stanley",
|
'morgan stanley',
|
||||||
"ubs neo",
|
'ubs neo',
|
||||||
"eikon", // Thomson Reuters / LSEG Workspace
|
'eikon', // Thomson Reuters / LSEG Workspace
|
||||||
// Crypto — exchanges, wallets, portfolio trackers
|
// Crypto — exchanges, wallets, portfolio trackers
|
||||||
"coinbase",
|
'coinbase',
|
||||||
"kraken",
|
'kraken',
|
||||||
"binance",
|
'binance',
|
||||||
"okx",
|
'okx',
|
||||||
"bybit",
|
'bybit',
|
||||||
// "gate.io" is too generic — the ".io" TLD suffix is common in app names
|
// "gate.io" is too generic — the ".io" TLD suffix is common in app names
|
||||||
// (e.g., "Draw.io"). Needs bundle-ID matching once verified.
|
// (e.g., "Draw.io"). Needs bundle-ID matching once verified.
|
||||||
"phemex",
|
'phemex',
|
||||||
"stormgain",
|
'stormgain',
|
||||||
"crypto.com",
|
'crypto.com',
|
||||||
// "exodus" is too generic — it's a common noun and would match unrelated
|
// "exodus" is too generic — it's a common noun and would match unrelated
|
||||||
// apps/games. Needs bundle-ID matching once verified.
|
// apps/games. Needs bundle-ID matching once verified.
|
||||||
"electrum",
|
'electrum',
|
||||||
"ledger live",
|
'ledger live',
|
||||||
"trezor",
|
'trezor',
|
||||||
"guarda",
|
'guarda',
|
||||||
"atomic wallet",
|
'atomic wallet',
|
||||||
"bitpay",
|
'bitpay',
|
||||||
"bisq",
|
'bisq',
|
||||||
"koinly",
|
'koinly',
|
||||||
"cointracker",
|
'cointracker',
|
||||||
"blockfi",
|
'blockfi',
|
||||||
"stripe cli",
|
'stripe cli',
|
||||||
// Crypto games / metaverse (same trade-execution risk model)
|
// Crypto games / metaverse (same trade-execution risk model)
|
||||||
"decentraland",
|
'decentraland',
|
||||||
"axie infinity",
|
'axie infinity',
|
||||||
"gods unchained",
|
'gods unchained',
|
||||||
];
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display-name substring match. Called when bundle-ID resolution returned
|
* Display-name substring match. Called when bundle-ID resolution returned
|
||||||
@@ -491,20 +491,20 @@ const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
|||||||
export function getDeniedCategoryByDisplayName(
|
export function getDeniedCategoryByDisplayName(
|
||||||
name: string,
|
name: string,
|
||||||
): DeniedCategory | null {
|
): DeniedCategory | null {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase()
|
||||||
// Trading first — proper-noun-only set, most specific. "Bloomberg Terminal"
|
// Trading first — proper-noun-only set, most specific. "Bloomberg Terminal"
|
||||||
// contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS
|
// contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS
|
||||||
// ran first.
|
// ran first.
|
||||||
for (const sub of TRADING_NAME_SUBSTRINGS) {
|
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) {
|
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) {
|
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,
|
displayName: string,
|
||||||
): DeniedCategory | null {
|
): DeniedCategory | null {
|
||||||
if (bundleId) {
|
if (bundleId) {
|
||||||
const byId = getDeniedCategory(bundleId);
|
const byId = getDeniedCategory(bundleId)
|
||||||
if (byId) return byId;
|
if (byId) return byId
|
||||||
}
|
}
|
||||||
return getDeniedCategoryByDisplayName(displayName);
|
return getDeniedCategoryByDisplayName(displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -537,8 +537,8 @@ export function getDeniedCategoryForApp(
|
|||||||
export function getDefaultTierForApp(
|
export function getDefaultTierForApp(
|
||||||
bundleId: string | undefined,
|
bundleId: string | undefined,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
): "read" | "click" | "full" {
|
): 'read' | 'click' | 'full' {
|
||||||
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
|
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _test = {
|
export const _test = {
|
||||||
@@ -550,4 +550,4 @@ export const _test = {
|
|||||||
TERMINAL_NAME_SUBSTRINGS,
|
TERMINAL_NAME_SUBSTRINGS,
|
||||||
TRADING_NAME_SUBSTRINGS,
|
TRADING_NAME_SUBSTRINGS,
|
||||||
POLICY_DENIED_NAME_SUBSTRINGS,
|
POLICY_DENIED_NAME_SUBSTRINGS,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -116,9 +116,17 @@ export interface ComputerExecutor {
|
|||||||
|
|
||||||
// ── Window management (Windows only, optional) ──────────────────────────
|
// ── Window management (Windows only, optional) ──────────────────────────
|
||||||
/** Perform a window management action on the bound window. Win32 API only — no global shortcuts. */
|
/** 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 */
|
/** 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) ────────────────────
|
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
|
||||||
/** Open terminal and launch an agent CLI */
|
/** Open terminal and launch an agent CLI */
|
||||||
@@ -129,17 +137,32 @@ export interface ComputerExecutor {
|
|||||||
workingDirectory?: string
|
workingDirectory?: string
|
||||||
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
|
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
|
||||||
/** Bind to a window by hwnd/title/pid. Returns bound window info or 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 */
|
/** Unbind from the current window */
|
||||||
unbindFromWindow?(): Promise<void>
|
unbindFromWindow?(): Promise<void>
|
||||||
/** Cheap binding-state check for window-targeted routing decisions. */
|
/** Cheap binding-state check for window-targeted routing decisions. */
|
||||||
hasBoundWindow?(): Promise<boolean>
|
hasBoundWindow?(): Promise<boolean>
|
||||||
/** Get current binding status */
|
/** 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 */
|
/** 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 */
|
/** 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 */
|
/** Virtual keyboard — send keys/text/combos to bound window only */
|
||||||
virtualKeyboard?(opts: {
|
virtualKeyboard?(opts: {
|
||||||
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
|
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
|
||||||
@@ -149,12 +172,26 @@ export interface ComputerExecutor {
|
|||||||
}): Promise<boolean>
|
}): Promise<boolean>
|
||||||
/** Virtual mouse — click/move/drag on bound window only */
|
/** Virtual mouse — click/move/drag on bound window only */
|
||||||
virtualMouse?(opts: {
|
virtualMouse?(opts: {
|
||||||
action: 'click' | 'double_click' | 'right_click' | 'move' | 'drag' | 'down' | 'up'
|
action:
|
||||||
x: number; y: number
|
| 'click'
|
||||||
startX?: number; startY?: number
|
| 'double_click'
|
||||||
|
| 'right_click'
|
||||||
|
| 'move'
|
||||||
|
| 'drag'
|
||||||
|
| 'down'
|
||||||
|
| 'up'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
startX?: number
|
||||||
|
startY?: number
|
||||||
}): Promise<boolean>
|
}): Promise<boolean>
|
||||||
/** Mouse wheel scroll at client coordinates (works on Excel, browsers, modern UI) */
|
/** 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) */
|
/** Activate the bound window (foreground + click to focus) */
|
||||||
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
|
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
|
||||||
/** Handle a terminal prompt (yes/no/select/type + enter) */
|
/** Handle a terminal prompt (yes/no/select/type + enter) */
|
||||||
@@ -165,7 +202,14 @@ export interface ComputerExecutor {
|
|||||||
text?: string
|
text?: string
|
||||||
}): Promise<boolean>
|
}): Promise<boolean>
|
||||||
/** Click an element by name/role/automationId via UI Automation */
|
/** 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 */
|
/** 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 {
|
export interface ResizeParams {
|
||||||
pxPerToken: number;
|
pxPerToken: number
|
||||||
maxTargetPx: number;
|
maxTargetPx: number
|
||||||
maxTargetTokens: number;
|
maxTargetTokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,11 +27,11 @@ export const API_RESIZE_PARAMS: ResizeParams = {
|
|||||||
pxPerToken: 28,
|
pxPerToken: 28,
|
||||||
maxTargetPx: 1568,
|
maxTargetPx: 1568,
|
||||||
maxTargetTokens: 1568,
|
maxTargetTokens: 1568,
|
||||||
};
|
}
|
||||||
|
|
||||||
/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */
|
/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */
|
||||||
export function nTokensForPx(px: number, pxPerToken: number): number {
|
export function nTokensForPx(px: number, pxPerToken: number): number {
|
||||||
return Math.floor((px - 1) / pxPerToken) + 1;
|
return Math.floor((px - 1) / pxPerToken) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function nTokensForImg(
|
function nTokensForImg(
|
||||||
@@ -39,7 +39,7 @@ function nTokensForImg(
|
|||||||
height: number,
|
height: number,
|
||||||
pxPerToken: number,
|
pxPerToken: number,
|
||||||
): 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,
|
height: number,
|
||||||
params: ResizeParams,
|
params: ResizeParams,
|
||||||
): [number, number] {
|
): [number, number] {
|
||||||
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
|
const { pxPerToken, maxTargetPx, maxTargetTokens } = params
|
||||||
|
|
||||||
if (
|
if (
|
||||||
width <= maxTargetPx &&
|
width <= maxTargetPx &&
|
||||||
height <= maxTargetPx &&
|
height <= maxTargetPx &&
|
||||||
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
|
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
|
||||||
) {
|
) {
|
||||||
return [width, height];
|
return [width, height]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize to landscape for the search; transpose result back.
|
// Normalize to landscape for the search; transpose result back.
|
||||||
if (height > width) {
|
if (height > width) {
|
||||||
const [w, h] = targetImageSize(height, width, params);
|
const [w, h] = targetImageSize(height, width, params)
|
||||||
return [h, w];
|
return [h, w]
|
||||||
}
|
}
|
||||||
|
|
||||||
const aspectRatio = width / height;
|
const aspectRatio = width / height
|
||||||
|
|
||||||
// Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is
|
// Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is
|
||||||
// always invalid. ~12 iterations for a 4000px image.
|
// always invalid. ~12 iterations for a 4000px image.
|
||||||
let upperBoundWidth = width;
|
let upperBoundWidth = width
|
||||||
let lowerBoundWidth = 1;
|
let lowerBoundWidth = 1
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (lowerBoundWidth + 1 === upperBoundWidth) {
|
if (lowerBoundWidth + 1 === upperBoundWidth) {
|
||||||
return [
|
return [
|
||||||
lowerBoundWidth,
|
lowerBoundWidth,
|
||||||
Math.max(Math.round(lowerBoundWidth / aspectRatio), 1),
|
Math.max(Math.round(lowerBoundWidth / aspectRatio), 1),
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2);
|
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2)
|
||||||
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1);
|
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
middleWidth <= maxTargetPx &&
|
middleWidth <= maxTargetPx &&
|
||||||
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
|
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
|
||||||
) {
|
) {
|
||||||
lowerBoundWidth = middleWidth;
|
lowerBoundWidth = middleWidth
|
||||||
} else {
|
} else {
|
||||||
upperBoundWidth = middleWidth;
|
upperBoundWidth = middleWidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type {
|
|||||||
ResolvePrepareCaptureResult,
|
ResolvePrepareCaptureResult,
|
||||||
RunningApp,
|
RunningApp,
|
||||||
ScreenshotResult,
|
ScreenshotResult,
|
||||||
} from "./executor.js";
|
} from './executor.js'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AppGrant,
|
AppGrant,
|
||||||
@@ -25,15 +25,15 @@ export type {
|
|||||||
ScreenshotDims,
|
ScreenshotDims,
|
||||||
TeachStepRequest,
|
TeachStepRequest,
|
||||||
TeachStepResult,
|
TeachStepResult,
|
||||||
} from "./types.js";
|
} from './types.js'
|
||||||
|
|
||||||
export { DEFAULT_GRANT_FLAGS } from "./types.js";
|
export { DEFAULT_GRANT_FLAGS } from './types.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SENTINEL_BUNDLE_IDS,
|
SENTINEL_BUNDLE_IDS,
|
||||||
getSentinelCategory,
|
getSentinelCategory,
|
||||||
} from "./sentinelApps.js";
|
} from './sentinelApps.js'
|
||||||
export type { SentinelCategory } from "./sentinelApps.js";
|
export type { SentinelCategory } from './sentinelApps.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
categoryToTier,
|
categoryToTier,
|
||||||
@@ -42,28 +42,28 @@ export {
|
|||||||
getDeniedCategoryByDisplayName,
|
getDeniedCategoryByDisplayName,
|
||||||
getDeniedCategoryForApp,
|
getDeniedCategoryForApp,
|
||||||
isPolicyDenied,
|
isPolicyDenied,
|
||||||
} from "./deniedApps.js";
|
} from './deniedApps.js'
|
||||||
export type { DeniedCategory } 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 { API_RESIZE_PARAMS, targetImageSize } from './imageResize.js'
|
||||||
export type { ResizeParams } from "./imageResize.js";
|
export type { ResizeParams } from './imageResize.js'
|
||||||
|
|
||||||
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
|
export { defersLockAcquire, handleToolCall } from './toolCalls.js'
|
||||||
export type {
|
export type {
|
||||||
CuCallTelemetry,
|
CuCallTelemetry,
|
||||||
CuCallToolResult,
|
CuCallToolResult,
|
||||||
CuErrorKind,
|
CuErrorKind,
|
||||||
} from "./toolCalls.js";
|
} from './toolCalls.js'
|
||||||
|
|
||||||
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
|
export { bindSessionContext, createComputerUseMcpServer } from './mcpServer.js'
|
||||||
export { buildComputerUseTools } from "./tools.js";
|
export { buildComputerUseTools } from './tools.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
comparePixelAtLocation,
|
comparePixelAtLocation,
|
||||||
validateClickTarget,
|
validateClickTarget,
|
||||||
} from "./pixelCompare.js";
|
} from './pixelCompare.js'
|
||||||
export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js";
|
export type { CropRawPatchFn, PixelCompareResult } from './pixelCompare.js'
|
||||||
|
|||||||
@@ -21,32 +21,32 @@
|
|||||||
*/
|
*/
|
||||||
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
|
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
|
||||||
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
|
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
|
||||||
meta: "meta",
|
meta: 'meta',
|
||||||
super: "meta",
|
super: 'meta',
|
||||||
command: "meta",
|
command: 'meta',
|
||||||
cmd: "meta",
|
cmd: 'meta',
|
||||||
windows: "meta",
|
windows: 'meta',
|
||||||
win: "meta",
|
win: 'meta',
|
||||||
// Key::Control + LControl + RControl
|
// Key::Control + LControl + RControl
|
||||||
ctrl: "ctrl",
|
ctrl: 'ctrl',
|
||||||
control: "ctrl",
|
control: 'ctrl',
|
||||||
lctrl: "ctrl",
|
lctrl: 'ctrl',
|
||||||
lcontrol: "ctrl",
|
lcontrol: 'ctrl',
|
||||||
rctrl: "ctrl",
|
rctrl: 'ctrl',
|
||||||
rcontrol: "ctrl",
|
rcontrol: 'ctrl',
|
||||||
// Key::Shift + LShift + RShift
|
// Key::Shift + LShift + RShift
|
||||||
shift: "shift",
|
shift: 'shift',
|
||||||
lshift: "shift",
|
lshift: 'shift',
|
||||||
rshift: "shift",
|
rshift: 'shift',
|
||||||
// Key::Alt and Key::Option — distinct Rust variants but same keycode on
|
// Key::Alt and Key::Option — distinct Rust variants but same keycode on
|
||||||
// darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
|
// darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
|
||||||
// both Force Quit.
|
// both Force Quit.
|
||||||
alt: "alt",
|
alt: 'alt',
|
||||||
option: "alt",
|
option: 'alt',
|
||||||
};
|
}
|
||||||
|
|
||||||
/** Sort order for canonicals. ctrl < alt < shift < meta. */
|
/** 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
|
* 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.
|
* The self-consistency test enforces this.
|
||||||
*/
|
*/
|
||||||
const BLOCKED_DARWIN = new Set([
|
const BLOCKED_DARWIN = new Set([
|
||||||
"meta+q", // Cmd+Q — quit frontmost app
|
'meta+q', // Cmd+Q — quit frontmost app
|
||||||
"shift+meta+q", // Cmd+Shift+Q — log out
|
'shift+meta+q', // Cmd+Shift+Q — log out
|
||||||
"alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog
|
'alt+meta+escape', // Cmd+Option+Esc — Force Quit dialog
|
||||||
"meta+tab", // Cmd+Tab — app switcher
|
'meta+tab', // Cmd+Tab — app switcher
|
||||||
"meta+space", // Cmd+Space — Spotlight
|
'meta+space', // Cmd+Space — Spotlight
|
||||||
"ctrl+meta+q", // Ctrl+Cmd+Q — lock screen
|
'ctrl+meta+q', // Ctrl+Cmd+Q — lock screen
|
||||||
]);
|
])
|
||||||
|
|
||||||
const BLOCKED_WIN32 = new Set([
|
const BLOCKED_WIN32 = new Set([
|
||||||
"ctrl+alt+delete", // Secure Attention Sequence
|
'ctrl+alt+delete', // Secure Attention Sequence
|
||||||
"alt+f4", // close window
|
'alt+f4', // close window
|
||||||
"alt+tab", // window switcher
|
'alt+tab', // window switcher
|
||||||
"meta+l", // Win+L — lock
|
'meta+l', // Win+L — lock
|
||||||
"meta+d", // Win+D — show desktop
|
'meta+d', // Win+D — show desktop
|
||||||
]);
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partition into sorted-canonical modifiers and non-modifier keys.
|
* 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[] } {
|
function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
|
||||||
const parts = seq
|
const parts = seq
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split("+")
|
.split('+')
|
||||||
.map((p) => p.trim())
|
.map(p => p.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean)
|
||||||
const mods: string[] = [];
|
const mods: string[] = []
|
||||||
const keys: string[] = [];
|
const keys: string[] = []
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
const canonical = CANONICAL_MODIFIER[p];
|
const canonical = CANONICAL_MODIFIER[p]
|
||||||
if (canonical !== undefined) {
|
if (canonical !== undefined) {
|
||||||
mods.push(canonical);
|
mods.push(canonical)
|
||||||
} else {
|
} else {
|
||||||
keys.push(p);
|
keys.push(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
|
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
|
||||||
const uniqueMods = [...new Set(mods)];
|
const uniqueMods = [...new Set(mods)]
|
||||||
uniqueMods.sort(
|
uniqueMods.sort(
|
||||||
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
|
(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.
|
* canonical, dedupe, sort modifiers, non-modifiers last.
|
||||||
*/
|
*/
|
||||||
export function normalizeKeySequence(seq: string): string {
|
export function normalizeKeySequence(seq: string): string {
|
||||||
const { mods, keys } = partitionKeys(seq);
|
const { mods, keys } = partitionKeys(seq)
|
||||||
return [...mods, ...keys].join("+");
|
return [...mods, ...keys].join('+')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,26 +123,26 @@ export function normalizeKeySequence(seq: string): string {
|
|||||||
*/
|
*/
|
||||||
export function isSystemKeyCombo(
|
export function isSystemKeyCombo(
|
||||||
seq: string,
|
seq: string,
|
||||||
platform: "darwin" | "win32",
|
platform: 'darwin' | 'win32',
|
||||||
): boolean {
|
): boolean {
|
||||||
const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
|
const blocklist = platform === 'darwin' ? BLOCKED_DARWIN : BLOCKED_WIN32
|
||||||
const { mods, keys } = partitionKeys(seq);
|
const { mods, keys } = partitionKeys(seq)
|
||||||
const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
|
const prefix = mods.length > 0 ? mods.join('+') + '+' : ''
|
||||||
|
|
||||||
// No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
|
// No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
|
||||||
// whole thing. Never matches (no blocklist entry is modifier-only) but
|
// whole thing. Never matches (no blocklist entry is modifier-only) but
|
||||||
// keeps the contract simple: every call reaches a .has().
|
// keeps the contract simple: every call reaches a .has().
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return blocklist.has(mods.join("+"));
|
return blocklist.has(mods.join('+'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// mods + each key. Any hit blocks the whole sequence.
|
// mods + each key. Any hit blocks the whole sequence.
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (blocklist.has(prefix + key)) {
|
if (blocklist.has(prefix + key)) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export const _test = {
|
export const _test = {
|
||||||
@@ -150,4 +150,4 @@ export const _test = {
|
|||||||
BLOCKED_DARWIN,
|
BLOCKED_DARWIN,
|
||||||
BLOCKED_WIN32,
|
BLOCKED_WIN32,
|
||||||
MODIFIER_ORDER,
|
MODIFIER_ORDER,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -17,21 +17,21 @@
|
|||||||
* is the same either way.
|
* is the same either way.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
|
||||||
import type { ScreenshotResult } from "./executor.js";
|
import type { ScreenshotResult } from './executor.js'
|
||||||
import type { CuCallToolResult } from "./toolCalls.js";
|
import type { CuCallToolResult } from './toolCalls.js'
|
||||||
import {
|
import {
|
||||||
defersLockAcquire,
|
defersLockAcquire,
|
||||||
handleToolCall,
|
handleToolCall,
|
||||||
resetMouseButtonHeld,
|
resetMouseButtonHeld,
|
||||||
} from "./toolCalls.js";
|
} from './toolCalls.js'
|
||||||
import { buildComputerUseTools } from "./tools.js";
|
import { buildComputerUseTools } from './tools.js'
|
||||||
import type {
|
import type {
|
||||||
AppGrant,
|
AppGrant,
|
||||||
ComputerUseHostAdapter,
|
ComputerUseHostAdapter,
|
||||||
@@ -40,12 +40,12 @@ import type {
|
|||||||
CoordinateMode,
|
CoordinateMode,
|
||||||
CuGrantFlags,
|
CuGrantFlags,
|
||||||
CuPermissionResponse,
|
CuPermissionResponse,
|
||||||
} from "./types.js";
|
} from './types.js'
|
||||||
import { DEFAULT_GRANT_FLAGS } from "./types.js";
|
import { DEFAULT_GRANT_FLAGS } from './types.js'
|
||||||
|
|
||||||
const DEFAULT_LOCK_HELD_MESSAGE =
|
const DEFAULT_LOCK_HELD_MESSAGE =
|
||||||
"Another Claude session is currently using the computer. Wait for that " +
|
'Another Claude session is currently using the computer. Wait for that ' +
|
||||||
"session to finish, or find a non-computer-use approach.";
|
'session to finish, or find a non-computer-use approach.'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
|
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
|
||||||
@@ -60,20 +60,20 @@ function mergePermissionResponse(
|
|||||||
existingFlags: CuGrantFlags,
|
existingFlags: CuGrantFlags,
|
||||||
response: CuPermissionResponse,
|
response: CuPermissionResponse,
|
||||||
): { apps: AppGrant[]; flags: CuGrantFlags } {
|
): { apps: AppGrant[]; flags: CuGrantFlags } {
|
||||||
const seen = new Set(existing.map((a) => a.bundleId));
|
const seen = new Set(existing.map(a => a.bundleId))
|
||||||
const apps = [
|
const apps = [
|
||||||
...existing,
|
...existing,
|
||||||
...response.granted.filter((g) => !seen.has(g.bundleId)),
|
...response.granted.filter(g => !seen.has(g.bundleId)),
|
||||||
];
|
]
|
||||||
const truthyFlags = Object.fromEntries(
|
const truthyFlags = Object.fromEntries(
|
||||||
Object.entries(response.flags).filter(([, v]) => v === true),
|
Object.entries(response.flags).filter(([, v]) => v === true),
|
||||||
);
|
)
|
||||||
const flags: CuGrantFlags = {
|
const flags: CuGrantFlags = {
|
||||||
...DEFAULT_GRANT_FLAGS,
|
...DEFAULT_GRANT_FLAGS,
|
||||||
...existingFlags,
|
...existingFlags,
|
||||||
...truthyFlags,
|
...truthyFlags,
|
||||||
};
|
}
|
||||||
return { apps, flags };
|
return { apps, flags }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,53 +91,53 @@ export function bindSessionContext(
|
|||||||
coordinateMode: CoordinateMode,
|
coordinateMode: CoordinateMode,
|
||||||
ctx: ComputerUseSessionContext,
|
ctx: ComputerUseSessionContext,
|
||||||
): (name: string, args: unknown) => Promise<CuCallToolResult> {
|
): (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
|
// Screenshot blob persists here across calls — NOT on `ctx`. Hosts hold
|
||||||
// onto the returned dispatcher; that's the identity that matters.
|
// onto the returned dispatcher; that's the identity that matters.
|
||||||
let lastScreenshot: ScreenshotResult | undefined;
|
let lastScreenshot: ScreenshotResult | undefined
|
||||||
|
|
||||||
const wrapPermission = ctx.onPermissionRequest
|
const wrapPermission = ctx.onPermissionRequest
|
||||||
? async (
|
? async (
|
||||||
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
|
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CuPermissionResponse> => {
|
): Promise<CuPermissionResponse> => {
|
||||||
const response = await ctx.onPermissionRequest!(req, signal);
|
const response = await ctx.onPermissionRequest!(req, signal)
|
||||||
const { apps, flags } = mergePermissionResponse(
|
const { apps, flags } = mergePermissionResponse(
|
||||||
ctx.getAllowedApps(),
|
ctx.getAllowedApps(),
|
||||||
ctx.getGrantFlags(),
|
ctx.getGrantFlags(),
|
||||||
response,
|
response,
|
||||||
);
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
`[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
||||||
);
|
)
|
||||||
ctx.onAllowedAppsChanged?.(apps, flags);
|
ctx.onAllowedAppsChanged?.(apps, flags)
|
||||||
return response;
|
return response
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
const wrapTeachPermission = ctx.onTeachPermissionRequest
|
const wrapTeachPermission = ctx.onTeachPermissionRequest
|
||||||
? async (
|
? async (
|
||||||
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
|
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CuPermissionResponse> => {
|
): Promise<CuPermissionResponse> => {
|
||||||
const response = await ctx.onTeachPermissionRequest!(req, signal);
|
const response = await ctx.onTeachPermissionRequest!(req, signal)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
`[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
||||||
);
|
)
|
||||||
// Teach doesn't request grant flags — preserve existing.
|
// Teach doesn't request grant flags — preserve existing.
|
||||||
const { apps } = mergePermissionResponse(
|
const { apps } = mergePermissionResponse(
|
||||||
ctx.getAllowedApps(),
|
ctx.getAllowedApps(),
|
||||||
ctx.getGrantFlags(),
|
ctx.getGrantFlags(),
|
||||||
response,
|
response,
|
||||||
);
|
)
|
||||||
ctx.onAllowedAppsChanged?.(apps, {
|
ctx.onAllowedAppsChanged?.(apps, {
|
||||||
...DEFAULT_GRANT_FLAGS,
|
...DEFAULT_GRANT_FLAGS,
|
||||||
...ctx.getGrantFlags(),
|
...ctx.getGrantFlags(),
|
||||||
});
|
})
|
||||||
return response;
|
return response
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined
|
||||||
|
|
||||||
return async (name, args) => {
|
return async (name, args) => {
|
||||||
// ─── Async lock gate ─────────────────────────────────────────────────
|
// ─── Async lock gate ─────────────────────────────────────────────────
|
||||||
@@ -146,18 +146,18 @@ export function bindSessionContext(
|
|||||||
// cross-process locks (O_EXCL file) await the real primitive here
|
// cross-process locks (O_EXCL file) await the real primitive here
|
||||||
// instead of pre-computing + feeding a fake sync result.
|
// instead of pre-computing + feeding a fake sync result.
|
||||||
if (ctx.checkCuLock) {
|
if (ctx.checkCuLock) {
|
||||||
const lock = await ctx.checkCuLock();
|
const lock = await ctx.checkCuLock()
|
||||||
if (lock.holder !== undefined && !lock.isSelf) {
|
if (lock.holder !== undefined && !lock.isSelf) {
|
||||||
const text =
|
const text =
|
||||||
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
|
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: 'text', text }],
|
||||||
isError: true,
|
isError: true,
|
||||||
telemetry: { error_kind: "cu_lock_held" },
|
telemetry: { error_kind: 'cu_lock_held' },
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
if (lock.holder === undefined && !defersLockAcquire(name)) {
|
if (lock.holder === undefined && !defersLockAcquire(name)) {
|
||||||
await ctx.acquireCuLock?.();
|
await ctx.acquireCuLock?.()
|
||||||
// Re-check: the awaits above yield the microtask queue, so another
|
// Re-check: the awaits above yield the microtask queue, so another
|
||||||
// session's check+acquire can interleave with ours. Hosts where
|
// session's check+acquire can interleave with ours. Hosts where
|
||||||
// acquire is a no-op when already held (Cowork's CuLockManager) give
|
// 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
|
// 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
|
// acquire instead; this re-check is a belt-and-suspenders for that
|
||||||
// path too.
|
// path too.
|
||||||
const recheck = await ctx.checkCuLock();
|
const recheck = await ctx.checkCuLock()
|
||||||
if (recheck.holder !== undefined && !recheck.isSelf) {
|
if (recheck.holder !== undefined && !recheck.isSelf) {
|
||||||
const text =
|
const text =
|
||||||
ctx.formatLockHeldMessage?.(recheck.holder) ??
|
ctx.formatLockHeldMessage?.(recheck.holder) ??
|
||||||
DEFAULT_LOCK_HELD_MESSAGE;
|
DEFAULT_LOCK_HELD_MESSAGE
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: 'text', text }],
|
||||||
isError: true,
|
isError: true,
|
||||||
telemetry: { error_kind: "cu_lock_held" },
|
telemetry: { error_kind: 'cu_lock_held' },
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
// Fresh holder → any prior session's mouseButtonHeld is stale.
|
// Fresh holder → any prior session's mouseButtonHeld is stale.
|
||||||
// Mirrors what Gate-3 does on the acquire branch. After the
|
// Mirrors what Gate-3 does on the acquire branch. After the
|
||||||
// re-check so we only clear module state when we actually won.
|
// re-check so we only clear module state when we actually won.
|
||||||
resetMouseButtonHeld();
|
resetMouseButtonHeld()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,12 +189,12 @@ export function bindSessionContext(
|
|||||||
// isEmpty → skip.
|
// isEmpty → skip.
|
||||||
const dimsFallback = lastScreenshot
|
const dimsFallback = lastScreenshot
|
||||||
? undefined
|
? undefined
|
||||||
: ctx.getLastScreenshotDims?.();
|
: ctx.getLastScreenshotDims?.()
|
||||||
|
|
||||||
// Per-call AbortController for dialog dismissal. Aborted in `finally` —
|
// Per-call AbortController for dialog dismissal. Aborted in `finally` —
|
||||||
// if handleToolCall finishes (MCP timeout, throw) before the user
|
// if handleToolCall finishes (MCP timeout, throw) before the user
|
||||||
// answers, the host's dialog handler sees the abort and tears down.
|
// answers, the host's dialog handler sees the abort and tears down.
|
||||||
const dialogAbort = new AbortController();
|
const dialogAbort = new AbortController()
|
||||||
|
|
||||||
const overrides: ComputerUseOverrides = {
|
const overrides: ComputerUseOverrides = {
|
||||||
allowedApps: [...ctx.getAllowedApps()],
|
allowedApps: [...ctx.getAllowedApps()],
|
||||||
@@ -206,12 +206,12 @@ export function bindSessionContext(
|
|||||||
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
|
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
|
||||||
lastScreenshot:
|
lastScreenshot:
|
||||||
lastScreenshot ??
|
lastScreenshot ??
|
||||||
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
|
(dimsFallback ? { ...dimsFallback, base64: '' } : undefined),
|
||||||
onPermissionRequest: wrapPermission
|
onPermissionRequest: wrapPermission
|
||||||
? (req) => wrapPermission(req, dialogAbort.signal)
|
? req => wrapPermission(req, dialogAbort.signal)
|
||||||
: undefined,
|
: undefined,
|
||||||
onTeachPermissionRequest: wrapTeachPermission
|
onTeachPermissionRequest: wrapTeachPermission
|
||||||
? (req) => wrapTeachPermission(req, dialogAbort.signal)
|
? req => wrapTeachPermission(req, dialogAbort.signal)
|
||||||
: undefined,
|
: undefined,
|
||||||
onAppsHidden: ctx.onAppsHidden,
|
onAppsHidden: ctx.onAppsHidden,
|
||||||
getClipboardStash: ctx.getClipboardStash,
|
getClipboardStash: ctx.getClipboardStash,
|
||||||
@@ -228,28 +228,28 @@ export function bindSessionContext(
|
|||||||
checkCuLock: undefined,
|
checkCuLock: undefined,
|
||||||
acquireCuLock: undefined,
|
acquireCuLock: undefined,
|
||||||
isAborted: ctx.isAborted,
|
isAborted: ctx.isAborted,
|
||||||
};
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`,
|
`[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`,
|
||||||
);
|
)
|
||||||
|
|
||||||
// ─── Dispatch ────────────────────────────────────────────────────────
|
// ─── Dispatch ────────────────────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
const result = await handleToolCall(adapter, name, args, overrides);
|
const result = await handleToolCall(adapter, name, args, overrides)
|
||||||
|
|
||||||
if (result.screenshot) {
|
if (result.screenshot) {
|
||||||
lastScreenshot = result.screenshot;
|
lastScreenshot = result.screenshot
|
||||||
const { base64: _blob, ...dims } = result.screenshot;
|
const { base64: _blob, ...dims } = result.screenshot
|
||||||
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`);
|
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`)
|
||||||
ctx.onScreenshotCaptured?.(dims);
|
ctx.onScreenshotCaptured?.(dims)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
dialogAbort.abort();
|
dialogAbort.abort()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createComputerUseMcpServer(
|
export function createComputerUseMcpServer(
|
||||||
@@ -257,35 +257,36 @@ export function createComputerUseMcpServer(
|
|||||||
coordinateMode: CoordinateMode,
|
coordinateMode: CoordinateMode,
|
||||||
context?: ComputerUseSessionContext,
|
context?: ComputerUseSessionContext,
|
||||||
): Server {
|
): Server {
|
||||||
const { serverName, logger } = adapter;
|
const { serverName, logger } = adapter
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: serverName, version: "0.1.3" },
|
{ name: serverName, version: '0.1.3' },
|
||||||
{ capabilities: { tools: {}, logging: {} } },
|
{ capabilities: { tools: {}, logging: {} } },
|
||||||
);
|
)
|
||||||
|
|
||||||
const tools = buildComputerUseTools(
|
const tools = buildComputerUseTools(
|
||||||
adapter.executor.capabilities,
|
adapter.executor.capabilities,
|
||||||
coordinateMode,
|
coordinateMode,
|
||||||
);
|
)
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
||||||
adapter.isDisabled() ? { tools: [] } : { tools },
|
adapter.isDisabled() ? { tools: [] } : { tools },
|
||||||
);
|
)
|
||||||
|
|
||||||
if (context) {
|
if (context) {
|
||||||
const dispatch = bindSessionContext(adapter, coordinateMode, context);
|
const dispatch = bindSessionContext(adapter, coordinateMode, context)
|
||||||
server.setRequestHandler(
|
server.setRequestHandler(
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
async (request): Promise<CallToolResult> => {
|
async (request): Promise<CallToolResult> => {
|
||||||
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
|
const {
|
||||||
request.params.name,
|
screenshot: _s,
|
||||||
request.params.arguments ?? {},
|
telemetry: _t,
|
||||||
);
|
...result
|
||||||
return 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
|
// Legacy: no context → stub handler. Reached only if something calls the
|
||||||
@@ -296,18 +297,18 @@ export function createComputerUseMcpServer(
|
|||||||
async (request): Promise<CallToolResult> => {
|
async (request): Promise<CallToolResult> => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`,
|
`[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`,
|
||||||
);
|
)
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
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.",
|
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,
|
isError: true,
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
return server;
|
return server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,28 +19,28 @@
|
|||||||
* this package never imports it — the crop is a function parameter.
|
* this package never imports it — the crop is a function parameter.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ScreenshotResult } from "./executor.js";
|
import type { ScreenshotResult } from './executor.js'
|
||||||
import type { Logger } from "./types.js";
|
import type { Logger } from './types.js'
|
||||||
|
|
||||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||||
export type CropRawPatchFn = (
|
export type CropRawPatchFn = (
|
||||||
jpegBase64: string,
|
jpegBase64: string,
|
||||||
rect: { x: number; y: number; width: number; height: number },
|
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
|
/** 9×9 is empirically the sweet spot — large enough to catch a tooltip
|
||||||
* appearing, small enough to not false-positive on surrounding animation.
|
* appearing, small enough to not false-positive on surrounding animation.
|
||||||
**/
|
**/
|
||||||
const DEFAULT_GRID_SIZE = 9;
|
const DEFAULT_GRID_SIZE = 9
|
||||||
|
|
||||||
export interface PixelCompareResult {
|
export interface PixelCompareResult {
|
||||||
/** true → click may proceed. false → patch changed, abort the click. */
|
/** 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
|
/** true → validation did not run (cold start, sub-gate off, or internal
|
||||||
* error). The caller MUST treat this identically to `valid: true`. */
|
* error). The caller MUST treat this identically to `valid: true`. */
|
||||||
skipped: boolean;
|
skipped: boolean
|
||||||
/** Populated when valid === false. Returned to the model verbatim. */
|
/** Populated when valid === false. Returned to the model verbatim. */
|
||||||
warning?: string;
|
warning?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,22 +57,22 @@ function computeCropRect(
|
|||||||
yPercent: number,
|
yPercent: number,
|
||||||
gridSize: number,
|
gridSize: number,
|
||||||
): { x: number; y: number; width: number; height: number } | null {
|
): { 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 clampedX = Math.max(0, Math.min(100, xPercent))
|
||||||
const clampedY = Math.max(0, Math.min(100, yPercent));
|
const clampedY = Math.max(0, Math.min(100, yPercent))
|
||||||
|
|
||||||
const centerX = Math.round((clampedX / 100.0) * imgW);
|
const centerX = Math.round((clampedX / 100.0) * imgW)
|
||||||
const centerY = Math.round((clampedY / 100.0) * imgH);
|
const centerY = Math.round((clampedY / 100.0) * imgH)
|
||||||
|
|
||||||
const halfGrid = Math.floor(gridSize / 2);
|
const halfGrid = Math.floor(gridSize / 2)
|
||||||
const cropX = Math.max(0, centerX - halfGrid);
|
const cropX = Math.max(0, centerX - halfGrid)
|
||||||
const cropY = Math.max(0, centerY - halfGrid);
|
const cropY = Math.max(0, centerY - halfGrid)
|
||||||
const cropW = Math.min(gridSize, imgW - cropX);
|
const cropW = Math.min(gridSize, imgW - cropX)
|
||||||
const cropH = Math.min(gridSize, imgH - cropY);
|
const cropH = Math.min(gridSize, imgH - cropY)
|
||||||
if (cropW <= 0 || cropH <= 0) return null;
|
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,
|
xPercent,
|
||||||
yPercent,
|
yPercent,
|
||||||
gridSize,
|
gridSize,
|
||||||
);
|
)
|
||||||
if (!rect) return false;
|
if (!rect) return false
|
||||||
|
|
||||||
const patch1 = crop(lastScreenshot.base64, rect);
|
const patch1 = crop(lastScreenshot.base64, rect)
|
||||||
const patch2 = crop(freshScreenshot.base64, rect);
|
const patch2 = crop(freshScreenshot.base64, rect)
|
||||||
if (!patch1 || !patch2) return false;
|
if (!patch1 || !patch2) return false
|
||||||
|
|
||||||
// Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's
|
// Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's
|
||||||
// .raw() gave RGB.
|
// .raw() gave RGB.
|
||||||
// Doesn't matter — we're comparing two same-format buffers for equality.
|
// 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,
|
gridSize: number = DEFAULT_GRID_SIZE,
|
||||||
): Promise<PixelCompareResult> {
|
): Promise<PixelCompareResult> {
|
||||||
if (!lastScreenshot) {
|
if (!lastScreenshot) {
|
||||||
return { valid: true, skipped: true };
|
return { valid: true, skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fresh = await takeFreshScreenshot();
|
const fresh = await takeFreshScreenshot()
|
||||||
if (!fresh) {
|
if (!fresh) {
|
||||||
return { valid: true, skipped: true };
|
return { valid: true, skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const pixelsMatch = comparePixelAtLocation(
|
const pixelsMatch = comparePixelAtLocation(
|
||||||
@@ -151,21 +151,21 @@ export async function validateClickTarget(
|
|||||||
xPercent,
|
xPercent,
|
||||||
yPercent,
|
yPercent,
|
||||||
gridSize,
|
gridSize,
|
||||||
);
|
)
|
||||||
|
|
||||||
if (pixelsMatch) {
|
if (pixelsMatch) {
|
||||||
return { valid: true, skipped: false };
|
return { valid: true, skipped: false }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
warning:
|
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) {
|
} catch (err) {
|
||||||
// Skip validation on technical errors, execute action anyway.
|
// Skip validation on technical errors, execute action anyway.
|
||||||
// Battle-tested: validation failure must never block the click.
|
// Battle-tested: validation failure must never block the click.
|
||||||
logger.debug("[pixelCompare] validation error, skipping", err);
|
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||||
return { valid: true, skipped: true };
|
return { valid: true, skipped: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,33 +11,33 @@
|
|||||||
|
|
||||||
/** These apps can execute arbitrary shell commands. */
|
/** These apps can execute arbitrary shell commands. */
|
||||||
const SHELL_ACCESS_BUNDLE_IDS = new Set([
|
const SHELL_ACCESS_BUNDLE_IDS = new Set([
|
||||||
"com.apple.Terminal",
|
'com.apple.Terminal',
|
||||||
"com.googlecode.iterm2",
|
'com.googlecode.iterm2',
|
||||||
"com.microsoft.VSCode",
|
'com.microsoft.VSCode',
|
||||||
"dev.warp.Warp-Stable",
|
'dev.warp.Warp-Stable',
|
||||||
"com.github.wez.wezterm",
|
'com.github.wez.wezterm',
|
||||||
"io.alacritty",
|
'io.alacritty',
|
||||||
"net.kovidgoyal.kitty",
|
'net.kovidgoyal.kitty',
|
||||||
"com.jetbrains.intellij",
|
'com.jetbrains.intellij',
|
||||||
"com.jetbrains.pycharm",
|
'com.jetbrains.pycharm',
|
||||||
]);
|
])
|
||||||
|
|
||||||
/** Finder in the allowlist ≈ browse + open-any-file. */
|
/** 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([
|
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||||
...SHELL_ACCESS_BUNDLE_IDS,
|
...SHELL_ACCESS_BUNDLE_IDS,
|
||||||
...FILESYSTEM_ACCESS_BUNDLE_IDS,
|
...FILESYSTEM_ACCESS_BUNDLE_IDS,
|
||||||
...SYSTEM_SETTINGS_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 {
|
export function getSentinelCategory(bundleId: string): SentinelCategory | null {
|
||||||
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return "shell";
|
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return 'shell'
|
||||||
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return "filesystem";
|
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return 'filesystem'
|
||||||
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return "system_settings";
|
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return 'system_settings'
|
||||||
return null;
|
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,
|
ComputerExecutor,
|
||||||
InstalledApp,
|
InstalledApp,
|
||||||
ScreenshotResult,
|
ScreenshotResult,
|
||||||
} from "./executor.js";
|
} from './executor.js'
|
||||||
|
|
||||||
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
|
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
|
||||||
* cross-respawn `scaleCoord` survival. */
|
* 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 */
|
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||||
export interface Logger {
|
export interface Logger {
|
||||||
info: (message: string, ...args: unknown[]) => void;
|
info: (message: string, ...args: unknown[]) => void
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
error: (message: string, ...args: unknown[]) => void
|
||||||
warn: (message: string, ...args: unknown[]) => void;
|
warn: (message: string, ...args: unknown[]) => void
|
||||||
debug: (message: string, ...args: unknown[]) => void;
|
debug: (message: string, ...args: unknown[]) => void
|
||||||
silly: (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
|
* Enforced in `runInputActionGates` via the frontmost-app check: keyboard
|
||||||
* actions require `"full"`, mouse actions require `"click"` or higher.
|
* 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
|
* A single app the user has approved for the current session. Session-scoped
|
||||||
@@ -45,32 +45,32 @@ export type CuAppPermTier = "read" | "click" | "full";
|
|||||||
* scope.
|
* scope.
|
||||||
*/
|
*/
|
||||||
export interface AppGrant {
|
export interface AppGrant {
|
||||||
bundleId: string;
|
bundleId: string
|
||||||
displayName: string;
|
displayName: string
|
||||||
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
|
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
|
||||||
grantedAt: number;
|
grantedAt: number
|
||||||
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
|
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
|
||||||
* session state). */
|
* session state). */
|
||||||
tier?: CuAppPermTier;
|
tier?: CuAppPermTier
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Orthogonal to the app allowlist. */
|
/** Orthogonal to the app allowlist. */
|
||||||
export interface CuGrantFlags {
|
export interface CuGrantFlags {
|
||||||
clipboardRead: boolean;
|
clipboardRead: boolean
|
||||||
clipboardWrite: boolean;
|
clipboardWrite: boolean
|
||||||
/**
|
/**
|
||||||
* When false, the `key` tool rejects combos in `keyBlocklist.ts`
|
* When false, the `key` tool rejects combos in `keyBlocklist.ts`
|
||||||
* (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other
|
* (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other
|
||||||
* key sequences work regardless.
|
* key sequences work regardless.
|
||||||
*/
|
*/
|
||||||
systemKeyCombos: boolean;
|
systemKeyCombos: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
|
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
|
||||||
clipboardRead: false,
|
clipboardRead: false,
|
||||||
clipboardWrite: false,
|
clipboardWrite: false,
|
||||||
systemKeyCombos: false,
|
systemKeyCombos: false,
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked
|
* 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`
|
* ONE convention and never learns the other exists. `normalized_0_100`
|
||||||
* sidesteps the Retina scaleFactor bug class entirely.
|
* 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
|
* Independent kill switches for subtle/risky ported behaviors. Read from
|
||||||
@@ -86,28 +86,28 @@ export type CoordinateMode = "pixels" | "normalized_0_100";
|
|||||||
*/
|
*/
|
||||||
export interface CuSubGates {
|
export interface CuSubGates {
|
||||||
/** 9×9 exact-byte staleness guard before click. */
|
/** 9×9 exact-byte staleness guard before click. */
|
||||||
pixelValidation: boolean;
|
pixelValidation: boolean
|
||||||
/** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */
|
/** 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
|
* Ease-out-cubic mouse glide at 60fps, distance-proportional duration
|
||||||
* (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency
|
* (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency
|
||||||
* per click. When off, cursor teleports instantly.
|
* per click. When off, cursor teleports instantly.
|
||||||
*/
|
*/
|
||||||
mouseAnimation: boolean;
|
mouseAnimation: boolean
|
||||||
/**
|
/**
|
||||||
* Pre-action sequence: hide non-allowlisted apps, then defocus us (from the
|
* Pre-action sequence: hide non-allowlisted apps, then defocus us (from the
|
||||||
* Vercept acquisition). When off, the
|
* Vercept acquisition). When off, the
|
||||||
* frontmost gate fires in the normal case and the model gets stuck — this
|
* frontmost gate fires in the normal case and the model gets stuck — this
|
||||||
* is the A/B-test-the-old-broken-behavior switch.
|
* is the A/B-test-the-old-broken-behavior switch.
|
||||||
*/
|
*/
|
||||||
hideBeforeAction: boolean;
|
hideBeforeAction: boolean
|
||||||
/**
|
/**
|
||||||
* Auto-resolve the target display before each screenshot when the
|
* Auto-resolve the target display before each screenshot when the
|
||||||
* selected display has no allowed-app windows. When on, `handleScreenshot`
|
* selected display has no allowed-app windows. When on, `handleScreenshot`
|
||||||
* uses the atomic Swift path; off → sticks with `selectedDisplayId`.
|
* uses the atomic Swift path; off → sticks with `selectedDisplayId`.
|
||||||
*/
|
*/
|
||||||
autoTargetDisplay: boolean;
|
autoTargetDisplay: boolean
|
||||||
/**
|
/**
|
||||||
* Stash+clear the clipboard while a tier-"click" app is frontmost.
|
* 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
|
* 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
|
* keyboard block can be routed around by clicking Paste. Restored when
|
||||||
* a non-"click" app becomes frontmost, or at turn end.
|
* 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. */
|
/** One entry per app the model asked for, after name → bundle ID resolution. */
|
||||||
export interface ResolvedAppRequest {
|
export interface ResolvedAppRequest {
|
||||||
/** What the model asked for (e.g. "Slack", "com.tinyspeck.slackmacgap"). */
|
/** 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). */
|
/** 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. */
|
/** 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. */
|
/** 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").
|
/** Hardcoded tier for this app (browser→"read", terminal→"click", else "full").
|
||||||
* The dialog displays this read-only; the renderer passes it through
|
* The dialog displays this read-only; the renderer passes it through
|
||||||
* verbatim in the AppGrant. */
|
* verbatim in the AppGrant. */
|
||||||
proposedTier: CuAppPermTier;
|
proposedTier: CuAppPermTier
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,18 +145,18 @@ export interface ResolvedAppRequest {
|
|||||||
* change needed.
|
* change needed.
|
||||||
*/
|
*/
|
||||||
export interface CuPermissionRequest {
|
export interface CuPermissionRequest {
|
||||||
requestId: string;
|
requestId: string
|
||||||
/** Model-provided reason string. Shown prominently in the approval UI. */
|
/** Model-provided reason string. Shown prominently in the approval UI. */
|
||||||
reason: string;
|
reason: string
|
||||||
apps: ResolvedAppRequest[];
|
apps: ResolvedAppRequest[]
|
||||||
/** What the model asked for. User can toggle independently of apps. */
|
/** 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
|
* For the "On Windows, Claude can see all apps..." footnote. Taken from
|
||||||
* `executor.capabilities.screenshotFiltering` so the renderer doesn't
|
* `executor.capabilities.screenshotFiltering` so the renderer doesn't
|
||||||
* need to know about platforms.
|
* need to know about platforms.
|
||||||
*/
|
*/
|
||||||
screenshotFiltering: "native" | "none";
|
screenshotFiltering: 'native' | 'none'
|
||||||
/**
|
/**
|
||||||
* Present only when TCC permissions are NOT yet granted. When present,
|
* Present only when TCC permissions are NOT yet granted. When present,
|
||||||
* the renderer shows a TCC toggle panel (two rows: Accessibility, Screen
|
* 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.
|
* restart after granting Screen Recording — we don't.
|
||||||
*/
|
*/
|
||||||
tccState?: {
|
tccState?: {
|
||||||
accessibility: boolean;
|
accessibility: boolean
|
||||||
screenRecording: boolean;
|
screenRecording: boolean
|
||||||
};
|
}
|
||||||
/**
|
/**
|
||||||
* Apps with windows on the CU display that aren't in the requested
|
* 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.
|
* 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
|
* user clicks Allow, but it's a preview, not a contract. Absent when
|
||||||
* empty so the renderer can skip the section cleanly.
|
* 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
|
* `chicagoAutoUnhide` app preference at request time. The renderer picks
|
||||||
* between "...then restored when Claude is done" and "...will be hidden"
|
* between "...then restored when Claude is done" and "...will be hidden"
|
||||||
* copy. Absent when `willHide` is absent (same condition).
|
* copy. Absent when `willHide` is absent (same condition).
|
||||||
*/
|
*/
|
||||||
autoUnhideEnabled?: boolean;
|
autoUnhideEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,10 +191,10 @@ export interface CuPermissionRequest {
|
|||||||
* LocalAgentModeSessionManager.ts:2794).
|
* LocalAgentModeSessionManager.ts:2794).
|
||||||
*/
|
*/
|
||||||
export interface CuPermissionResponse {
|
export interface CuPermissionResponse {
|
||||||
granted: AppGrant[];
|
granted: AppGrant[]
|
||||||
/** Bundle IDs the user unchecked, or apps that weren't installed. */
|
/** Bundle IDs the user unchecked, or apps that weren't installed. */
|
||||||
denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>;
|
denied: Array<{ bundleId: string; reason: 'user_denied' | 'not_installed' }>
|
||||||
flags: CuGrantFlags;
|
flags: CuGrantFlags
|
||||||
/**
|
/**
|
||||||
* Whether the user clicked Allow in THIS dialog. Only set by the
|
* Whether the user clicked Allow in THIS dialog. Only set by the
|
||||||
* teach-mode handler — regular request_access doesn't need it (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
|
* them apart without this. Undefined → legacy/regular path, do not
|
||||||
* gate on it.
|
* gate on it.
|
||||||
*/
|
*/
|
||||||
userConsented?: boolean;
|
userConsented?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -218,9 +218,9 @@ export interface CuPermissionResponse {
|
|||||||
* No Electron imports in this package — the host injects everything.
|
* No Electron imports in this package — the host injects everything.
|
||||||
*/
|
*/
|
||||||
export interface ComputerUseHostAdapter {
|
export interface ComputerUseHostAdapter {
|
||||||
serverName: string;
|
serverName: string
|
||||||
logger: Logger;
|
logger: Logger
|
||||||
executor: ComputerExecutor;
|
executor: ComputerExecutor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
|
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
|
||||||
@@ -231,23 +231,23 @@ export interface ComputerUseHostAdapter {
|
|||||||
ensureOsPermissions(): Promise<
|
ensureOsPermissions(): Promise<
|
||||||
| { granted: true }
|
| { granted: true }
|
||||||
| { granted: false; accessibility: boolean; screenRecording: boolean }
|
| { granted: false; accessibility: boolean; screenRecording: boolean }
|
||||||
>;
|
>
|
||||||
|
|
||||||
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
|
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
|
||||||
isDisabled(): boolean;
|
isDisabled(): boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest`
|
* The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest`
|
||||||
* to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's
|
* to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's
|
||||||
* "will be hidden" copy can say "then restored" only when true.
|
* "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
|
* Sub-gates re-read on every tool call so GrowthBook flips take effect
|
||||||
* mid-session without restart.
|
* mid-session without restart.
|
||||||
*/
|
*/
|
||||||
getSubGates(): CuSubGates;
|
getSubGates(): CuSubGates
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
|
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
|
||||||
@@ -261,7 +261,7 @@ export interface ComputerUseHostAdapter {
|
|||||||
cropRawPatch(
|
cropRawPatch(
|
||||||
jpegBase64: string,
|
jpegBase64: string,
|
||||||
rect: { x: number; y: number; width: number; height: number },
|
rect: { x: number; y: number; width: number; height: number },
|
||||||
): Buffer | null;
|
): Buffer | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -286,18 +286,18 @@ export interface ComputerUseHostAdapter {
|
|||||||
export interface ComputerUseSessionContext {
|
export interface ComputerUseSessionContext {
|
||||||
// ── Read state fresh per call ──────────────────────────────────────
|
// ── Read state fresh per call ──────────────────────────────────────
|
||||||
|
|
||||||
getAllowedApps(): readonly AppGrant[];
|
getAllowedApps(): readonly AppGrant[]
|
||||||
getGrantFlags(): CuGrantFlags;
|
getGrantFlags(): CuGrantFlags
|
||||||
/** Per-user auto-deny list (Settings page). Empty array = none. */
|
/** Per-user auto-deny list (Settings page). Empty array = none. */
|
||||||
getUserDeniedBundleIds(): readonly string[];
|
getUserDeniedBundleIds(): readonly string[]
|
||||||
getSelectedDisplayId(): number | undefined;
|
getSelectedDisplayId(): number | undefined
|
||||||
getDisplayPinnedByModel?(): boolean;
|
getDisplayPinnedByModel?(): boolean
|
||||||
getDisplayResolvedForApps?(): string | undefined;
|
getDisplayResolvedForApps?(): string | undefined
|
||||||
getTeachModeActive?(): boolean;
|
getTeachModeActive?(): boolean
|
||||||
/** Dims-only fallback when `lastScreenshot` is unset (cross-respawn).
|
/** Dims-only fallback when `lastScreenshot` is unset (cross-respawn).
|
||||||
* `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord
|
* `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord
|
||||||
* works and pixelCompare correctly skips. */
|
* works and pixelCompare correctly skips. */
|
||||||
getLastScreenshotDims?(): ScreenshotDims | undefined;
|
getLastScreenshotDims?(): ScreenshotDims | undefined
|
||||||
|
|
||||||
// ── Write-back callbacks ───────────────────────────────────────────
|
// ── Write-back callbacks ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -307,46 +307,46 @@ export interface ComputerUseSessionContext {
|
|||||||
onPermissionRequest?(
|
onPermissionRequest?(
|
||||||
req: CuPermissionRequest,
|
req: CuPermissionRequest,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CuPermissionResponse>;
|
): Promise<CuPermissionResponse>
|
||||||
/** Teach-mode sibling of `onPermissionRequest`. */
|
/** Teach-mode sibling of `onPermissionRequest`. */
|
||||||
onTeachPermissionRequest?(
|
onTeachPermissionRequest?(
|
||||||
req: CuTeachPermissionRequest,
|
req: CuTeachPermissionRequest,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<CuPermissionResponse>;
|
): Promise<CuPermissionResponse>
|
||||||
/** Called by `bindSessionContext` after merging a permission response into
|
/** Called by `bindSessionContext` after merging a permission response into
|
||||||
* the allowlist (dedupe on bundleId, truthy-only flag spread). Host
|
* the allowlist (dedupe on bundleId, truthy-only flag spread). Host
|
||||||
* persists for resume survival. */
|
* persists for resume survival. */
|
||||||
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void;
|
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void
|
||||||
onAppsHidden?(bundleIds: string[]): void;
|
onAppsHidden?(bundleIds: string[]): void
|
||||||
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
|
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
|
||||||
getClipboardStash?(): string | undefined;
|
getClipboardStash?(): string | undefined
|
||||||
/** Writes the clipboardGuard stash. undefined clears it. */
|
/** Writes the clipboardGuard stash. undefined clears it. */
|
||||||
onClipboardStashChanged?(stash: string | undefined): void;
|
onClipboardStashChanged?(stash: string | undefined): void
|
||||||
onResolvedDisplayUpdated?(displayId: number): void;
|
onResolvedDisplayUpdated?(displayId: number): void
|
||||||
onDisplayPinned?(displayId: number | undefined): void;
|
onDisplayPinned?(displayId: number | undefined): void
|
||||||
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void;
|
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void
|
||||||
/** Called after each screenshot. Host persists for respawn survival. */
|
/** Called after each screenshot. Host persists for respawn survival. */
|
||||||
onScreenshotCaptured?(dims: ScreenshotDims): void;
|
onScreenshotCaptured?(dims: ScreenshotDims): void
|
||||||
onTeachModeActivated?(): void;
|
onTeachModeActivated?(): void
|
||||||
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>;
|
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>
|
||||||
onTeachWorking?(): void;
|
onTeachWorking?(): void
|
||||||
|
|
||||||
// ── Lock (async) ───────────────────────────────────────────────────
|
// ── Lock (async) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** At most one session uses CU at a time. Awaited by `bindSessionContext`
|
/** At most one session uses CU at a time. Awaited by `bindSessionContext`
|
||||||
* before dispatch. Undefined → no lock gating (proceed). */
|
* 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`
|
/** Take the lock. Called when `checkCuLock` returned `holder: undefined`
|
||||||
* on a non-deferring tool. Host emits enter-CU signals here. */
|
* 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
|
/** Host-specific lock-held error text. Default is the package's generic
|
||||||
* message. The CLI host includes the holder session-ID prefix. */
|
* 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`
|
/** User-abort signal. Passed through to `ComputerUseOverrides.isAborted`
|
||||||
* for the mid-loop checks in handleComputerBatch / handleType. See that
|
* for the mid-loop checks in handleComputerBatch / handleType. See that
|
||||||
* field for semantics. */
|
* field for semantics. */
|
||||||
isAborted?(): boolean;
|
isAborted?(): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -360,9 +360,9 @@ export interface ComputerUseSessionContext {
|
|||||||
* store, not the server.
|
* store, not the server.
|
||||||
*/
|
*/
|
||||||
export interface ComputerUseOverrides {
|
export interface ComputerUseOverrides {
|
||||||
allowedApps: AppGrant[];
|
allowedApps: AppGrant[]
|
||||||
grantFlags: CuGrantFlags;
|
grantFlags: CuGrantFlags
|
||||||
coordinateMode: CoordinateMode;
|
coordinateMode: CoordinateMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-configured auto-deny list (Settings → Desktop app → Computer Use).
|
* 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.
|
* not session state). Contrast with `allowedApps` which is per-session.
|
||||||
* Empty array = no user-configured denies (the default).
|
* 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
|
* Display CU operates on; read fresh per call. `scaleCoord` uses the
|
||||||
* `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches
|
* `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches
|
||||||
* only affect the NEXT screenshot/prepare call.
|
* only affect the NEXT screenshot/prepare call.
|
||||||
*/
|
*/
|
||||||
selectedDisplayId?: number;
|
selectedDisplayId?: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `request_access` tool handler calls this and awaits. The wrapper
|
* 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.
|
* 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.
|
* 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,
|
* For the pixel-validation staleness guard. The model's-last-screenshot,
|
||||||
* stashed by serverDef.ts after each `screenshot` tool call. Undefined on
|
* stashed by serverDef.ts after each `screenshot` tool call. Undefined on
|
||||||
* cold start → pixel validation skipped (click proceeds).
|
* cold start → pixel validation skipped (click proceeds).
|
||||||
*/
|
*/
|
||||||
lastScreenshot?: ScreenshotResult;
|
lastScreenshot?: ScreenshotResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fired after every `prepareForAction` with the bundle IDs it just hid.
|
* 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
|
* Undefined when the session wasn't wired with a tracker — unhide just
|
||||||
* doesn't happen.
|
* doesn't happen.
|
||||||
*/
|
*/
|
||||||
onAppsHidden?: (bundleIds: string[]) => void;
|
onAppsHidden?: (bundleIds: string[]) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the clipboardGuard stash from session state. `undefined` means no
|
* 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
|
* and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern
|
||||||
* — state lives on the host's session, not module-level here.
|
* — 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.
|
* 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
|
* directly and restores via Electron's `clipboard.writeText` (no nest-only
|
||||||
* import surface).
|
* import surface).
|
||||||
*/
|
*/
|
||||||
onClipboardStashChanged?: (stash: string | undefined) => void;
|
onClipboardStashChanged?: (stash: string | undefined) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the resolver's picked display back to session so teach overlay
|
* 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`.
|
* `resolvePrepareCapture`'s pick differs from `selectedDisplayId`.
|
||||||
* Fire-and-forget.
|
* Fire-and-forget.
|
||||||
*/
|
*/
|
||||||
onResolvedDisplayUpdated?: (displayId: number) => void;
|
onResolvedDisplayUpdated?: (displayId: number) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set when the model explicitly picked a display via `switch_display`.
|
* 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
|
* overrides any `selectedDisplayId` whenever an allowed app shares the
|
||||||
* host's monitor.
|
* host's monitor.
|
||||||
*/
|
*/
|
||||||
displayPinnedByModel?: boolean;
|
displayPinnedByModel?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the model's explicit display pick to session. `displayId:
|
* 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 —
|
* Sibling of `onResolvedDisplayUpdated` but also sets the pin flag —
|
||||||
* the two are semantically distinct (resolver-picked vs model-picked).
|
* 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
|
* 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
|
* doesn't yank the display on every screenshot, only when the app set
|
||||||
* has changed since the last resolve (or manual switch).
|
* 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
|
* Records which app set the current display selection was made for. Fired
|
||||||
* alongside `onResolvedDisplayUpdated` when the resolver picks, so the next
|
* alongside `onResolvedDisplayUpdated` when the resolver picks, so the next
|
||||||
* screenshot sees a matching set and skips auto-resolve.
|
* 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
|
* 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
|
* The host manages release (on session idle/stop/archive) — this package
|
||||||
* never releases.
|
* 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
|
* 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
|
* undefined. No-op if already held (defensive — the check should have
|
||||||
* short-circuited). Host emits an event the overlay listens to.
|
* short-circuited). Host emits an event the overlay listens to.
|
||||||
*/
|
*/
|
||||||
acquireCuLock?: () => void;
|
acquireCuLock?: () => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-abort signal. Checked mid-iteration inside `handleComputerBatch`
|
* 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 —
|
* Undefined → never aborts (e.g. unwired host). Live per-check read —
|
||||||
* same lazy-getter pattern as `checkCuLock`.
|
* same lazy-getter pattern as `checkCuLock`.
|
||||||
*/
|
*/
|
||||||
isAborted?: () => boolean;
|
isAborted?: () => boolean
|
||||||
|
|
||||||
// ── Teach mode ───────────────────────────────────────────────────────
|
// ── Teach mode ───────────────────────────────────────────────────────
|
||||||
// Wired only when the host's teachModeEnabled gate is on. All five
|
// Wired only when the host's teachModeEnabled gate is on. All five
|
||||||
@@ -529,7 +531,7 @@ export interface ComputerUseOverrides {
|
|||||||
*/
|
*/
|
||||||
onTeachPermissionRequest?: (
|
onTeachPermissionRequest?: (
|
||||||
req: CuTeachPermissionRequest,
|
req: CuTeachPermissionRequest,
|
||||||
) => Promise<CuPermissionResponse>;
|
) => Promise<CuPermissionResponse>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by `handleRequestTeachAccess` after the user approves and at least
|
* 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")`)
|
* fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`)
|
||||||
* alongside the CU lock release.
|
* alongside the CU lock release.
|
||||||
*/
|
*/
|
||||||
onTeachModeActivated?: () => void;
|
onTeachModeActivated?: () => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read by `handleRequestAccess` and `handleRequestTeachAccess` to
|
* 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 a boolean field) because teach mode state lives on the session,
|
||||||
* not on this per-call overrides object.
|
* not on this per-call overrides object.
|
||||||
*/
|
*/
|
||||||
getTeachModeActive?: () => boolean;
|
getTeachModeActive?: () => boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by `handleTeachStep` with the scaled anchor + text. Host stores
|
* 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
|
* Same blocking-promise pattern as `onPermissionRequest`, but resolved by
|
||||||
* the teach overlay's own preload (not the main renderer's tool-approval UI).
|
* 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
|
* 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
|
* notch). The next `onTeachStep` call replaces the spinner with the new
|
||||||
* tooltip content.
|
* tooltip content.
|
||||||
*/
|
*/
|
||||||
onTeachWorking?: () => void;
|
onTeachWorking?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -590,13 +592,13 @@ export interface ComputerUseOverrides {
|
|||||||
* CSS coords match.
|
* CSS coords match.
|
||||||
*/
|
*/
|
||||||
export interface TeachStepRequest {
|
export interface TeachStepRequest {
|
||||||
explanation: string;
|
explanation: string
|
||||||
nextPreview: string;
|
nextPreview: string
|
||||||
/** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */
|
/** 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
|
* 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).
|
* fields it doesn't render (no grant-flag checkboxes in teach mode).
|
||||||
*/
|
*/
|
||||||
export interface CuTeachPermissionRequest {
|
export interface CuTeachPermissionRequest {
|
||||||
requestId: string;
|
requestId: string
|
||||||
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
|
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
|
||||||
reason: string;
|
reason: string
|
||||||
apps: ResolvedAppRequest[];
|
apps: ResolvedAppRequest[]
|
||||||
screenshotFiltering: "native" | "none";
|
screenshotFiltering: 'native' | 'none'
|
||||||
/** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */
|
/** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */
|
||||||
tccState?: {
|
tccState?: {
|
||||||
accessibility: boolean;
|
accessibility: boolean
|
||||||
screenRecording: boolean;
|
screenRecording: boolean
|
||||||
};
|
}
|
||||||
willHide?: Array<{ bundleId: string; displayName: string }>;
|
willHide?: Array<{ bundleId: string; displayName: string }>
|
||||||
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
|
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
|
||||||
autoUnhideEnabled?: boolean;
|
autoUnhideEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@ant/computer-use-swift",
|
"name": "@ant/computer-use-swift",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts"
|
"types": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ import { readFileSync, unlinkSync } from 'fs'
|
|||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type {
|
import type {
|
||||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
AppInfo,
|
||||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
AppsAPI,
|
||||||
SwiftBackend, WindowDisplayInfo,
|
DisplayAPI,
|
||||||
|
DisplayGeometry,
|
||||||
|
InstalledApp,
|
||||||
|
PrepareDisplayResult,
|
||||||
|
RunningApp,
|
||||||
|
ScreenshotAPI,
|
||||||
|
ScreenshotResult,
|
||||||
|
SwiftBackend,
|
||||||
|
WindowDisplayInfo,
|
||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -32,7 +40,8 @@ export type {
|
|||||||
function jxaSync(script: string): string {
|
function jxaSync(script: string): string {
|
||||||
const result = Bun.spawnSync({
|
const result = Bun.spawnSync({
|
||||||
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
|
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
return new TextDecoder().decode(result.stdout).trim()
|
return new TextDecoder().decode(result.stdout).trim()
|
||||||
}
|
}
|
||||||
@@ -40,14 +49,16 @@ function jxaSync(script: string): string {
|
|||||||
function osascriptSync(script: string): string {
|
function osascriptSync(script: string): string {
|
||||||
const result = Bun.spawnSync({
|
const result = Bun.spawnSync({
|
||||||
cmd: ['osascript', '-e', script],
|
cmd: ['osascript', '-e', script],
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
return new TextDecoder().decode(result.stdout).trim()
|
return new TextDecoder().decode(result.stdout).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function osascript(script: string): Promise<string> {
|
async function osascript(script: string): Promise<string> {
|
||||||
const proc = Bun.spawn(['osascript', '-e', script], {
|
const proc = Bun.spawn(['osascript', '-e', script], {
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
const text = await new Response(proc.stdout).text()
|
const text = await new Response(proc.stdout).text()
|
||||||
await proc.exited
|
await proc.exited
|
||||||
@@ -56,7 +67,8 @@ async function osascript(script: string): Promise<string> {
|
|||||||
|
|
||||||
async function jxa(script: string): Promise<string> {
|
async function jxa(script: string): Promise<string> {
|
||||||
const proc = Bun.spawn(['osascript', '-l', 'JavaScript', '-e', script], {
|
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()
|
const text = await new Response(proc.stdout).text()
|
||||||
await proc.exited
|
await proc.exited
|
||||||
@@ -101,8 +113,10 @@ export const display: DisplayAPI = {
|
|||||||
JSON.stringify(result);
|
JSON.stringify(result);
|
||||||
`)
|
`)
|
||||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||||
width: Number(d.width), height: Number(d.height),
|
width: Number(d.width),
|
||||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
height: Number(d.height),
|
||||||
|
scaleFactor: Number(d.scaleFactor),
|
||||||
|
displayId: Number(d.displayId),
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
@@ -126,8 +140,10 @@ export const display: DisplayAPI = {
|
|||||||
JSON.stringify(result);
|
JSON.stringify(result);
|
||||||
`)
|
`)
|
||||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||||
width: Number(d.width), height: Number(d.height),
|
width: Number(d.width),
|
||||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
height: Number(d.height),
|
||||||
|
scaleFactor: Number(d.scaleFactor),
|
||||||
|
displayId: Number(d.displayId),
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
|
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
|
||||||
@@ -177,9 +193,15 @@ export const apps: AppsAPI = {
|
|||||||
const dirs = ['/Applications', '~/Applications', '/System/Applications']
|
const dirs = ['/Applications', '~/Applications', '/System/Applications']
|
||||||
const allApps: InstalledApp[] = []
|
const allApps: InstalledApp[] = []
|
||||||
for (const dir of dirs) {
|
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(
|
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' },
|
{ stdout: 'pipe', stderr: 'pipe' },
|
||||||
)
|
)
|
||||||
const text = await new Response(proc.stdout).text()
|
const text = await new Response(proc.stdout).text()
|
||||||
@@ -245,10 +267,13 @@ export const apps: AppsAPI = {
|
|||||||
// ScreenshotAPI
|
// 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 tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`)
|
||||||
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
|
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
await proc.exited
|
await proc.exited
|
||||||
try {
|
try {
|
||||||
@@ -258,18 +283,36 @@ async function captureScreenToBase64(args: string[]): Promise<{ base64: string;
|
|||||||
const height = buf.readUInt32BE(20)
|
const height = buf.readUInt32BE(20)
|
||||||
return { base64, width, height }
|
return { base64, width, height }
|
||||||
} finally {
|
} finally {
|
||||||
try { unlinkSync(tmpFile) } catch {}
|
try {
|
||||||
|
unlinkSync(tmpFile)
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const screenshot: ScreenshotAPI = {
|
export const screenshot: ScreenshotAPI = {
|
||||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
|
async captureExcluding(
|
||||||
|
_allowedBundleIds,
|
||||||
|
_quality,
|
||||||
|
_targetW,
|
||||||
|
_targetH,
|
||||||
|
displayId,
|
||||||
|
) {
|
||||||
const args = ['-x']
|
const args = ['-x']
|
||||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||||
return captureScreenToBase64(args)
|
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}`]
|
const args = ['-x', '-R', `${x},${y},${w},${h}`]
|
||||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||||
return captureScreenToBase64(args)
|
return captureScreenToBase64(args)
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
AppInfo,
|
||||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
AppsAPI,
|
||||||
SwiftBackend, WindowDisplayInfo,
|
DisplayAPI,
|
||||||
|
DisplayGeometry,
|
||||||
|
InstalledApp,
|
||||||
|
PrepareDisplayResult,
|
||||||
|
RunningApp,
|
||||||
|
ScreenshotAPI,
|
||||||
|
ScreenshotResult,
|
||||||
|
SwiftBackend,
|
||||||
|
WindowDisplayInfo,
|
||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,7 +42,11 @@ async function runAsync(cmd: string[]): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commandExists(name: string): boolean {
|
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
|
return result.exitCode === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +97,11 @@ export const display: DisplayAPI = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const apps: AppsAPI = {
|
export const apps: AppsAPI = {
|
||||||
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId): Promise<PrepareDisplayResult> {
|
async prepareDisplay(
|
||||||
|
_allowlistBundleIds,
|
||||||
|
_surrogateHost,
|
||||||
|
_displayId,
|
||||||
|
): Promise<PrepareDisplayResult> {
|
||||||
return { activated: '', hidden: [] }
|
return { activated: '', hidden: [] }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -100,7 +116,15 @@ export const apps: AppsAPI = {
|
|||||||
async appUnderPoint(x, y): Promise<AppInfo | null> {
|
async appUnderPoint(x, y): Promise<AppInfo | null> {
|
||||||
try {
|
try {
|
||||||
// Move mouse to point, get window under cursor
|
// 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+)/)
|
const windowMatch = out.match(/WINDOW=(\d+)/)
|
||||||
if (!windowMatch) return null
|
if (!windowMatch) return null
|
||||||
|
|
||||||
@@ -109,10 +133,18 @@ export const apps: AppsAPI = {
|
|||||||
if (!pidStr) return null
|
if (!pidStr) return null
|
||||||
|
|
||||||
let exePath = ''
|
let exePath = ''
|
||||||
try { exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`]) } catch { /* ignore */ }
|
try {
|
||||||
|
exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`])
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
let appName = ''
|
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
|
if (!exePath && !appName) return null
|
||||||
return { bundleId: exePath || pidStr!, displayName: appName || 'unknown' }
|
return { bundleId: exePath || pidStr!, displayName: appName || 'unknown' }
|
||||||
@@ -124,14 +156,20 @@ export const apps: AppsAPI = {
|
|||||||
async listInstalled(): Promise<InstalledApp[]> {
|
async listInstalled(): Promise<InstalledApp[]> {
|
||||||
try {
|
try {
|
||||||
// Read .desktop files from standard locations
|
// 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[] = []
|
const apps: InstalledApp[] = []
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
let files: string
|
let files: string
|
||||||
try {
|
try {
|
||||||
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
|
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
|
||||||
} catch { continue }
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for (const filepath of files.split('\n').filter(Boolean)) {
|
for (const filepath of files.split('\n').filter(Boolean)) {
|
||||||
try {
|
try {
|
||||||
@@ -146,11 +184,14 @@ export const apps: AppsAPI = {
|
|||||||
if (!name) continue
|
if (!name) continue
|
||||||
|
|
||||||
apps.push({
|
apps.push({
|
||||||
bundleId: filepath.split('/').pop()?.replace('.desktop', '') ?? '',
|
bundleId:
|
||||||
|
filepath.split('/').pop()?.replace('.desktop', '') ?? '',
|
||||||
displayName: name,
|
displayName: name,
|
||||||
path: exec.split(/\s+/)[0] ?? '',
|
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
|
if (!pid || pid === '0') continue
|
||||||
|
|
||||||
let exePath = ''
|
let exePath = ''
|
||||||
try { exePath = run(['readlink', '-f', `/proc/${pid}/exe`]) } catch { /* ignore */ }
|
try {
|
||||||
|
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
let appName = ''
|
let appName = ''
|
||||||
try { appName = run(['cat', `/proc/${pid}/comm`]) } catch { /* ignore */ }
|
try {
|
||||||
|
appName = run(['cat', `/proc/${pid}/comm`])
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
if (appName) {
|
if (appName) {
|
||||||
apps.push({ bundleId: exePath || pid, displayName: appName })
|
apps.push({ bundleId: exePath || pid, displayName: appName })
|
||||||
@@ -187,11 +236,13 @@ export const apps: AppsAPI = {
|
|||||||
}
|
}
|
||||||
// Deduplicate by bundleId
|
// Deduplicate by bundleId
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
return apps.filter(a => {
|
return apps
|
||||||
if (seen.has(a.bundleId)) return false
|
.filter(a => {
|
||||||
seen.add(a.bundleId)
|
if (seen.has(a.bundleId)) return false
|
||||||
return true
|
seen.add(a.bundleId)
|
||||||
}).slice(0, 50)
|
return true
|
||||||
|
})
|
||||||
|
.slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: ps with visible processes
|
// Fallback: ps with visible processes
|
||||||
@@ -217,7 +268,9 @@ export const apps: AppsAPI = {
|
|||||||
await runAsync(['gtk-launch', desktopName])
|
await runAsync(['gtk-launch', desktopName])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch { /* fall through */ }
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
|
||||||
await runAsync(['xdg-open', name])
|
await runAsync(['xdg-open', name])
|
||||||
},
|
},
|
||||||
@@ -232,7 +285,9 @@ export const apps: AppsAPI = {
|
|||||||
// Try xdotool windowactivate with search by name
|
// Try xdotool windowactivate with search by name
|
||||||
await runAsync(['xdotool', 'search', '--name', id, 'windowactivate'])
|
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'
|
const SCREENSHOT_PATH = '/tmp/cu-screenshot.png'
|
||||||
|
|
||||||
export const screenshot: ScreenshotAPI = {
|
export const screenshot: ScreenshotAPI = {
|
||||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, _displayId): Promise<ScreenshotResult> {
|
async captureExcluding(
|
||||||
|
_allowedBundleIds,
|
||||||
|
_quality,
|
||||||
|
_targetW,
|
||||||
|
_targetH,
|
||||||
|
_displayId,
|
||||||
|
): Promise<ScreenshotResult> {
|
||||||
try {
|
try {
|
||||||
await runAsync(['scrot', '-o', SCREENSHOT_PATH])
|
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 {
|
try {
|
||||||
// scrot -a x,y,w,h captures a specific region
|
// 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 file = Bun.file(SCREENSHOT_PATH)
|
||||||
const buffer = await file.arrayBuffer()
|
const buffer = await file.arrayBuffer()
|
||||||
|
|||||||
@@ -6,13 +6,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
AppInfo,
|
||||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
AppsAPI,
|
||||||
SwiftBackend, WindowDisplayInfo,
|
DisplayAPI,
|
||||||
|
DisplayGeometry,
|
||||||
|
InstalledApp,
|
||||||
|
PrepareDisplayResult,
|
||||||
|
RunningApp,
|
||||||
|
ScreenshotAPI,
|
||||||
|
ScreenshotResult,
|
||||||
|
SwiftBackend,
|
||||||
|
WindowDisplayInfo,
|
||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
|
|
||||||
import { listWindows } from 'src/utils/computerUse/win32/windowEnum.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
|
// PowerShell helper
|
||||||
@@ -63,15 +74,18 @@ foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
|
|||||||
}
|
}
|
||||||
$result -join "|"
|
$result -join "|"
|
||||||
`)
|
`)
|
||||||
return raw.split('|').filter(Boolean).map(entry => {
|
return raw
|
||||||
const [w, h, id, primary] = entry.split(',')
|
.split('|')
|
||||||
return {
|
.filter(Boolean)
|
||||||
width: Number(w),
|
.map(entry => {
|
||||||
height: Number(h),
|
const [w, h, id, primary] = entry.split(',')
|
||||||
scaleFactor: 1, // Windows DPI scaling handled at system level
|
return {
|
||||||
displayId: Number(id),
|
width: Number(w),
|
||||||
}
|
height: Number(h),
|
||||||
})
|
scaleFactor: 1, // Windows DPI scaling handled at system level
|
||||||
|
displayId: Number(id),
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
|
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
|
$apps | Select-Object -Unique | Select-Object -First 200
|
||||||
`)
|
`)
|
||||||
return raw.split('\n').filter(Boolean).map(line => {
|
return raw
|
||||||
const [name, path, id] = line.split('|', 3)
|
.split('\n')
|
||||||
return {
|
.filter(Boolean)
|
||||||
bundleId: id ?? name ?? '',
|
.map(line => {
|
||||||
displayName: name ?? '',
|
const [name, path, id] = line.split('|', 3)
|
||||||
path: path ?? '',
|
return {
|
||||||
}
|
bundleId: id ?? name ?? '',
|
||||||
})
|
displayName: name ?? '',
|
||||||
|
path: path ?? '',
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -204,7 +221,13 @@ if ($proc) { [WinShow]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null; [WinSh
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const screenshot: ScreenshotAPI = {
|
export const screenshot: ScreenshotAPI = {
|
||||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
|
async captureExcluding(
|
||||||
|
_allowedBundleIds,
|
||||||
|
_quality,
|
||||||
|
_targetW,
|
||||||
|
_targetH,
|
||||||
|
displayId,
|
||||||
|
) {
|
||||||
const raw = await psAsync(`
|
const raw = await psAsync(`
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
Add-Type -AssemblyName System.Drawing
|
Add-Type -AssemblyName System.Drawing
|
||||||
@@ -229,7 +252,17 @@ $ms.Dispose()
|
|||||||
return { base64, width, height }
|
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(`
|
const raw = await psAsync(`
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
Add-Type -AssemblyName System.Drawing
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|||||||
@@ -37,25 +37,52 @@ const backend = loadBackend()
|
|||||||
|
|
||||||
export class ComputerUseAPI {
|
export class ComputerUseAPI {
|
||||||
apps = backend?.apps ?? {
|
apps = backend?.apps ?? {
|
||||||
async prepareDisplay() { return { activated: '', hidden: [] } },
|
async prepareDisplay() {
|
||||||
async previewHideSet() { return [] },
|
return { activated: '', hidden: [] }
|
||||||
async findWindowDisplays(ids: string[]) { return ids.map((b: string) => ({ bundleId: b, displayIds: [] as number[] })) },
|
},
|
||||||
async appUnderPoint() { return null },
|
async previewHideSet() {
|
||||||
async listInstalled() { return [] },
|
return []
|
||||||
iconDataUrl() { return null },
|
},
|
||||||
listRunning() { return [] },
|
async findWindowDisplays(ids: string[]) {
|
||||||
async open() { throw new Error('@ant/computer-use-swift: macOS only') },
|
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() {},
|
async unhide() {},
|
||||||
}
|
}
|
||||||
|
|
||||||
display = backend?.display ?? {
|
display = backend?.display ?? {
|
||||||
getSize() { throw new Error('@ant/computer-use-swift: macOS only') },
|
getSize() {
|
||||||
listAll() { throw new Error('@ant/computer-use-swift: macOS only') },
|
throw new Error('@ant/computer-use-swift: macOS only')
|
||||||
|
},
|
||||||
|
listAll() {
|
||||||
|
throw new Error('@ant/computer-use-swift: macOS only')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
screenshot = backend?.screenshot ?? {
|
screenshot = backend?.screenshot ?? {
|
||||||
async captureExcluding() { throw new Error('@ant/computer-use-swift: macOS only') },
|
async captureExcluding() {
|
||||||
async captureRegion() { throw new Error('@ant/computer-use-swift: macOS only') },
|
throw new Error('@ant/computer-use-swift: macOS only')
|
||||||
|
},
|
||||||
|
async captureRegion() {
|
||||||
|
throw new Error('@ant/computer-use-swift: macOS only')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolvePrepareCapture(
|
async resolvePrepareCapture(
|
||||||
@@ -66,6 +93,12 @@ export class ComputerUseAPI {
|
|||||||
targetH: number,
|
targetH: number,
|
||||||
displayId?: number,
|
displayId?: number,
|
||||||
): Promise<ResolvePrepareCaptureResult> {
|
): 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 {
|
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[]>
|
previewHideSet(bundleIds: string[], displayId?: number): Promise<AppInfo[]>
|
||||||
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
|
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
|
||||||
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
|
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
|
||||||
@@ -68,13 +72,22 @@ export interface AppsAPI {
|
|||||||
|
|
||||||
export interface ScreenshotAPI {
|
export interface ScreenshotAPI {
|
||||||
captureExcluding(
|
captureExcluding(
|
||||||
allowedBundleIds: string[], quality: number,
|
allowedBundleIds: string[],
|
||||||
targetW: number, targetH: number, displayId?: number,
|
quality: number,
|
||||||
|
targetW: number,
|
||||||
|
targetH: number,
|
||||||
|
displayId?: number,
|
||||||
): Promise<ScreenshotResult>
|
): Promise<ScreenshotResult>
|
||||||
captureRegion(
|
captureRegion(
|
||||||
allowedBundleIds: string[],
|
allowedBundleIds: string[],
|
||||||
x: number, y: number, w: number, h: number,
|
x: number,
|
||||||
outW: number, outH: number, quality: number, displayId?: number,
|
y: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
outW: number,
|
||||||
|
outH: number,
|
||||||
|
quality: number,
|
||||||
|
displayId?: number,
|
||||||
): Promise<ScreenshotResult>
|
): Promise<ScreenshotResult>
|
||||||
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
|
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
import React, {
|
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react';
|
||||||
type PropsWithChildren,
|
import instances from '../core/instances.js';
|
||||||
useContext,
|
|
||||||
useInsertionEffect,
|
|
||||||
} from 'react'
|
|
||||||
import instances from '../core/instances.js'
|
|
||||||
import {
|
import {
|
||||||
DISABLE_MOUSE_TRACKING,
|
DISABLE_MOUSE_TRACKING,
|
||||||
ENABLE_MOUSE_TRACKING,
|
ENABLE_MOUSE_TRACKING,
|
||||||
ENTER_ALT_SCREEN,
|
ENTER_ALT_SCREEN,
|
||||||
EXIT_ALT_SCREEN,
|
EXIT_ALT_SCREEN,
|
||||||
} from '../core/termio/dec.js'
|
} from '../core/termio/dec.js';
|
||||||
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js'
|
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js';
|
||||||
import Box from './Box.js'
|
import Box from './Box.js';
|
||||||
import { TerminalSizeContext } from './TerminalSizeContext.js'
|
import { TerminalSizeContext } from './TerminalSizeContext.js';
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
type Props = PropsWithChildren<{
|
||||||
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
|
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
|
||||||
mouseTracking?: boolean
|
mouseTracking?: boolean;
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run children in the terminal's alternate screen buffer, constrained to
|
* 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
|
* from scrolling content) and so signal-exit cleanup can exit the alt
|
||||||
* screen if the component's own unmount doesn't run.
|
* screen if the component's own unmount doesn't run.
|
||||||
*/
|
*/
|
||||||
export function AlternateScreen({
|
export function AlternateScreen({ children, mouseTracking = true }: Props): React.ReactNode {
|
||||||
children,
|
const size = useContext(TerminalSizeContext);
|
||||||
mouseTracking = true,
|
const writeRaw = useContext(TerminalWriteContext);
|
||||||
}: Props): React.ReactNode {
|
|
||||||
const size = useContext(TerminalSizeContext)
|
|
||||||
const writeRaw = useContext(TerminalWriteContext)
|
|
||||||
|
|
||||||
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
|
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
|
||||||
// resetAfterCommit between the mutation and layout commit phases, and
|
// 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
|
// Cleanup timing is unchanged: both insertion and layout effect cleanup
|
||||||
// run in the mutation phase on unmount, before resetAfterCommit.
|
// run in the mutation phase on unmount, before resetAfterCommit.
|
||||||
useInsertionEffect(() => {
|
useInsertionEffect(() => {
|
||||||
const ink = instances.get(process.stdout)
|
const ink = instances.get(process.stdout);
|
||||||
if (!writeRaw) return
|
if (!writeRaw) return;
|
||||||
|
|
||||||
writeRaw(
|
writeRaw(ENTER_ALT_SCREEN + '\x1b[2J\x1b[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''));
|
||||||
ENTER_ALT_SCREEN +
|
ink?.setAltScreenActive(true, mouseTracking);
|
||||||
'\x1b[2J\x1b[H' +
|
|
||||||
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
|
|
||||||
)
|
|
||||||
ink?.setAltScreenActive(true, mouseTracking)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ink?.setAltScreenActive(false)
|
ink?.setAltScreenActive(false);
|
||||||
ink?.clearTextSelection()
|
ink?.clearTextSelection();
|
||||||
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
|
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN);
|
||||||
}
|
};
|
||||||
}, [writeRaw, mouseTracking])
|
}, [writeRaw, mouseTracking]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box flexDirection="column" height={size?.rows ?? 24} width="100%" flexShrink={0}>
|
||||||
flexDirection="column"
|
|
||||||
height={size?.rows ?? 24}
|
|
||||||
width="100%"
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</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
|
// Business-layer callbacks — replaced with inline defaults so this package
|
||||||
// has zero dependencies on business code. The business layer can inject
|
// has zero dependencies on business code. The business layer can inject
|
||||||
// implementations via AppCallbacks when needed.
|
// implementations via AppCallbacks when needed.
|
||||||
type AppCallbacks = {
|
type AppCallbacks = {
|
||||||
updateLastInteractionTime?: () => void
|
updateLastInteractionTime?: () => void;
|
||||||
stopCapturingEarlyInput?: () => void
|
stopCapturingEarlyInput?: () => void;
|
||||||
isMouseClicksDisabled?: () => boolean
|
isMouseClicksDisabled?: () => boolean;
|
||||||
logError?: (error: unknown) => void
|
logError?: (error: unknown) => void;
|
||||||
logForDebugging?: (message: string, opts?: { level?: string }) => void
|
logForDebugging?: (message: string, opts?: { level?: string }) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Default no-op / safe-default implementations */
|
/** Default no-op / safe-default implementations */
|
||||||
const defaultCallbacks: Required<AppCallbacks> = {
|
const defaultCallbacks: Required<AppCallbacks> = {
|
||||||
@@ -17,46 +17,34 @@ const defaultCallbacks: Required<AppCallbacks> = {
|
|||||||
isMouseClicksDisabled: () => false,
|
isMouseClicksDisabled: () => false,
|
||||||
logError: (error: unknown) => console.error(error),
|
logError: (error: unknown) => console.error(error),
|
||||||
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
|
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override the default no-op callbacks. Call this from the business layer
|
* Override the default no-op callbacks. Call this from the business layer
|
||||||
* (e.g. src/ink.tsx) before mounting <App>.
|
* (e.g. src/ink.tsx) before mounting <App>.
|
||||||
*/
|
*/
|
||||||
export function setAppCallbacks(cb: AppCallbacks): void {
|
export function setAppCallbacks(cb: AppCallbacks): void {
|
||||||
Object.assign(defaultCallbacks, cb)
|
Object.assign(defaultCallbacks, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEnvTruthy(value: string | undefined): boolean {
|
function isEnvTruthy(value: string | undefined): boolean {
|
||||||
return value === '1' || value === 'true'
|
return value === '1' || value === 'true';
|
||||||
}
|
}
|
||||||
import { EventEmitter } from '../core/events/emitter.js'
|
import { EventEmitter } from '../core/events/emitter.js';
|
||||||
import { InputEvent } from '../core/events/input-event.js'
|
import { InputEvent } from '../core/events/input-event.js';
|
||||||
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js'
|
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js';
|
||||||
import {
|
import {
|
||||||
INITIAL_STATE,
|
INITIAL_STATE,
|
||||||
type ParsedInput,
|
type ParsedInput,
|
||||||
type ParsedKey,
|
type ParsedKey,
|
||||||
type ParsedMouse,
|
type ParsedMouse,
|
||||||
parseMultipleKeypresses,
|
parseMultipleKeypresses,
|
||||||
} from '../core/parse-keypress.js'
|
} from '../core/parse-keypress.js';
|
||||||
import reconciler from '../core/reconciler.js'
|
import reconciler from '../core/reconciler.js';
|
||||||
import {
|
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
|
||||||
finishSelection,
|
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
|
||||||
hasSelection,
|
import { getTerminalFocused, setTerminalFocused } from '../core/terminal-focus-state.js';
|
||||||
type SelectionState,
|
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js';
|
||||||
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 {
|
import {
|
||||||
DISABLE_KITTY_KEYBOARD,
|
DISABLE_KITTY_KEYBOARD,
|
||||||
DISABLE_MODIFY_OTHER_KEYS,
|
DISABLE_MODIFY_OTHER_KEYS,
|
||||||
@@ -64,155 +52,145 @@ import {
|
|||||||
ENABLE_MODIFY_OTHER_KEYS,
|
ENABLE_MODIFY_OTHER_KEYS,
|
||||||
FOCUS_IN,
|
FOCUS_IN,
|
||||||
FOCUS_OUT,
|
FOCUS_OUT,
|
||||||
} from '../core/termio/csi.js'
|
} from '../core/termio/csi.js';
|
||||||
import {
|
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../core/termio/dec.js';
|
||||||
DBP,
|
import AppContext from './AppContext.js';
|
||||||
DFE,
|
import { ClockProvider } from './ClockContext.js';
|
||||||
DISABLE_MOUSE_TRACKING,
|
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
|
||||||
EBP,
|
import ErrorOverview from './ErrorOverview.js';
|
||||||
EFE,
|
import StdinContext from './StdinContext.js';
|
||||||
HIDE_CURSOR,
|
import { TerminalFocusProvider } from './TerminalFocusContext.js';
|
||||||
SHOW_CURSOR,
|
import { TerminalSizeContext } from './TerminalSizeContext.js';
|
||||||
} 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)
|
// 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
|
// After this many milliseconds of stdin silence, the next chunk triggers
|
||||||
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
|
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
|
||||||
// ssh reconnect, and laptop wake — the terminal resets DEC private modes
|
// 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 no signal reaches us. 5s is well above normal inter-keystroke gaps
|
||||||
// but short enough that the first scroll after reattach works.
|
// but short enough that the first scroll after reattach works.
|
||||||
const STDIN_RESUME_GAP_MS = 5000
|
const STDIN_RESUME_GAP_MS = 5000;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly children: ReactNode
|
readonly children: ReactNode;
|
||||||
readonly stdin: NodeJS.ReadStream
|
readonly stdin: NodeJS.ReadStream;
|
||||||
readonly stdout: NodeJS.WriteStream
|
readonly stdout: NodeJS.WriteStream;
|
||||||
readonly stderr: NodeJS.WriteStream
|
readonly stderr: NodeJS.WriteStream;
|
||||||
readonly exitOnCtrlC: boolean
|
readonly exitOnCtrlC: boolean;
|
||||||
readonly onExit: (error?: Error) => void
|
readonly onExit: (error?: Error) => void;
|
||||||
readonly terminalColumns: number
|
readonly terminalColumns: number;
|
||||||
readonly terminalRows: number
|
readonly terminalRows: number;
|
||||||
// Text selection state. App mutates this directly from mouse events
|
// Text selection state. App mutates this directly from mouse events
|
||||||
// and calls onSelectionChange to trigger a repaint. Mouse events only
|
// and calls onSelectionChange to trigger a repaint. Mouse events only
|
||||||
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
|
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
|
||||||
// so the handler is always wired but dormant until tracking is on.
|
// so the handler is always wired but dormant until tracking is on.
|
||||||
readonly selection: SelectionState
|
readonly selection: SelectionState;
|
||||||
readonly onSelectionChange: () => void
|
readonly onSelectionChange: () => void;
|
||||||
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
|
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
|
||||||
// onClick handlers. Returns true if a DOM handler consumed the click.
|
// onClick handlers. Returns true if a DOM handler consumed the click.
|
||||||
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
|
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
|
||||||
// gates on altScreenActive).
|
// 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
|
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
|
||||||
// DOM elements. Called for mode-1003 motion events with no button held.
|
// DOM elements. Called for mode-1003 motion events with no button held.
|
||||||
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
|
// 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
|
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
|
||||||
// time. Returns the URL or undefined. The browser-open is deferred by
|
// time. Returns the URL or undefined. The browser-open is deferred by
|
||||||
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
|
// 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.
|
// 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
|
// 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
|
// the word under the cursor; count=3 selects the line. Ink reads the
|
||||||
// screen buffer to find word/line boundaries and mutates selection,
|
// screen buffer to find word/line boundaries and mutates selection,
|
||||||
// setting isDragging=true so a subsequent drag extends by word/line.
|
// 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
|
// Called on drag-motion. Mode-aware: char mode updates focus to the
|
||||||
// exact cell; word/line mode snaps to word/line boundaries. Needs
|
// exact cell; word/line mode snaps to word/line boundaries. Needs
|
||||||
// screen-buffer access (word boundaries) so lives on Ink, not here.
|
// 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.
|
// Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
|
||||||
// Ink re-asserts terminal modes: extended key reporting, and (when in
|
// Ink re-asserts terminal modes: extended key reporting, and (when in
|
||||||
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
|
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
|
||||||
// terminal side. Optional so testing.tsx doesn't need to stub it.
|
// 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
|
// Receives the declared native-cursor position from useDeclaredCursor
|
||||||
// so ink.tsx can park the terminal cursor there after each frame.
|
// so ink.tsx can park the terminal cursor there after each frame.
|
||||||
// Enables IME composition at the input caret and lets screen readers /
|
// Enables IME composition at the input caret and lets screen readers /
|
||||||
// magnifiers track the input. Optional so testing.tsx doesn't stub it.
|
// 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
|
// Dispatch a keyboard event through the DOM tree. Called for each
|
||||||
// parsed key alongside the legacy EventEmitter path.
|
// 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
|
// Multi-click detection thresholds. 500ms is the macOS default; a small
|
||||||
// position tolerance allows for trackpad jitter between clicks.
|
// position tolerance allows for trackpad jitter between clicks.
|
||||||
const MULTI_CLICK_TIMEOUT_MS = 500
|
const MULTI_CLICK_TIMEOUT_MS = 500;
|
||||||
const MULTI_CLICK_DISTANCE = 1
|
const MULTI_CLICK_DISTANCE = 1;
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
readonly error?: Error
|
readonly error?: Error;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Root component for all Ink apps
|
// Root component for all Ink apps
|
||||||
// It renders stdin and stdout contexts, so that children can access them if needed
|
// It renders stdin and stdout contexts, so that children can access them if needed
|
||||||
// It also handles Ctrl+C exiting and cursor visibility
|
// It also handles Ctrl+C exiting and cursor visibility
|
||||||
export default class App extends PureComponent<Props, State> {
|
export default class App extends PureComponent<Props, State> {
|
||||||
static displayName = 'InternalApp'
|
static displayName = 'InternalApp';
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
static getDerivedStateFromError(error: Error) {
|
||||||
return { error }
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
override state = {
|
override state = {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
// Count how many components enabled raw mode to avoid disabling
|
// Count how many components enabled raw mode to avoid disabling
|
||||||
// raw mode until all components don't need it anymore
|
// raw mode until all components don't need it anymore
|
||||||
rawModeEnabledCount = 0
|
rawModeEnabledCount = 0;
|
||||||
|
|
||||||
internal_eventEmitter = new EventEmitter()
|
internal_eventEmitter = new EventEmitter();
|
||||||
keyParseState = INITIAL_STATE
|
keyParseState = INITIAL_STATE;
|
||||||
// Timer for flushing incomplete escape sequences
|
// Timer for flushing incomplete escape sequences
|
||||||
incompleteEscapeTimer: NodeJS.Timeout | null = null
|
incompleteEscapeTimer: NodeJS.Timeout | null = null;
|
||||||
// Timeout durations for incomplete sequences (ms)
|
// Timeout durations for incomplete sequences (ms)
|
||||||
readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
|
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
|
||||||
readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
|
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
|
||||||
|
|
||||||
// Terminal query/response dispatch. Responses arrive on stdin (parsed
|
// Terminal query/response dispatch. Responses arrive on stdin (parsed
|
||||||
// out by parse-keypress) and are routed to pending promise resolvers.
|
// 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
|
// Multi-click tracking for double/triple-click text selection. A click
|
||||||
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
|
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
|
||||||
// click increments clickCount; otherwise it resets to 1.
|
// click increments clickCount; otherwise it resets to 1.
|
||||||
lastClickTime = 0
|
lastClickTime = 0;
|
||||||
lastClickCol = -1
|
lastClickCol = -1;
|
||||||
lastClickRow = -1
|
lastClickRow = -1;
|
||||||
clickCount = 0
|
clickCount = 0;
|
||||||
// Deferred hyperlink-open timer — cancelled if a second click arrives
|
// Deferred hyperlink-open timer — cancelled if a second click arrives
|
||||||
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
|
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
|
||||||
// the word without also opening the browser). DOM onClick dispatch is
|
// the word without also opening the browser). DOM onClick dispatch is
|
||||||
// NOT deferred — it returns true from onClickAt and skips this timer.
|
// 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
|
// Last mode-1003 motion position. Terminals already dedupe to cell
|
||||||
// granularity but this also lets us skip dispatchHover entirely on
|
// granularity but this also lets us skip dispatchHover entirely on
|
||||||
// repeat events (drag-then-release at same cell, etc.).
|
// repeat events (drag-then-release at same cell, etc.).
|
||||||
lastHoverCol = -1
|
lastHoverCol = -1;
|
||||||
lastHoverRow = -1
|
lastHoverRow = -1;
|
||||||
|
|
||||||
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
|
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
|
||||||
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
|
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
|
||||||
// Initialized to now so startup doesn't false-trigger.
|
// Initialized to now so startup doesn't false-trigger.
|
||||||
lastStdinTime = Date.now()
|
lastStdinTime = Date.now();
|
||||||
|
|
||||||
// Determines if TTY is supported on the provided stdin
|
// Determines if TTY is supported on the provided stdin
|
||||||
isRawModeSupported(): boolean {
|
isRawModeSupported(): boolean {
|
||||||
return this.props.stdin.isTTY
|
return this.props.stdin.isTTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -242,56 +220,47 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
>
|
>
|
||||||
<TerminalFocusProvider>
|
<TerminalFocusProvider>
|
||||||
<ClockProvider>
|
<ClockProvider>
|
||||||
<CursorDeclarationContext.Provider
|
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
||||||
value={this.props.onCursorDeclaration ?? (() => {})}
|
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
|
||||||
>
|
|
||||||
{this.state.error ? (
|
|
||||||
<ErrorOverview error={this.state.error as Error} />
|
|
||||||
) : (
|
|
||||||
this.props.children
|
|
||||||
)}
|
|
||||||
</CursorDeclarationContext.Provider>
|
</CursorDeclarationContext.Provider>
|
||||||
</ClockProvider>
|
</ClockProvider>
|
||||||
</TerminalFocusProvider>
|
</TerminalFocusProvider>
|
||||||
</StdinContext.Provider>
|
</StdinContext.Provider>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
</TerminalSizeContext.Provider>
|
</TerminalSizeContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override componentDidMount() {
|
override componentDidMount() {
|
||||||
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
|
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
|
||||||
if (
|
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
|
||||||
this.props.stdout.isTTY &&
|
this.props.stdout.write(HIDE_CURSOR);
|
||||||
!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
|
|
||||||
) {
|
|
||||||
this.props.stdout.write(HIDE_CURSOR)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override componentWillUnmount() {
|
override componentWillUnmount() {
|
||||||
if (this.props.stdout.isTTY) {
|
if (this.props.stdout.isTTY) {
|
||||||
this.props.stdout.write(SHOW_CURSOR)
|
this.props.stdout.write(SHOW_CURSOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any pending timers
|
// Clear any pending timers
|
||||||
if (this.incompleteEscapeTimer) {
|
if (this.incompleteEscapeTimer) {
|
||||||
clearTimeout(this.incompleteEscapeTimer)
|
clearTimeout(this.incompleteEscapeTimer);
|
||||||
this.incompleteEscapeTimer = null
|
this.incompleteEscapeTimer = null;
|
||||||
}
|
}
|
||||||
if (this.pendingHyperlinkTimer) {
|
if (this.pendingHyperlinkTimer) {
|
||||||
clearTimeout(this.pendingHyperlinkTimer)
|
clearTimeout(this.pendingHyperlinkTimer);
|
||||||
this.pendingHyperlinkTimer = null
|
this.pendingHyperlinkTimer = null;
|
||||||
}
|
}
|
||||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||||
if (this.isRawModeSupported()) {
|
if (this.isRawModeSupported()) {
|
||||||
this.handleSetRawMode(false)
|
this.handleSetRawMode(false);
|
||||||
} else {
|
} else {
|
||||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
// 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
|
// Windows Node.js), ensure stdin is unref'd so the process can
|
||||||
// exit. earlyInput may have called ref() before Ink mounted.
|
// exit. earlyInput may have called ref() before Ink mounted.
|
||||||
try {
|
try {
|
||||||
this.props.stdin.unref()
|
this.props.stdin.unref();
|
||||||
} catch {
|
} catch {
|
||||||
// stdin may already be destroyed
|
// stdin may already be destroyed
|
||||||
}
|
}
|
||||||
@@ -299,25 +268,25 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override componentDidCatch(error: Error) {
|
override componentDidCatch(error: Error) {
|
||||||
this.handleExit(error)
|
this.handleExit(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSetRawMode = (isEnabled: boolean): void => {
|
handleSetRawMode = (isEnabled: boolean): void => {
|
||||||
const { stdin } = this.props
|
const { stdin } = this.props;
|
||||||
|
|
||||||
if (!this.isRawModeSupported()) {
|
if (!this.isRawModeSupported()) {
|
||||||
if (stdin === process.stdin) {
|
if (stdin === process.stdin) {
|
||||||
throw new Error(
|
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',
|
'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 {
|
} else {
|
||||||
throw new Error(
|
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',
|
'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) {
|
if (isEnabled) {
|
||||||
// Ensure raw mode is enabled only once
|
// Ensure raw mode is enabled only once
|
||||||
@@ -326,34 +295,34 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
// Both use the same stdin 'readable' + read() pattern, so they can't
|
// Both use the same stdin 'readable' + read() pattern, so they can't
|
||||||
// coexist -- the early capture handler would drain stdin before ours
|
// coexist -- the early capture handler would drain stdin before ours
|
||||||
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
|
// 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
|
// Safety net: remove any pre-existing readable listeners that aren't
|
||||||
// ours. In builds where setAppCallbacks() was never called, the early
|
// ours. In builds where setAppCallbacks() was never called, the early
|
||||||
// input capture's readableHandler remains attached and would consume
|
// input capture's readableHandler remains attached and would consume
|
||||||
// all stdin data before our handleReadable sees it.
|
// all stdin data before our handleReadable sees it.
|
||||||
const existingListeners = stdin.listeners('readable')
|
const existingListeners = stdin.listeners('readable');
|
||||||
for (const listener of existingListeners) {
|
for (const listener of existingListeners) {
|
||||||
if (listener !== this.handleReadable) {
|
if (listener !== this.handleReadable) {
|
||||||
stdin.removeListener('readable', listener as any)
|
stdin.removeListener('readable', listener as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stdin.ref()
|
stdin.ref();
|
||||||
stdin.setRawMode(true)
|
stdin.setRawMode(true);
|
||||||
stdin.addListener('readable', this.handleReadable)
|
stdin.addListener('readable', this.handleReadable);
|
||||||
// Enable bracketed paste mode
|
// Enable bracketed paste mode
|
||||||
this.props.stdout.write(EBP)
|
this.props.stdout.write(EBP);
|
||||||
// Enable terminal focus reporting (DECSET 1004)
|
// 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
|
// Enable extended key reporting so ctrl+shift+<letter> is
|
||||||
// distinguishable from ctrl+<letter>. We write both the kitty stack
|
// distinguishable from ctrl+<letter>. We write both the kitty stack
|
||||||
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
|
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
|
||||||
// terminals honor whichever they implement (tmux only accepts the
|
// terminals honor whichever they implement (tmux only accepts the
|
||||||
// latter).
|
// latter).
|
||||||
if (supportsExtendedKeys()) {
|
if (supportsExtendedKeys()) {
|
||||||
this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
|
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
|
||||||
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
|
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
|
||||||
}
|
}
|
||||||
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
|
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
|
||||||
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
|
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
|
||||||
@@ -364,22 +333,19 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
// init sequence completes — avoids interleaving with alt-screen/mouse
|
// init sequence completes — avoids interleaving with alt-screen/mouse
|
||||||
// tracking enable writes that may happen in the same render cycle.
|
// tracking enable writes that may happen in the same render cycle.
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
void Promise.all([
|
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
|
||||||
this.querier.send(xtversion()),
|
|
||||||
this.querier.flush(),
|
|
||||||
]).then(([r]) => {
|
|
||||||
if (r) {
|
if (r) {
|
||||||
setXtversionName(r.name)
|
setXtversionName(r.name);
|
||||||
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
|
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
|
||||||
} else {
|
} else {
|
||||||
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
|
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rawModeEnabledCount++
|
this.rawModeEnabledCount++;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable raw mode only when no components left that are using it
|
// Disable raw mode only when no components left that are using it
|
||||||
@@ -389,31 +355,31 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
// If the old tree had more useInput hooks than the new tree, the old
|
// 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
|
// cleanup over-decrements the count to 0 even though the new tree has
|
||||||
// active listeners. Detect this and fix the count instead of disabling.
|
// 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) {
|
if (activeListeners > 0) {
|
||||||
this.rawModeEnabledCount = activeListeners
|
this.rawModeEnabledCount = activeListeners;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
|
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
|
||||||
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
|
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
|
||||||
// Disable terminal focus reporting (DECSET 1004)
|
// Disable terminal focus reporting (DECSET 1004)
|
||||||
this.props.stdout.write(DFE)
|
this.props.stdout.write(DFE);
|
||||||
// Disable bracketed paste mode
|
// Disable bracketed paste mode
|
||||||
this.props.stdout.write(DBP)
|
this.props.stdout.write(DBP);
|
||||||
stdin.setRawMode(false)
|
stdin.setRawMode(false);
|
||||||
stdin.removeListener('readable', this.handleReadable)
|
stdin.removeListener('readable', this.handleReadable);
|
||||||
stdin.unref()
|
stdin.unref();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Helper to flush incomplete escape sequences
|
// Helper to flush incomplete escape sequences
|
||||||
flushIncomplete = (): void => {
|
flushIncomplete = (): void => {
|
||||||
// Clear the timer reference
|
// Clear the timer reference
|
||||||
this.incompleteEscapeTimer = null
|
this.incompleteEscapeTimer = null;
|
||||||
|
|
||||||
// Only proceed if we have incomplete sequences
|
// 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
|
// Fullscreen: if stdin has data waiting, it's almost certainly the
|
||||||
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
|
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
|
||||||
@@ -424,23 +390,20 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
// drain stdin next and clear this timer. Prevents both the spurious
|
// drain stdin next and clear this timer. Prevents both the spurious
|
||||||
// Escape key and the lost scroll event.
|
// Escape key and the lost scroll event.
|
||||||
if (this.props.stdin.readableLength > 0) {
|
if (this.props.stdin.readableLength > 0) {
|
||||||
this.incompleteEscapeTimer = setTimeout(
|
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT);
|
||||||
this.flushIncomplete,
|
return;
|
||||||
this.NORMAL_TIMEOUT,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process incomplete as a flush operation (input=null)
|
// Process incomplete as a flush operation (input=null)
|
||||||
// This reuses all existing parsing logic
|
// This reuses all existing parsing logic
|
||||||
this.processInput(null)
|
this.processInput(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Process input through the parser and handle the results
|
// Process input through the parser and handle the results
|
||||||
processInput = (input: string | Buffer | null): void => {
|
processInput = (input: string | Buffer | null): void => {
|
||||||
// Parse input using our state machine
|
// Parse input using our state machine
|
||||||
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
|
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
|
||||||
this.keyParseState = newState
|
this.keyParseState = newState;
|
||||||
|
|
||||||
// Process ALL keys in a SINGLE discreteUpdates call to prevent
|
// Process ALL keys in a SINGLE discreteUpdates call to prevent
|
||||||
// "Maximum update depth exceeded" error when many keys arrive at once
|
// "Maximum update depth exceeded" error when many keys arrive at once
|
||||||
@@ -448,106 +411,94 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
// This batches all state updates from handleInput and all useInput
|
// This batches all state updates from handleInput and all useInput
|
||||||
// listeners together within one high-priority update context.
|
// listeners together within one high-priority update context.
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
reconciler.discreteUpdates(
|
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
|
||||||
processKeysInBatch,
|
|
||||||
this,
|
|
||||||
keys,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have incomplete escape sequences, set a timer to flush them
|
// If we have incomplete escape sequences, set a timer to flush them
|
||||||
if (this.keyParseState.incomplete) {
|
if (this.keyParseState.incomplete) {
|
||||||
// Cancel any existing timer first
|
// Cancel any existing timer first
|
||||||
if (this.incompleteEscapeTimer) {
|
if (this.incompleteEscapeTimer) {
|
||||||
clearTimeout(this.incompleteEscapeTimer)
|
clearTimeout(this.incompleteEscapeTimer);
|
||||||
}
|
}
|
||||||
this.incompleteEscapeTimer = setTimeout(
|
this.incompleteEscapeTimer = setTimeout(
|
||||||
this.flushIncomplete,
|
this.flushIncomplete,
|
||||||
this.keyParseState.mode === 'IN_PASTE'
|
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT,
|
||||||
? this.PASTE_TIMEOUT
|
);
|
||||||
: this.NORMAL_TIMEOUT,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
handleReadable = (): void => {
|
handleReadable = (): void => {
|
||||||
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
|
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
|
||||||
// The terminal may have reset DEC private modes; re-assert mouse
|
// The terminal may have reset DEC private modes; re-assert mouse
|
||||||
// tracking. Checked before the read loop so one Date.now() covers
|
// tracking. Checked before the read loop so one Date.now() covers
|
||||||
// all chunks in this readable event.
|
// all chunks in this readable event.
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
|
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
|
||||||
this.props.onStdinResume?.()
|
this.props.onStdinResume?.();
|
||||||
}
|
}
|
||||||
this.lastStdinTime = now
|
this.lastStdinTime = now;
|
||||||
try {
|
try {
|
||||||
let chunk
|
let chunk;
|
||||||
while ((chunk = this.props.stdin.read() as string | null) !== null) {
|
while ((chunk = this.props.stdin.read() as string | null) !== null) {
|
||||||
// Process the input chunk
|
// Process the input chunk
|
||||||
this.processInput(chunk)
|
this.processInput(chunk);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// In Bun, an uncaught throw inside a stream 'readable' handler can
|
// In Bun, an uncaught throw inside a stream 'readable' handler can
|
||||||
// permanently wedge the stream: data stays buffered and 'readable'
|
// permanently wedge the stream: data stays buffered and 'readable'
|
||||||
// never re-emits. Catching here ensures the stream stays healthy so
|
// never re-emits. Catching here ensures the stream stays healthy so
|
||||||
// subsequent keystrokes are still delivered.
|
// subsequent keystrokes are still delivered.
|
||||||
defaultCallbacks.logError(error)
|
defaultCallbacks.logError(error);
|
||||||
|
|
||||||
// Re-attach the listener in case the exception detached it.
|
// Re-attach the listener in case the exception detached it.
|
||||||
// Bun may remove the listener after an error; without this,
|
// Bun may remove the listener after an error; without this,
|
||||||
// the session freezes permanently (stdin reader dead, event loop alive).
|
// the session freezes permanently (stdin reader dead, event loop alive).
|
||||||
const { stdin } = this.props
|
const { stdin } = this.props;
|
||||||
if (
|
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
|
||||||
this.rawModeEnabledCount > 0 &&
|
defaultCallbacks.logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
|
||||||
!stdin.listeners('readable').includes(this.handleReadable)
|
level: 'warn',
|
||||||
) {
|
});
|
||||||
defaultCallbacks.logForDebugging(
|
stdin.addListener('readable', this.handleReadable);
|
||||||
'handleReadable: re-attaching stdin readable listener after error recovery',
|
|
||||||
{ level: 'warn' },
|
|
||||||
)
|
|
||||||
stdin.addListener('readable', this.handleReadable)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
handleInput = (input: string | undefined): void => {
|
handleInput = (input: string | undefined): void => {
|
||||||
// Exit on Ctrl+C
|
// Exit on Ctrl+C
|
||||||
if (input === '\x03' && this.props.exitOnCtrlC) {
|
if (input === '\x03' && this.props.exitOnCtrlC) {
|
||||||
this.handleExit()
|
this.handleExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
|
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
|
||||||
// parsed key to support both raw (\x1a) and CSI u format from Kitty
|
// parsed key to support both raw (\x1a) and CSI u format from Kitty
|
||||||
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
|
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
|
||||||
}
|
};
|
||||||
|
|
||||||
handleExit = (error?: Error): void => {
|
handleExit = (error?: Error): void => {
|
||||||
if (this.isRawModeSupported()) {
|
if (this.isRawModeSupported()) {
|
||||||
this.handleSetRawMode(false)
|
this.handleSetRawMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.onExit(error)
|
this.props.onExit(error);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleTerminalFocus = (isFocused: boolean): void => {
|
handleTerminalFocus = (isFocused: boolean): void => {
|
||||||
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
|
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
|
||||||
// and Clock (interval speed) — no App setState needed.
|
// and Clock (interval speed) — no App setState needed.
|
||||||
setTerminalFocused(isFocused)
|
setTerminalFocused(isFocused);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleSuspend = (): void => {
|
handleSuspend = (): void => {
|
||||||
if (!this.isRawModeSupported()) {
|
if (!this.isRawModeSupported()) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the exact raw mode count to restore it properly
|
// Store the exact raw mode count to restore it properly
|
||||||
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
|
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
|
||||||
|
|
||||||
// Completely disable raw mode before suspending
|
// Completely disable raw mode before suspending
|
||||||
while (this.rawModeEnabledCount > 0) {
|
while (this.rawModeEnabledCount > 0) {
|
||||||
this.handleSetRawMode(false)
|
this.handleSetRawMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show cursor, disable focus reporting, and disable mouse tracking
|
// Show cursor, disable focus reporting, and disable mouse tracking
|
||||||
@@ -556,49 +507,44 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
// it, SGR mouse sequences would appear as garbled text at the
|
// it, SGR mouse sequences would appear as garbled text at the
|
||||||
// shell prompt while suspended.
|
// shell prompt while suspended.
|
||||||
if (this.props.stdout.isTTY) {
|
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
|
// 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
|
// Set up resume handler
|
||||||
const resumeHandler = () => {
|
const resumeHandler = () => {
|
||||||
// Restore raw mode to exact previous state
|
// Restore raw mode to exact previous state
|
||||||
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
|
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
|
||||||
if (this.isRawModeSupported()) {
|
if (this.isRawModeSupported()) {
|
||||||
this.handleSetRawMode(true)
|
this.handleSetRawMode(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
|
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
|
||||||
if (this.props.stdout.isTTY) {
|
if (this.props.stdout.isTTY) {
|
||||||
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
|
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
|
// 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
|
// 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.on('SIGCONT', resumeHandler);
|
||||||
process.kill(process.pid, 'SIGSTOP')
|
process.kill(process.pid, 'SIGSTOP');
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to process all keys within a single discrete update context.
|
// Helper to process all keys within a single discrete update context.
|
||||||
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
|
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
|
||||||
function processKeysInBatch(
|
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
|
||||||
app: App,
|
|
||||||
items: ParsedInput[],
|
|
||||||
_unused1: undefined,
|
|
||||||
_unused2: undefined,
|
|
||||||
): void {
|
|
||||||
// Update interaction time for notification timeout tracking.
|
// Update interaction time for notification timeout tracking.
|
||||||
// This is called from the central input handler to avoid having multiple
|
// This is called from the central input handler to avoid having multiple
|
||||||
// stdin listeners that can cause race conditions and dropped input.
|
// stdin listeners that can cause race conditions and dropped input.
|
||||||
@@ -606,75 +552,70 @@ function processKeysInBatch(
|
|||||||
// Mode-1003 no-button motion is also excluded — passive cursor drift is
|
// Mode-1003 no-button motion is also excluded — passive cursor drift is
|
||||||
// not engagement (would suppress idle notifications + defer housekeeping).
|
// not engagement (would suppress idle notifications + defer housekeeping).
|
||||||
if (
|
if (
|
||||||
items.some(
|
items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)))
|
||||||
i =>
|
|
||||||
i.kind === 'key' ||
|
|
||||||
(i.kind === 'mouse' &&
|
|
||||||
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
defaultCallbacks.updateLastInteractionTime()
|
defaultCallbacks.updateLastInteractionTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
|
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
|
||||||
// input — route them to the querier to resolve pending promises.
|
// input — route them to the querier to resolve pending promises.
|
||||||
if (item.kind === 'response') {
|
if (item.kind === 'response') {
|
||||||
app.querier.onResponse(item.response)
|
app.querier.onResponse(item.response);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse click/drag events update selection state (fullscreen only).
|
// Mouse click/drag events update selection state (fullscreen only).
|
||||||
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
|
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
|
||||||
// screen buffer. Button bit 0x20 = drag (motion while button held).
|
// screen buffer. Button bit 0x20 = drag (motion while button held).
|
||||||
if (item.kind === 'mouse') {
|
if (item.kind === 'mouse') {
|
||||||
handleMouseEvent(app, item)
|
handleMouseEvent(app, item);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sequence = item.sequence
|
const sequence = item.sequence;
|
||||||
|
|
||||||
// Handle terminal focus events (DECSET 1004)
|
// Handle terminal focus events (DECSET 1004)
|
||||||
if (sequence === FOCUS_IN) {
|
if (sequence === FOCUS_IN) {
|
||||||
app.handleTerminalFocus(true)
|
app.handleTerminalFocus(true);
|
||||||
const event = new TerminalFocusEvent('terminalfocus')
|
const event = new TerminalFocusEvent('terminalfocus');
|
||||||
app.internal_eventEmitter.emit('terminalfocus', event)
|
app.internal_eventEmitter.emit('terminalfocus', event);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (sequence === FOCUS_OUT) {
|
if (sequence === FOCUS_OUT) {
|
||||||
app.handleTerminalFocus(false)
|
app.handleTerminalFocus(false);
|
||||||
// Defensive: if we lost the release event (mouse released outside
|
// Defensive: if we lost the release event (mouse released outside
|
||||||
// terminal window — some emulators drop it rather than capturing the
|
// terminal window — some emulators drop it rather than capturing the
|
||||||
// pointer), focus-out is the next observable signal that the drag is
|
// pointer), focus-out is the next observable signal that the drag is
|
||||||
// over. Without this, drag-to-scroll's timer runs until the scroll
|
// over. Without this, drag-to-scroll's timer runs until the scroll
|
||||||
// boundary is hit.
|
// boundary is hit.
|
||||||
if (app.props.selection.isDragging) {
|
if (app.props.selection.isDragging) {
|
||||||
finishSelection(app.props.selection)
|
finishSelection(app.props.selection);
|
||||||
app.props.onSelectionChange()
|
app.props.onSelectionChange();
|
||||||
}
|
}
|
||||||
const event = new TerminalFocusEvent('terminalblur')
|
const event = new TerminalFocusEvent('terminalblur');
|
||||||
app.internal_eventEmitter.emit('terminalblur', event)
|
app.internal_eventEmitter.emit('terminalblur', event);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failsafe: if we receive input, the terminal must be focused
|
// Failsafe: if we receive input, the terminal must be focused
|
||||||
if (!getTerminalFocused()) {
|
if (!getTerminalFocused()) {
|
||||||
setTerminalFocused(true)
|
setTerminalFocused(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
|
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
|
||||||
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
|
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
|
||||||
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
|
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
|
||||||
app.handleSuspend()
|
app.handleSuspend();
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.handleInput(sequence)
|
app.handleInput(sequence);
|
||||||
const event = new InputEvent(item)
|
const event = new InputEvent(item);
|
||||||
app.internal_eventEmitter.emit('input', event)
|
app.internal_eventEmitter.emit('input', event);
|
||||||
|
|
||||||
// Also dispatch through the DOM tree so onKeyDown handlers fire.
|
// Also dispatch through the DOM tree so onKeyDown handlers fire.
|
||||||
app.props.dispatchKeyboardEvent(item)
|
app.props.dispatchKeyboardEvent(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,13 +623,13 @@ function processKeysInBatch(
|
|||||||
export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||||
// Allow disabling click handling while keeping wheel scroll (which goes
|
// Allow disabling click handling while keeping wheel scroll (which goes
|
||||||
// through the keybinding system as 'wheelup'/'wheeldown', not here).
|
// 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
|
// Terminal coords are 1-indexed; screen buffer is 0-indexed
|
||||||
const col = m.col - 1
|
const col = m.col - 1;
|
||||||
const row = m.row - 1
|
const row = m.row - 1;
|
||||||
const baseButton = m.button & 0x03
|
const baseButton = m.button & 0x03;
|
||||||
|
|
||||||
if (m.action === 'press') {
|
if (m.action === 'press') {
|
||||||
if ((m.button & 0x20) !== 0 && baseButton === 3) {
|
if ((m.button & 0x20) !== 0 && baseButton === 3) {
|
||||||
@@ -702,25 +643,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
|||||||
// past the edge, came back" — and tmux drops focus events unless
|
// past the edge, came back" — and tmux drops focus events unless
|
||||||
// `focus-events on` is set, so this is the more reliable signal.
|
// `focus-events on` is set, so this is the more reliable signal.
|
||||||
if (sel.isDragging) {
|
if (sel.isDragging) {
|
||||||
finishSelection(sel)
|
finishSelection(sel);
|
||||||
app.props.onSelectionChange()
|
app.props.onSelectionChange();
|
||||||
}
|
}
|
||||||
if (col === app.lastHoverCol && row === app.lastHoverRow) return
|
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
|
||||||
app.lastHoverCol = col
|
app.lastHoverCol = col;
|
||||||
app.lastHoverRow = row
|
app.lastHoverRow = row;
|
||||||
app.props.onHoverAt(col, row)
|
app.props.onHoverAt(col, row);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (baseButton !== 0) {
|
if (baseButton !== 0) {
|
||||||
// Non-left press breaks the multi-click chain.
|
// Non-left press breaks the multi-click chain.
|
||||||
app.clickCount = 0
|
app.clickCount = 0;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if ((m.button & 0x20) !== 0) {
|
if ((m.button & 0x20) !== 0) {
|
||||||
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
|
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
|
||||||
// calls notifySelectionChange internally — no extra onSelectionChange.
|
// calls notifySelectionChange internally — no extra onSelectionChange.
|
||||||
app.props.onSelectionDrag(col, row)
|
app.props.onSelectionDrag(col, row);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
// Lost-release fallback for mode-1002-only terminals: a fresh press
|
// Lost-release fallback for mode-1002-only terminals: a fresh press
|
||||||
// while isDragging=true means the previous release was dropped (cursor
|
// while isDragging=true means the previous release was dropped (cursor
|
||||||
@@ -728,43 +669,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
|||||||
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
|
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
|
||||||
// hit the no-button-motion recovery above instead, so this is rare.
|
// hit the no-button-motion recovery above instead, so this is rare.
|
||||||
if (sel.isDragging) {
|
if (sel.isDragging) {
|
||||||
finishSelection(sel)
|
finishSelection(sel);
|
||||||
app.props.onSelectionChange()
|
app.props.onSelectionChange();
|
||||||
}
|
}
|
||||||
// Fresh left press. Detect multi-click HERE (not on release) so the
|
// Fresh left press. Detect multi-click HERE (not on release) so the
|
||||||
// word/line highlight appears immediately and a subsequent drag can
|
// word/line highlight appears immediately and a subsequent drag can
|
||||||
// extend by word/line like native macOS. Previously detected on
|
// extend by word/line like native macOS. Previously detected on
|
||||||
// release, which meant (a) visible latency before the word highlights
|
// release, which meant (a) visible latency before the word highlights
|
||||||
// and (b) double-click+drag fell through to char-mode selection.
|
// and (b) double-click+drag fell through to char-mode selection.
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
const nearLast =
|
const nearLast =
|
||||||
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
|
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
|
||||||
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
|
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
|
||||||
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
|
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
|
||||||
app.clickCount = nearLast ? app.clickCount + 1 : 1
|
app.clickCount = nearLast ? app.clickCount + 1 : 1;
|
||||||
app.lastClickTime = now
|
app.lastClickTime = now;
|
||||||
app.lastClickCol = col
|
app.lastClickCol = col;
|
||||||
app.lastClickRow = row
|
app.lastClickRow = row;
|
||||||
if (app.clickCount >= 2) {
|
if (app.clickCount >= 2) {
|
||||||
// Cancel any pending hyperlink-open from the first click — this is
|
// Cancel any pending hyperlink-open from the first click — this is
|
||||||
// a double-click, not a single-click on a link.
|
// a double-click, not a single-click on a link.
|
||||||
if (app.pendingHyperlinkTimer) {
|
if (app.pendingHyperlinkTimer) {
|
||||||
clearTimeout(app.pendingHyperlinkTimer)
|
clearTimeout(app.pendingHyperlinkTimer);
|
||||||
app.pendingHyperlinkTimer = null
|
app.pendingHyperlinkTimer = null;
|
||||||
}
|
}
|
||||||
// Cap at 3 (line select) for quadruple+ clicks.
|
// Cap at 3 (line select) for quadruple+ clicks.
|
||||||
const count = app.clickCount === 2 ? 2 : 3
|
const count = app.clickCount === 2 ? 2 : 3;
|
||||||
app.props.onMultiClick(col, row, count)
|
app.props.onMultiClick(col, row, count);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
startSelection(sel, col, row)
|
startSelection(sel, col, row);
|
||||||
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
|
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
|
||||||
// comment at the hyperlink-open guard below). On macOS xterm.js,
|
// comment at the hyperlink-open guard below). On macOS xterm.js,
|
||||||
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
|
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
|
||||||
// xterm.js would have consumed the event for native selection).
|
// xterm.js would have consumed the event for native selection).
|
||||||
sel.lastPressHadAlt = (m.button & 0x08) !== 0
|
sel.lastPressHadAlt = (m.button & 0x08) !== 0;
|
||||||
app.props.onSelectionChange()
|
app.props.onSelectionChange();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release: end the drag even for non-zero button codes. Some terminals
|
// Release: end the drag even for non-zero button codes. Some terminals
|
||||||
@@ -774,12 +715,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
|||||||
// scroll boundary. Only act on non-left releases when we ARE dragging
|
// scroll boundary. Only act on non-left releases when we ARE dragging
|
||||||
// (so an unrelated middle/right click-release doesn't touch selection).
|
// (so an unrelated middle/right click-release doesn't touch selection).
|
||||||
if (baseButton !== 0) {
|
if (baseButton !== 0) {
|
||||||
if (!sel.isDragging) return
|
if (!sel.isDragging) return;
|
||||||
finishSelection(sel)
|
finishSelection(sel);
|
||||||
app.props.onSelectionChange()
|
app.props.onSelectionChange();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
finishSelection(sel)
|
finishSelection(sel);
|
||||||
// NOTE: unlike the old release-based detection we do NOT reset clickCount
|
// NOTE: unlike the old release-based detection we do NOT reset clickCount
|
||||||
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
|
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
|
||||||
// an intervening drag doesn't break the click chain. Practical upside:
|
// an intervening drag doesn't break the click chain. Practical upside:
|
||||||
@@ -800,7 +741,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
|||||||
// Resolve the hyperlink URL synchronously while the screen buffer
|
// Resolve the hyperlink URL synchronously while the screen buffer
|
||||||
// still reflects what the user clicked — deferring only the
|
// still reflects what the user clicked — deferring only the
|
||||||
// browser-open so double-click can cancel it.
|
// 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
|
// xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
|
||||||
// handler that fires on Cmd+click *without consuming the mouse event*
|
// handler that fires on Cmd+click *without consuming the mouse event*
|
||||||
// (Linkifier._handleMouseUp calls link.activate() but never
|
// (Linkifier._handleMouseUp calls link.activate() but never
|
||||||
@@ -816,19 +757,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
|||||||
// Clear any prior pending timer — clicking a second link
|
// Clear any prior pending timer — clicking a second link
|
||||||
// supersedes the first (only the latest click opens).
|
// supersedes the first (only the latest click opens).
|
||||||
if (app.pendingHyperlinkTimer) {
|
if (app.pendingHyperlinkTimer) {
|
||||||
clearTimeout(app.pendingHyperlinkTimer)
|
clearTimeout(app.pendingHyperlinkTimer);
|
||||||
}
|
}
|
||||||
app.pendingHyperlinkTimer = setTimeout(
|
app.pendingHyperlinkTimer = setTimeout(
|
||||||
(app, url) => {
|
(app, url) => {
|
||||||
app.pendingHyperlinkTimer = null
|
app.pendingHyperlinkTimer = null;
|
||||||
app.props.onOpenHyperlink(url)
|
app.props.onOpenHyperlink(url);
|
||||||
},
|
},
|
||||||
MULTI_CLICK_TIMEOUT_MS,
|
MULTI_CLICK_TIMEOUT_MS,
|
||||||
app,
|
app,
|
||||||
url,
|
url,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.props.onSelectionChange()
|
app.props.onSelectionChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
import React, { type PropsWithChildren, type Ref } from 'react'
|
import React, { type PropsWithChildren, type Ref } from 'react';
|
||||||
import type { Except } from 'type-fest'
|
import type { Except } from 'type-fest';
|
||||||
import type { DOMElement } from '../core/dom.js'
|
import type { DOMElement } from '../core/dom.js';
|
||||||
import type { ClickEvent } from '../core/events/click-event.js'
|
import type { ClickEvent } from '../core/events/click-event.js';
|
||||||
import type { FocusEvent } from '../core/events/focus-event.js'
|
import type { FocusEvent } from '../core/events/focus-event.js';
|
||||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
|
||||||
import type { Styles } from '../core/styles.js'
|
import type { Styles } from '../core/styles.js';
|
||||||
import * as warn from '../core/warn.js'
|
import * as warn from '../core/warn.js';
|
||||||
|
|
||||||
export type Props = Except<Styles, 'textWrap'> & {
|
export type Props = Except<Styles, 'textWrap'> & {
|
||||||
ref?: Ref<DOMElement>
|
ref?: Ref<DOMElement>;
|
||||||
/**
|
/**
|
||||||
* Tab order index. Nodes with `tabIndex >= 0` participate in
|
* Tab order index. Nodes with `tabIndex >= 0` participate in
|
||||||
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
|
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
|
||||||
*/
|
*/
|
||||||
tabIndex?: number
|
tabIndex?: number;
|
||||||
/**
|
/**
|
||||||
* Focus this element when it mounts. Like the HTML `autofocus`
|
* Focus this element when it mounts. Like the HTML `autofocus`
|
||||||
* attribute — the FocusManager calls `focus(node)` during the
|
* attribute — the FocusManager calls `focus(node)` during the
|
||||||
* reconciler's `commitMount` phase.
|
* reconciler's `commitMount` phase.
|
||||||
*/
|
*/
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean;
|
||||||
/**
|
/**
|
||||||
* Fired on left-button click (press + release without drag). Only works
|
* Fired on left-button click (press + release without drag). Only works
|
||||||
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
|
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
|
||||||
* otherwise. The event bubbles from the deepest hit Box up through
|
* otherwise. The event bubbles from the deepest hit Box up through
|
||||||
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
|
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
|
||||||
*/
|
*/
|
||||||
onClick?: (event: ClickEvent) => void
|
onClick?: (event: ClickEvent) => void;
|
||||||
onFocus?: (event: FocusEvent) => void
|
onFocus?: (event: FocusEvent) => void;
|
||||||
onFocusCapture?: (event: FocusEvent) => void
|
onFocusCapture?: (event: FocusEvent) => void;
|
||||||
onBlur?: (event: FocusEvent) => void
|
onBlur?: (event: FocusEvent) => void;
|
||||||
onBlurCapture?: (event: FocusEvent) => void
|
onBlurCapture?: (event: FocusEvent) => void;
|
||||||
onKeyDown?: (event: KeyboardEvent) => void
|
onKeyDown?: (event: KeyboardEvent) => void;
|
||||||
onKeyDownCapture?: (event: KeyboardEvent) => void
|
onKeyDownCapture?: (event: KeyboardEvent) => void;
|
||||||
/**
|
/**
|
||||||
* Fired when the mouse moves into this Box's rendered rect. Like DOM
|
* Fired when the mouse moves into this Box's rendered rect. Like DOM
|
||||||
* `mouseenter`, does NOT bubble — moving between children does not
|
* `mouseenter`, does NOT bubble — moving between children does not
|
||||||
* re-fire on the parent. Only works inside `<AlternateScreen>` where
|
* re-fire on the parent. Only works inside `<AlternateScreen>` where
|
||||||
* mode-1003 mouse tracking is enabled.
|
* mode-1003 mouse tracking is enabled.
|
||||||
*/
|
*/
|
||||||
onMouseEnter?: () => void
|
onMouseEnter?: () => void;
|
||||||
/** Fired when the mouse moves out of this Box's rendered rect. */
|
/** 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.
|
* `<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
|
...style
|
||||||
}: PropsWithChildren<Props>): React.ReactNode {
|
}: PropsWithChildren<Props>): React.ReactNode {
|
||||||
// Warn if spacing values are not integers to prevent fractional layout dimensions
|
// Warn if spacing values are not integers to prevent fractional layout dimensions
|
||||||
warn.ifNotInteger(style.margin, 'margin')
|
warn.ifNotInteger(style.margin, 'margin');
|
||||||
warn.ifNotInteger(style.marginX, 'marginX')
|
warn.ifNotInteger(style.marginX, 'marginX');
|
||||||
warn.ifNotInteger(style.marginY, 'marginY')
|
warn.ifNotInteger(style.marginY, 'marginY');
|
||||||
warn.ifNotInteger(style.marginTop, 'marginTop')
|
warn.ifNotInteger(style.marginTop, 'marginTop');
|
||||||
warn.ifNotInteger(style.marginBottom, 'marginBottom')
|
warn.ifNotInteger(style.marginBottom, 'marginBottom');
|
||||||
warn.ifNotInteger(style.marginLeft, 'marginLeft')
|
warn.ifNotInteger(style.marginLeft, 'marginLeft');
|
||||||
warn.ifNotInteger(style.marginRight, 'marginRight')
|
warn.ifNotInteger(style.marginRight, 'marginRight');
|
||||||
warn.ifNotInteger(style.padding, 'padding')
|
warn.ifNotInteger(style.padding, 'padding');
|
||||||
warn.ifNotInteger(style.paddingX, 'paddingX')
|
warn.ifNotInteger(style.paddingX, 'paddingX');
|
||||||
warn.ifNotInteger(style.paddingY, 'paddingY')
|
warn.ifNotInteger(style.paddingY, 'paddingY');
|
||||||
warn.ifNotInteger(style.paddingTop, 'paddingTop')
|
warn.ifNotInteger(style.paddingTop, 'paddingTop');
|
||||||
warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
|
warn.ifNotInteger(style.paddingBottom, 'paddingBottom');
|
||||||
warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
|
warn.ifNotInteger(style.paddingLeft, 'paddingLeft');
|
||||||
warn.ifNotInteger(style.paddingRight, 'paddingRight')
|
warn.ifNotInteger(style.paddingRight, 'paddingRight');
|
||||||
warn.ifNotInteger(style.gap, 'gap')
|
warn.ifNotInteger(style.gap, 'gap');
|
||||||
warn.ifNotInteger(style.columnGap, 'columnGap')
|
warn.ifNotInteger(style.columnGap, 'columnGap');
|
||||||
warn.ifNotInteger(style.rowGap, 'rowGap')
|
warn.ifNotInteger(style.rowGap, 'rowGap');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ink-box
|
<ink-box
|
||||||
@@ -112,7 +112,7 @@ function Box({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ink-box>
|
</ink-box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Box
|
export default Box;
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import React, {
|
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
type Ref,
|
import type { Except } from 'type-fest';
|
||||||
useCallback,
|
import type { DOMElement } from '../core/dom.js';
|
||||||
useEffect,
|
import type { ClickEvent } from '../core/events/click-event.js';
|
||||||
useRef,
|
import type { FocusEvent } from '../core/events/focus-event.js';
|
||||||
useState,
|
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
|
||||||
} from 'react'
|
import type { Styles } from '../core/styles.js';
|
||||||
import type { Except } from 'type-fest'
|
import Box from './Box.js';
|
||||||
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 = {
|
type ButtonState = {
|
||||||
focused: boolean
|
focused: boolean;
|
||||||
hovered: boolean
|
hovered: boolean;
|
||||||
active: boolean
|
active: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Props = Except<Styles, 'textWrap'> & {
|
export type Props = Except<Styles, 'textWrap'> & {
|
||||||
ref?: Ref<DOMElement>
|
ref?: Ref<DOMElement>;
|
||||||
/**
|
/**
|
||||||
* Called when the button is activated via Enter, Space, or click.
|
* Called when the button is activated via Enter, Space, or click.
|
||||||
*/
|
*/
|
||||||
onAction: () => void
|
onAction: () => void;
|
||||||
/**
|
/**
|
||||||
* Tab order index. Defaults to 0 (in tab order).
|
* Tab order index. Defaults to 0 (in tab order).
|
||||||
* Set to -1 for programmatically focusable only.
|
* Set to -1 for programmatically focusable only.
|
||||||
*/
|
*/
|
||||||
tabIndex?: number
|
tabIndex?: number;
|
||||||
/**
|
/**
|
||||||
* Focus this button when it mounts.
|
* Focus this button when it mounts.
|
||||||
*/
|
*/
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean;
|
||||||
/**
|
/**
|
||||||
* Render prop receiving the interactive state. Use this to
|
* Render prop receiving the interactive state. Use this to
|
||||||
* style children based on focus/hover/active — Button itself
|
* 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).
|
* 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({
|
function Button({ onAction, tabIndex = 0, autoFocus, children, ref, ...style }: Props): React.ReactNode {
|
||||||
onAction,
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
tabIndex = 0,
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
autoFocus,
|
const [isActive, setIsActive] = useState(false);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (activeTimer.current) clearTimeout(activeTimer.current)
|
if (activeTimer.current) clearTimeout(activeTimer.current);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (e.key === 'return' || e.key === ' ') {
|
if (e.key === 'return' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsActive(true)
|
setIsActive(true);
|
||||||
onAction()
|
onAction();
|
||||||
if (activeTimer.current) clearTimeout(activeTimer.current)
|
if (activeTimer.current) clearTimeout(activeTimer.current);
|
||||||
activeTimer.current = setTimeout(
|
activeTimer.current = setTimeout(setter => setter(false), 100, setIsActive);
|
||||||
setter => setter(false),
|
|
||||||
100,
|
|
||||||
setIsActive,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onAction],
|
[onAction],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(_e: ClickEvent) => {
|
(_e: ClickEvent) => {
|
||||||
onAction()
|
onAction();
|
||||||
},
|
},
|
||||||
[onAction],
|
[onAction],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
|
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []);
|
||||||
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
|
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []);
|
||||||
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
|
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
|
||||||
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
|
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const state: ButtonState = {
|
const state: ButtonState = {
|
||||||
focused: isFocused,
|
focused: isFocused,
|
||||||
hovered: isHovered,
|
hovered: isHovered,
|
||||||
active: isActive,
|
active: isActive,
|
||||||
}
|
};
|
||||||
const content = typeof children === 'function' ? children(state) : children
|
const content = typeof children === 'function' ? children(state) : children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -115,8 +98,8 @@ function Button({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Button
|
export default Button;
|
||||||
export type { ButtonState }
|
export type { ButtonState };
|
||||||
|
|||||||
@@ -1,99 +1,93 @@
|
|||||||
import React, { createContext, useEffect, useState } from 'react'
|
import React, { createContext, useEffect, useState } from 'react';
|
||||||
import { FRAME_INTERVAL_MS } from '../core/constants.js'
|
import { FRAME_INTERVAL_MS } from '../core/constants.js';
|
||||||
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
|
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
|
||||||
|
|
||||||
export type Clock = {
|
export type Clock = {
|
||||||
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
|
subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
|
||||||
now: () => number
|
now: () => number;
|
||||||
setTickInterval: (ms: number) => void
|
setTickInterval: (ms: number) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function createClock(tickIntervalMs: number): Clock {
|
export function createClock(tickIntervalMs: number): Clock {
|
||||||
const subscribers = new Map<() => void, boolean>()
|
const subscribers = new Map<() => void, boolean>();
|
||||||
let interval: ReturnType<typeof setInterval> | null = null
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
let currentTickIntervalMs = tickIntervalMs
|
let currentTickIntervalMs = tickIntervalMs;
|
||||||
let startTime = 0
|
let startTime = 0;
|
||||||
// Snapshot of the current tick's time, ensuring all subscribers in the same
|
// Snapshot of the current tick's time, ensuring all subscribers in the same
|
||||||
// tick see the same value (keeps animations synchronized)
|
// tick see the same value (keeps animations synchronized)
|
||||||
let tickTime = 0
|
let tickTime = 0;
|
||||||
|
|
||||||
function tick(): void {
|
function tick(): void {
|
||||||
tickTime = Date.now() - startTime
|
tickTime = Date.now() - startTime;
|
||||||
for (const onChange of subscribers.keys()) {
|
for (const onChange of subscribers.keys()) {
|
||||||
onChange()
|
onChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInterval(): void {
|
function updateInterval(): void {
|
||||||
const anyKeepAlive = [...subscribers.values()].some(Boolean)
|
const anyKeepAlive = [...subscribers.values()].some(Boolean);
|
||||||
|
|
||||||
if (anyKeepAlive) {
|
if (anyKeepAlive) {
|
||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval)
|
clearInterval(interval);
|
||||||
interval = null
|
interval = null;
|
||||||
}
|
}
|
||||||
if (startTime === 0) {
|
if (startTime === 0) {
|
||||||
startTime = Date.now()
|
startTime = Date.now();
|
||||||
}
|
}
|
||||||
interval = setInterval(tick, currentTickIntervalMs)
|
interval = setInterval(tick, currentTickIntervalMs);
|
||||||
} else if (interval) {
|
} else if (interval) {
|
||||||
clearInterval(interval)
|
clearInterval(interval);
|
||||||
interval = null
|
interval = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe(onChange, keepAlive) {
|
subscribe(onChange, keepAlive) {
|
||||||
subscribers.set(onChange, keepAlive)
|
subscribers.set(onChange, keepAlive);
|
||||||
updateInterval()
|
updateInterval();
|
||||||
return () => {
|
return () => {
|
||||||
subscribers.delete(onChange)
|
subscribers.delete(onChange);
|
||||||
updateInterval()
|
updateInterval();
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
now() {
|
now() {
|
||||||
if (startTime === 0) {
|
if (startTime === 0) {
|
||||||
startTime = Date.now()
|
startTime = Date.now();
|
||||||
}
|
}
|
||||||
// When the clock interval is running, return the synchronized tickTime
|
// When the clock interval is running, return the synchronized tickTime
|
||||||
// so all subscribers in the same tick see the same value.
|
// so all subscribers in the same tick see the same value.
|
||||||
// When paused (no keepAlive subscribers), return real-time to avoid
|
// When paused (no keepAlive subscribers), return real-time to avoid
|
||||||
// returning a stale tickTime from the last tick before the pause.
|
// returning a stale tickTime from the last tick before the pause.
|
||||||
if (interval && tickTime) {
|
if (interval && tickTime) {
|
||||||
return tickTime
|
return tickTime;
|
||||||
}
|
}
|
||||||
return Date.now() - startTime
|
return Date.now() - startTime;
|
||||||
},
|
},
|
||||||
|
|
||||||
setTickInterval(ms) {
|
setTickInterval(ms) {
|
||||||
if (ms === currentTickIntervalMs) return
|
if (ms === currentTickIntervalMs) return;
|
||||||
currentTickIntervalMs = ms
|
currentTickIntervalMs = ms;
|
||||||
updateInterval()
|
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.
|
// 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
|
// The clock value is stable (created once via useState), so the provider
|
||||||
// never causes consumer re-renders on its own.
|
// never causes consumer re-renders on its own.
|
||||||
export function ClockProvider({
|
export function ClockProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||||
children,
|
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS));
|
||||||
}: {
|
const focused = useTerminalFocus();
|
||||||
children: React.ReactNode
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
|
|
||||||
const focused = useTerminalFocus()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clock.setTickInterval(
|
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
|
||||||
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
|
}, [clock, focused]);
|
||||||
)
|
|
||||||
}, [clock, focused])
|
|
||||||
|
|
||||||
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
|
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
|
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs';
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import StackUtils from 'stack-utils'
|
import StackUtils from 'stack-utils';
|
||||||
import Box from './Box.js'
|
import Box from './Box.js';
|
||||||
import Text from './Text.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 */
|
/* 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
|
// Error's source file is reported as file:///home/user/file.js
|
||||||
// This function removes the file://[cwd] part
|
// This function removes the file://[cwd] part
|
||||||
const cleanupPath = (path: string | undefined): string | undefined => {
|
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 {
|
function getStackUtils(): StackUtils {
|
||||||
return (stackUtils ??= new StackUtils({
|
return (stackUtils ??= new StackUtils({
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
internals: StackUtils.nodeInternals(),
|
internals: StackUtils.nodeInternals(),
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable custom-rules/no-process-cwd */
|
/* eslint-enable custom-rules/no-process-cwd */
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly error: Error
|
readonly error: Error;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function ErrorOverview({ error }: Props) {
|
export default function ErrorOverview({ error }: Props) {
|
||||||
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
|
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
|
||||||
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
|
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
|
||||||
const filePath = cleanupPath(origin?.file)
|
const filePath = cleanupPath(origin?.file);
|
||||||
let excerpt: CodeExcerpt[] | undefined
|
let excerpt: CodeExcerpt[] | undefined;
|
||||||
let lineWidth = 0
|
let lineWidth = 0;
|
||||||
|
|
||||||
if (filePath && origin?.line) {
|
if (filePath && origin?.line) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
|
// 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')
|
const sourceCode = readFileSync(filePath, 'utf8');
|
||||||
excerpt = codeExcerpt(sourceCode, origin.line)
|
excerpt = codeExcerpt(sourceCode, origin.line);
|
||||||
|
|
||||||
if (excerpt) {
|
if (excerpt) {
|
||||||
for (const { line } of excerpt) {
|
for (const { line } of excerpt) {
|
||||||
lineWidth = Math.max(lineWidth, String(line).length)
|
lineWidth = Math.max(lineWidth, String(line).length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -76,9 +76,7 @@ export default function ErrorOverview({ error }: Props) {
|
|||||||
<Box width={lineWidth + 1}>
|
<Box width={lineWidth + 1}>
|
||||||
<Text
|
<Text
|
||||||
dim={line !== origin.line}
|
dim={line !== origin.line}
|
||||||
backgroundColor={
|
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
|
||||||
line === origin.line ? 'ansi:red' : undefined
|
|
||||||
}
|
|
||||||
color={line === origin.line ? 'ansi:white' : undefined}
|
color={line === origin.line ? 'ansi:white' : undefined}
|
||||||
>
|
>
|
||||||
{String(line).padStart(lineWidth, ' ')}:
|
{String(line).padStart(lineWidth, ' ')}:
|
||||||
@@ -103,7 +101,7 @@ export default function ErrorOverview({ error }: Props) {
|
|||||||
.split('\n')
|
.split('\n')
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.map(line => {
|
.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 the line from the stack cannot be parsed, we print out the unparsed line.
|
||||||
if (!parsedLine) {
|
if (!parsedLine) {
|
||||||
@@ -112,7 +110,7 @@ export default function ErrorOverview({ error }: Props) {
|
|||||||
<Text dim>- </Text>
|
<Text dim>- </Text>
|
||||||
<Text bold>{line}</Text>
|
<Text bold>{line}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,14 +119,13 @@ export default function ErrorOverview({ error }: Props) {
|
|||||||
<Text bold>{parsedLine.function}</Text>
|
<Text bold>{parsedLine.function}</Text>
|
||||||
<Text dim>
|
<Text dim>
|
||||||
{' '}
|
{' '}
|
||||||
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
|
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column})
|
||||||
{parsedLine.column})
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
|
import { supportsHyperlinks } from '../core/supports-hyperlinks.js';
|
||||||
import Text from './Text.js'
|
import Text from './Text.js';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly children?: ReactNode
|
readonly children?: ReactNode;
|
||||||
readonly url: string
|
readonly url: string;
|
||||||
readonly fallback?: ReactNode
|
readonly fallback?: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Link({
|
export default function Link({ children, url, fallback }: Props): React.ReactNode {
|
||||||
children,
|
|
||||||
url,
|
|
||||||
fallback,
|
|
||||||
}: Props): React.ReactNode {
|
|
||||||
// Use children if provided, otherwise display the URL
|
// Use children if provided, otherwise display the URL
|
||||||
const content = children ?? url
|
const content = children ?? url;
|
||||||
|
|
||||||
if (supportsHyperlinks()) {
|
if (supportsHyperlinks()) {
|
||||||
// Wrap in Text to ensure we're in a text context
|
// Wrap in Text to ensure we're in a text context
|
||||||
@@ -24,8 +20,8 @@ export default function Link({
|
|||||||
<Text>
|
<Text>
|
||||||
<ink-link href={url}>{content}</ink-link>
|
<ink-link href={url}>{content}</ink-link>
|
||||||
</Text>
|
</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 = {
|
export type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -6,12 +6,12 @@ export type Props = {
|
|||||||
*
|
*
|
||||||
* @default 1
|
* @default 1
|
||||||
*/
|
*/
|
||||||
readonly count?: number
|
readonly count?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||||
*/
|
*/
|
||||||
export default function Newline({ count = 1 }: Props) {
|
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 React, { type PropsWithChildren } from 'react';
|
||||||
import Box, { type Props as BoxProps } from './Box.js'
|
import Box, { type Props as BoxProps } from './Box.js';
|
||||||
|
|
||||||
type Props = Omit<BoxProps, 'noSelect'> & {
|
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||||
/**
|
/**
|
||||||
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
|
|||||||
*
|
*
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
fromLeftEdge?: boolean
|
fromLeftEdge?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks its contents as non-selectable in fullscreen text selection.
|
* 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
|
* tracking). No-op in the main-screen scrollback render where the
|
||||||
* terminal's native selection is used instead.
|
* terminal's native selection is used instead.
|
||||||
*/
|
*/
|
||||||
export function NoSelect({
|
export function NoSelect({ children, fromLeftEdge, ...boxProps }: PropsWithChildren<Props>): React.ReactNode {
|
||||||
children,
|
|
||||||
fromLeftEdge,
|
|
||||||
...boxProps
|
|
||||||
}: PropsWithChildren<Props>): React.ReactNode {
|
|
||||||
return (
|
return (
|
||||||
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
|
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||||
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
* (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. */
|
/** 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
|
* 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 {
|
export function RawAnsi({ lines, width }: Props): React.ReactNode {
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return <ink-raw-ansi rawText={lines.join('\n')} rawWidth={width} rawHeight={lines.length} />;
|
||||||
<ink-raw-ansi
|
|
||||||
rawText={lines.join('\n')}
|
|
||||||
rawWidth={width}
|
|
||||||
rawHeight={lines.length}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import React, {
|
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
|
||||||
type PropsWithChildren,
|
import type { Except } from 'type-fest';
|
||||||
type Ref,
|
import type { DOMElement } from '../core/dom.js';
|
||||||
useImperativeHandle,
|
import { markDirty, scheduleRenderFrom } from '../core/dom.js';
|
||||||
useRef,
|
import { markCommitStart } from '../core/reconciler.js';
|
||||||
useState,
|
import type { Styles } from '../core/styles.js';
|
||||||
} from 'react'
|
import Box from './Box.js';
|
||||||
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 = {
|
export type ScrollBoxHandle = {
|
||||||
scrollTo: (y: number) => void
|
scrollTo: (y: number) => void;
|
||||||
scrollBy: (dy: number) => void
|
scrollBy: (dy: number) => void;
|
||||||
/**
|
/**
|
||||||
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
|
* 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
|
* 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
|
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
|
||||||
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
|
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
|
||||||
*/
|
*/
|
||||||
scrollToElement: (el: DOMElement, offset?: number) => void
|
scrollToElement: (el: DOMElement, offset?: number) => void;
|
||||||
scrollToBottom: () => void
|
scrollToBottom: () => void;
|
||||||
getScrollTop: () => number
|
getScrollTop: () => number;
|
||||||
getPendingDelta: () => number
|
getPendingDelta: () => number;
|
||||||
getScrollHeight: () => number
|
getScrollHeight: () => number;
|
||||||
/**
|
/**
|
||||||
* Like getScrollHeight, but reads Yoga directly instead of the cached
|
* Like getScrollHeight, but reads Yoga directly instead of the cached
|
||||||
* value written by render-node-to-output (throttled, up to 16ms stale).
|
* 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
|
* Use when you need a fresh value in useLayoutEffect after a React commit
|
||||||
* that grew content. Slightly more expensive (native Yoga call).
|
* that grew content. Slightly more expensive (native Yoga call).
|
||||||
*/
|
*/
|
||||||
getFreshScrollHeight: () => number
|
getFreshScrollHeight: () => number;
|
||||||
getViewportHeight: () => number
|
getViewportHeight: () => number;
|
||||||
/**
|
/**
|
||||||
* Absolute screen-buffer row of the first visible content line (inside
|
* Absolute screen-buffer row of the first visible content line (inside
|
||||||
* padding). Used for drag-to-scroll edge detection.
|
* padding). Used for drag-to-scroll edge detection.
|
||||||
*/
|
*/
|
||||||
getViewportTop: () => number
|
getViewportTop: () => number;
|
||||||
/**
|
/**
|
||||||
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
|
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
|
||||||
* initial stickyScroll attribute, and by the renderer when positional
|
* 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
|
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
|
||||||
* layout values (unlike scrollTop+viewportH >= scrollHeight).
|
* layout values (unlike scrollTop+viewportH >= scrollHeight).
|
||||||
*/
|
*/
|
||||||
isSticky: () => boolean
|
isSticky: () => boolean;
|
||||||
/**
|
/**
|
||||||
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
|
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
|
||||||
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
|
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
|
||||||
* happen during Ink's render phase after React has committed. Callers that
|
* happen during Ink's render phase after React has committed. Callers that
|
||||||
* care about the sticky case should treat "at bottom" as a fallback.
|
* 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
|
* Set the render-time scrollTop clamp to the currently-mounted children's
|
||||||
* coverage span. Called by useVirtualScroll after computing its range;
|
* 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,
|
* content instead of blank spacer. Pass undefined to disable (sticky,
|
||||||
* cold start).
|
* cold start).
|
||||||
*/
|
*/
|
||||||
setClampBounds: (min: number | undefined, max: number | undefined) => void
|
setClampBounds: (min: number | undefined, max: number | undefined) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ScrollBoxProps = Except<
|
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
|
||||||
Styles,
|
ref?: Ref<ScrollBoxHandle>;
|
||||||
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
|
|
||||||
> & {
|
|
||||||
ref?: Ref<ScrollBoxHandle>
|
|
||||||
/**
|
/**
|
||||||
* When true, automatically pins scroll position to the bottom when content
|
* When true, automatically pins scroll position to the bottom when content
|
||||||
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
|
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
|
||||||
*/
|
*/
|
||||||
stickyScroll?: boolean
|
stickyScroll?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Box with `overflow: scroll` and an imperative scroll API.
|
* 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.
|
* Works best inside a fullscreen (constrained-height root) Ink tree.
|
||||||
*/
|
*/
|
||||||
function ScrollBox({
|
function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
|
||||||
children,
|
const domRef = useRef<DOMElement>(null);
|
||||||
ref,
|
|
||||||
stickyScroll,
|
|
||||||
...style
|
|
||||||
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
|
|
||||||
const domRef = useRef<DOMElement>(null)
|
|
||||||
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
|
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
|
||||||
// mark it dirty, and call the root's throttled scheduleRender directly.
|
// mark it dirty, and call the root's throttled scheduleRender directly.
|
||||||
// The Ink renderer reads scrollTop from the node — no React state needed,
|
// 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
|
// render — otherwise scheduleRender's leading edge fires on the FIRST
|
||||||
// event before subsequent events mutate scrollTop. scrollToBottom still
|
// event before subsequent events mutate scrollTop. scrollToBottom still
|
||||||
// forces a React render: sticky is attribute-observed, no DOM-only path.
|
// forces a React render: sticky is attribute-observed, no DOM-only path.
|
||||||
const [, forceRender] = useState(0)
|
const [, forceRender] = useState(0);
|
||||||
const listenersRef = useRef(new Set<() => void>())
|
const listenersRef = useRef(new Set<() => void>());
|
||||||
const renderQueuedRef = useRef(false)
|
const renderQueuedRef = useRef(false);
|
||||||
|
|
||||||
const notify = () => {
|
const notify = () => {
|
||||||
for (const l of listenersRef.current) l()
|
for (const l of listenersRef.current) l();
|
||||||
}
|
};
|
||||||
|
|
||||||
function scrollMutated(el: DOMElement): void {
|
function scrollMutated(el: DOMElement): void {
|
||||||
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
|
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
|
||||||
// check) to skip their next tick — they compete for the event loop and
|
// check) to skip their next tick — they compete for the event loop and
|
||||||
// contributed to 1402ms max frame gaps during scroll drain.
|
// contributed to 1402ms max frame gaps during scroll drain.
|
||||||
// noop — injected by business layer via onScrollActivity callback
|
// noop — injected by business layer via onScrollActivity callback
|
||||||
markDirty(el)
|
markDirty(el);
|
||||||
markCommitStart()
|
markCommitStart();
|
||||||
notify()
|
notify();
|
||||||
if (renderQueuedRef.current) return
|
if (renderQueuedRef.current) return;
|
||||||
renderQueuedRef.current = true
|
renderQueuedRef.current = true;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
renderQueuedRef.current = false
|
renderQueuedRef.current = false;
|
||||||
scheduleRenderFrom(el)
|
scheduleRenderFrom(el);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
(): ScrollBoxHandle => ({
|
(): ScrollBoxHandle => ({
|
||||||
scrollTo(y: number) {
|
scrollTo(y: number) {
|
||||||
const el = domRef.current
|
const el = domRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
// Explicit false overrides the DOM attribute so manual scroll
|
// Explicit false overrides the DOM attribute so manual scroll
|
||||||
// breaks stickiness. Render code checks ?? precedence.
|
// breaks stickiness. Render code checks ?? precedence.
|
||||||
el.stickyScroll = false
|
el.stickyScroll = false;
|
||||||
el.pendingScrollDelta = undefined
|
el.pendingScrollDelta = undefined;
|
||||||
el.scrollAnchor = undefined
|
el.scrollAnchor = undefined;
|
||||||
el.scrollTop = Math.max(0, Math.floor(y))
|
el.scrollTop = Math.max(0, Math.floor(y));
|
||||||
scrollMutated(el)
|
scrollMutated(el);
|
||||||
},
|
},
|
||||||
scrollToElement(el: DOMElement, offset = 0) {
|
scrollToElement(el: DOMElement, offset = 0) {
|
||||||
const box = domRef.current
|
const box = domRef.current;
|
||||||
if (!box) return
|
if (!box) return;
|
||||||
box.stickyScroll = false
|
box.stickyScroll = false;
|
||||||
box.pendingScrollDelta = undefined
|
box.pendingScrollDelta = undefined;
|
||||||
box.scrollAnchor = { el, offset }
|
box.scrollAnchor = { el, offset };
|
||||||
scrollMutated(box)
|
scrollMutated(box);
|
||||||
},
|
},
|
||||||
scrollBy(dy: number) {
|
scrollBy(dy: number) {
|
||||||
const el = domRef.current
|
const el = domRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
el.stickyScroll = false
|
el.stickyScroll = false;
|
||||||
// Wheel input cancels any in-flight anchor seek — user override.
|
// 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
|
// Accumulate in pendingScrollDelta; renderer drains it at a capped
|
||||||
// rate so fast flicks show intermediate frames. Pure accumulator:
|
// rate so fast flicks show intermediate frames. Pure accumulator:
|
||||||
// scroll-up followed by scroll-down naturally cancels.
|
// scroll-up followed by scroll-down naturally cancels.
|
||||||
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
|
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
|
||||||
scrollMutated(el)
|
scrollMutated(el);
|
||||||
},
|
},
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
const el = domRef.current
|
const el = domRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
el.pendingScrollDelta = undefined
|
el.pendingScrollDelta = undefined;
|
||||||
el.stickyScroll = true
|
el.stickyScroll = true;
|
||||||
markDirty(el)
|
markDirty(el);
|
||||||
notify()
|
notify();
|
||||||
forceRender(n => n + 1)
|
forceRender(n => n + 1);
|
||||||
},
|
},
|
||||||
getScrollTop() {
|
getScrollTop() {
|
||||||
return domRef.current?.scrollTop ?? 0
|
return domRef.current?.scrollTop ?? 0;
|
||||||
},
|
},
|
||||||
getPendingDelta() {
|
getPendingDelta() {
|
||||||
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
|
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
|
||||||
// this to mount the union [committed, committed+pending] range —
|
// this to mount the union [committed, committed+pending] range —
|
||||||
// otherwise intermediate drain frames find no children (blank).
|
// otherwise intermediate drain frames find no children (blank).
|
||||||
return domRef.current?.pendingScrollDelta ?? 0
|
return domRef.current?.pendingScrollDelta ?? 0;
|
||||||
},
|
},
|
||||||
getScrollHeight() {
|
getScrollHeight() {
|
||||||
return domRef.current?.scrollHeight ?? 0
|
return domRef.current?.scrollHeight ?? 0;
|
||||||
},
|
},
|
||||||
getFreshScrollHeight() {
|
getFreshScrollHeight() {
|
||||||
const content = domRef.current?.childNodes[0] as DOMElement | undefined
|
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
|
||||||
return (
|
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
|
||||||
content?.yogaNode?.getComputedHeight() ??
|
|
||||||
domRef.current?.scrollHeight ??
|
|
||||||
0
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
getViewportHeight() {
|
getViewportHeight() {
|
||||||
return domRef.current?.scrollViewportHeight ?? 0
|
return domRef.current?.scrollViewportHeight ?? 0;
|
||||||
},
|
},
|
||||||
getViewportTop() {
|
getViewportTop() {
|
||||||
return domRef.current?.scrollViewportTop ?? 0
|
return domRef.current?.scrollViewportTop ?? 0;
|
||||||
},
|
},
|
||||||
isSticky() {
|
isSticky() {
|
||||||
const el = domRef.current
|
const el = domRef.current;
|
||||||
if (!el) return false
|
if (!el) return false;
|
||||||
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
|
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
|
||||||
},
|
},
|
||||||
subscribe(listener: () => void) {
|
subscribe(listener: () => void) {
|
||||||
listenersRef.current.add(listener)
|
listenersRef.current.add(listener);
|
||||||
return () => listenersRef.current.delete(listener)
|
return () => listenersRef.current.delete(listener);
|
||||||
},
|
},
|
||||||
setClampBounds(min, max) {
|
setClampBounds(min, max) {
|
||||||
const el = domRef.current
|
const el = domRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
el.scrollClampMin = min
|
el.scrollClampMin = min;
|
||||||
el.scrollClampMax = max
|
el.scrollClampMax = max;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// notify/scrollMutated are inline (no useCallback) but only close over
|
// notify/scrollMutated are inline (no useCallback) but only close over
|
||||||
@@ -217,7 +199,7 @@ function ScrollBox({
|
|||||||
// every render (which re-registers the ref = churn).
|
// every render (which re-registers the ref = churn).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[],
|
[],
|
||||||
)
|
);
|
||||||
|
|
||||||
// Structure: outer viewport (overflow:scroll, constrained height) >
|
// Structure: outer viewport (overflow:scroll, constrained height) >
|
||||||
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
|
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
|
||||||
@@ -233,8 +215,8 @@ function ScrollBox({
|
|||||||
return (
|
return (
|
||||||
<ink-box
|
<ink-box
|
||||||
ref={el => {
|
ref={el => {
|
||||||
domRef.current = el
|
domRef.current = el;
|
||||||
if (el) el.scrollTop ??= 0
|
if (el) el.scrollTop ??= 0;
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
flexWrap: 'nowrap',
|
flexWrap: 'nowrap',
|
||||||
@@ -251,7 +233,7 @@ function ScrollBox({
|
|||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</ink-box>
|
</ink-box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScrollBox
|
export default ScrollBox;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import Box from './Box.js'
|
import Box from './Box.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flexible space that expands along the major axis of its containing layout.
|
* 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.
|
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||||
*/
|
*/
|
||||||
export default function Spacer() {
|
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 {
|
import {
|
||||||
getTerminalFocused,
|
getTerminalFocused,
|
||||||
getTerminalFocusState,
|
getTerminalFocusState,
|
||||||
subscribeTerminalFocus,
|
subscribeTerminalFocus,
|
||||||
type TerminalFocusState,
|
type TerminalFocusState,
|
||||||
} from '../core/terminal-focus-state.js'
|
} from '../core/terminal-focus-state.js';
|
||||||
|
|
||||||
export type { TerminalFocusState }
|
export type { TerminalFocusState };
|
||||||
|
|
||||||
export type TerminalFocusContextProps = {
|
export type TerminalFocusContextProps = {
|
||||||
readonly isTerminalFocused: boolean
|
readonly isTerminalFocused: boolean;
|
||||||
readonly terminalFocusState: TerminalFocusState
|
readonly terminalFocusState: TerminalFocusState;
|
||||||
}
|
};
|
||||||
|
|
||||||
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||||
isTerminalFocused: true,
|
isTerminalFocused: true,
|
||||||
terminalFocusState: 'unknown',
|
terminalFocusState: 'unknown',
|
||||||
})
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
// 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.
|
// 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 —
|
// Children are a stable prop reference, so they don't re-render either —
|
||||||
// only components that consume the context will re-render.
|
// only components that consume the context will re-render.
|
||||||
export function TerminalFocusProvider({
|
export function TerminalFocusProvider({ children }: { children: React.ReactNode }): React.ReactNode {
|
||||||
children,
|
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
|
||||||
}: {
|
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
|
||||||
children: React.ReactNode
|
|
||||||
}): React.ReactNode {
|
|
||||||
const isTerminalFocused = useSyncExternalStore(
|
|
||||||
subscribeTerminalFocus,
|
|
||||||
getTerminalFocused,
|
|
||||||
)
|
|
||||||
const terminalFocusState = useSyncExternalStore(
|
|
||||||
subscribeTerminalFocus,
|
|
||||||
getTerminalFocusState,
|
|
||||||
)
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(() => ({ isTerminalFocused, terminalFocusState }), [isTerminalFocused, terminalFocusState]);
|
||||||
() => ({ isTerminalFocused, terminalFocusState }),
|
|
||||||
[isTerminalFocused, terminalFocusState],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>;
|
||||||
<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 = {
|
export type TerminalSize = {
|
||||||
columns: number
|
columns: number;
|
||||||
rows: 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 type { ReactNode } from 'react';
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import type { Color, Styles, TextStyles } from '../core/styles.js'
|
import type { Color, Styles, TextStyles } from '../core/styles.js';
|
||||||
|
|
||||||
type BaseProps = {
|
type BaseProps = {
|
||||||
/**
|
/**
|
||||||
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
||||||
*/
|
*/
|
||||||
readonly color?: Color
|
readonly color?: Color;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as `color`, but for background.
|
* Same as `color`, but for background.
|
||||||
*/
|
*/
|
||||||
readonly backgroundColor?: Color
|
readonly backgroundColor?: Color;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the text italic.
|
* Make the text italic.
|
||||||
*/
|
*/
|
||||||
readonly italic?: boolean
|
readonly italic?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the text underlined.
|
* Make the text underlined.
|
||||||
*/
|
*/
|
||||||
readonly underline?: boolean
|
readonly underline?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the text crossed with a line.
|
* Make the text crossed with a line.
|
||||||
*/
|
*/
|
||||||
readonly strikethrough?: boolean
|
readonly strikethrough?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inverse background and foreground colors.
|
* 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.
|
* 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 `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.
|
* 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.
|
* Bold and dim are mutually exclusive in terminals.
|
||||||
* This type ensures you can use one or the other, but not both.
|
* This type ensures you can use one or the other, but not both.
|
||||||
*/
|
*/
|
||||||
type WeightProps =
|
type WeightProps = { bold?: never; dim?: never } | { bold: boolean; dim?: never } | { dim: boolean; bold?: never };
|
||||||
| { 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> = {
|
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||||
wrap: {
|
wrap: {
|
||||||
@@ -103,7 +100,7 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
textWrap: 'truncate-start',
|
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.
|
* 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,
|
children,
|
||||||
}: Props): React.ReactNode {
|
}: Props): React.ReactNode {
|
||||||
if (children === undefined || children === null) {
|
if (children === undefined || children === null) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build textStyles object with only the properties that are set
|
// Build textStyles object with only the properties that are set
|
||||||
@@ -134,11 +131,11 @@ export default function Text({
|
|||||||
...(underline && { underline }),
|
...(underline && { underline }),
|
||||||
...(strikethrough && { strikethrough }),
|
...(strikethrough && { strikethrough }),
|
||||||
...(inverse && { inverse }),
|
...(inverse && { inverse }),
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
|
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
|
||||||
{children}
|
{children}
|
||||||
</ink-text>
|
</ink-text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import Link from '../components/Link.js'
|
import Link from '../components/Link.js';
|
||||||
import Text from '../components/Text.js'
|
import Text from '../components/Text.js';
|
||||||
import type { Color } from './styles.js'
|
import type { Color } from './styles.js';
|
||||||
import {
|
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js';
|
||||||
type NamedColor,
|
|
||||||
Parser,
|
|
||||||
type Color as TermioColor,
|
|
||||||
type TextStyle,
|
|
||||||
} from './termio.js'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: string
|
children: string;
|
||||||
/** When true, force all text to be rendered with dim styling */
|
/** When true, force all text to be rendered with dim styling */
|
||||||
dimColor?: boolean
|
dimColor?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
type SpanProps = {
|
type SpanProps = {
|
||||||
color?: Color
|
color?: Color;
|
||||||
backgroundColor?: Color
|
backgroundColor?: Color;
|
||||||
dim?: boolean
|
dim?: boolean;
|
||||||
bold?: boolean
|
bold?: boolean;
|
||||||
italic?: boolean
|
italic?: boolean;
|
||||||
underline?: boolean
|
underline?: boolean;
|
||||||
strikethrough?: boolean
|
strikethrough?: boolean;
|
||||||
inverse?: boolean
|
inverse?: boolean;
|
||||||
hyperlink?: string
|
hyperlink?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that parses ANSI escape codes and renders them using Text components.
|
* 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.
|
* Memoized to prevent re-renders when parent changes but children string is the same.
|
||||||
*/
|
*/
|
||||||
export const Ansi = React.memo(function Ansi({
|
export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): React.ReactNode {
|
||||||
children,
|
|
||||||
dimColor,
|
|
||||||
}: Props): React.ReactNode {
|
|
||||||
if (typeof children !== 'string') {
|
if (typeof children !== 'string') {
|
||||||
return dimColor ? (
|
return dimColor ? <Text dim>{String(children)}</Text> : <Text>{String(children)}</Text>;
|
||||||
<Text dim>{String(children)}</Text>
|
|
||||||
) : (
|
|
||||||
<Text>{String(children)}</Text>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (children === '') {
|
if (children === '') {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spans = parseToSpans(children)
|
const spans = parseToSpans(children);
|
||||||
|
|
||||||
if (spans.length === 0) {
|
if (spans.length === 0) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
|
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
|
||||||
return dimColor ? (
|
return dimColor ? <Text dim>{spans[0]!.text}</Text> : <Text>{spans[0]!.text}</Text>;
|
||||||
<Text dim>{spans[0]!.text}</Text>
|
|
||||||
) : (
|
|
||||||
<Text>{spans[0]!.text}</Text>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = spans.map((span, i) => {
|
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
|
// When dimColor is forced, override the span's dim prop
|
||||||
if (dimColor) {
|
if (dimColor) {
|
||||||
span.props.dim = true
|
span.props.dim = true;
|
||||||
}
|
}
|
||||||
const hasTextProps = hasAnyTextProps(span.props)
|
const hasTextProps = hasAnyTextProps(span.props);
|
||||||
|
|
||||||
if (hyperlink) {
|
if (hyperlink) {
|
||||||
return hasTextProps ? (
|
return hasTextProps ? (
|
||||||
@@ -93,7 +77,7 @@ export const Ansi = React.memo(function Ansi({
|
|||||||
<Link key={i} url={hyperlink}>
|
<Link key={i} url={hyperlink}>
|
||||||
{span.text}
|
{span.text}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasTextProps ? (
|
return hasTextProps ? (
|
||||||
@@ -112,79 +96,79 @@ export const Ansi = React.memo(function Ansi({
|
|||||||
</StyledText>
|
</StyledText>
|
||||||
) : (
|
) : (
|
||||||
span.text
|
span.text
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
|
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>;
|
||||||
})
|
});
|
||||||
|
|
||||||
type Span = {
|
type Span = {
|
||||||
text: string
|
text: string;
|
||||||
props: SpanProps
|
props: SpanProps;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an ANSI string into spans using the termio parser.
|
* Parse an ANSI string into spans using the termio parser.
|
||||||
*/
|
*/
|
||||||
function parseToSpans(input: string): Span[] {
|
function parseToSpans(input: string): Span[] {
|
||||||
const parser = new Parser()
|
const parser = new Parser();
|
||||||
const actions = parser.feed(input)
|
const actions = parser.feed(input);
|
||||||
const spans: Span[] = []
|
const spans: Span[] = [];
|
||||||
|
|
||||||
let currentHyperlink: string | undefined
|
let currentHyperlink: string | undefined;
|
||||||
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (action.type === 'link') {
|
if (action.type === 'link') {
|
||||||
if (action.action.type === 'start') {
|
if (action.action.type === 'start') {
|
||||||
currentHyperlink = action.action.url
|
currentHyperlink = action.action.url;
|
||||||
} else {
|
} else {
|
||||||
currentHyperlink = undefined
|
currentHyperlink = undefined;
|
||||||
}
|
}
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === 'text') {
|
if (action.type === 'text') {
|
||||||
const text = action.graphemes.map(g => g.value).join('')
|
const text = action.graphemes.map(g => g.value).join('');
|
||||||
if (!text) continue
|
if (!text) continue;
|
||||||
|
|
||||||
const props = textStyleToSpanProps(action.style)
|
const props = textStyleToSpanProps(action.style);
|
||||||
if (currentHyperlink) {
|
if (currentHyperlink) {
|
||||||
props.hyperlink = currentHyperlink
|
props.hyperlink = currentHyperlink;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to merge with previous span if props match
|
// 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)) {
|
if (lastSpan && propsEqual(lastSpan.props, props)) {
|
||||||
lastSpan.text += text
|
lastSpan.text += text;
|
||||||
} else {
|
} else {
|
||||||
spans.push({ text, props })
|
spans.push({ text, props });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return spans
|
return spans;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert termio's TextStyle to SpanProps.
|
* Convert termio's TextStyle to SpanProps.
|
||||||
*/
|
*/
|
||||||
function textStyleToSpanProps(style: TextStyle): SpanProps {
|
function textStyleToSpanProps(style: TextStyle): SpanProps {
|
||||||
const props: SpanProps = {}
|
const props: SpanProps = {};
|
||||||
|
|
||||||
if (style.bold) props.bold = true
|
if (style.bold) props.bold = true;
|
||||||
if (style.dim) props.dim = true
|
if (style.dim) props.dim = true;
|
||||||
if (style.italic) props.italic = true
|
if (style.italic) props.italic = true;
|
||||||
if (style.underline !== 'none') props.underline = true
|
if (style.underline !== 'none') props.underline = true;
|
||||||
if (style.strikethrough) props.strikethrough = true
|
if (style.strikethrough) props.strikethrough = true;
|
||||||
if (style.inverse) props.inverse = true
|
if (style.inverse) props.inverse = true;
|
||||||
|
|
||||||
const fgColor = colorToString(style.fg)
|
const fgColor = colorToString(style.fg);
|
||||||
if (fgColor) props.color = fgColor
|
if (fgColor) props.color = fgColor;
|
||||||
|
|
||||||
const bgColor = colorToString(style.bg)
|
const bgColor = colorToString(style.bg);
|
||||||
if (bgColor) props.backgroundColor = bgColor
|
if (bgColor) props.backgroundColor = bgColor;
|
||||||
|
|
||||||
return props
|
return props;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map termio named colors to the ansi: format
|
// Map termio named colors to the ansi: format
|
||||||
@@ -205,7 +189,7 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
|
|||||||
brightMagenta: 'ansi:magentaBright',
|
brightMagenta: 'ansi:magentaBright',
|
||||||
brightCyan: 'ansi:cyanBright',
|
brightCyan: 'ansi:cyanBright',
|
||||||
brightWhite: 'ansi:whiteBright',
|
brightWhite: 'ansi:whiteBright',
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert termio's Color to the string format used by Ink.
|
* 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 {
|
function colorToString(color: TermioColor): Color | undefined {
|
||||||
switch (color.type) {
|
switch (color.type) {
|
||||||
case 'named':
|
case 'named':
|
||||||
return NAMED_COLOR_MAP[color.name] as Color
|
return NAMED_COLOR_MAP[color.name] as Color;
|
||||||
case 'indexed':
|
case 'indexed':
|
||||||
return `ansi256(${color.index})` as Color
|
return `ansi256(${color.index})` as Color;
|
||||||
case 'rgb':
|
case 'rgb':
|
||||||
return `rgb(${color.r},${color.g},${color.b})` as Color
|
return `rgb(${color.r},${color.g},${color.b})` as Color;
|
||||||
case 'default':
|
case 'default':
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +221,7 @@ function propsEqual(a: SpanProps, b: SpanProps): boolean {
|
|||||||
a.strikethrough === b.strikethrough &&
|
a.strikethrough === b.strikethrough &&
|
||||||
a.inverse === b.inverse &&
|
a.inverse === b.inverse &&
|
||||||
a.hyperlink === b.hyperlink
|
a.hyperlink === b.hyperlink
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyProps(props: SpanProps): boolean {
|
function hasAnyProps(props: SpanProps): boolean {
|
||||||
@@ -251,7 +235,7 @@ function hasAnyProps(props: SpanProps): boolean {
|
|||||||
props.strikethrough === true ||
|
props.strikethrough === true ||
|
||||||
props.inverse === true ||
|
props.inverse === true ||
|
||||||
props.hyperlink !== undefined
|
props.hyperlink !== undefined
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyTextProps(props: SpanProps): boolean {
|
function hasAnyTextProps(props: SpanProps): boolean {
|
||||||
@@ -264,18 +248,18 @@ function hasAnyTextProps(props: SpanProps): boolean {
|
|||||||
props.underline === true ||
|
props.underline === true ||
|
||||||
props.strikethrough === true ||
|
props.strikethrough === true ||
|
||||||
props.inverse === true
|
props.inverse === true
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text style props without weight (bold/dim) - these are handled separately
|
// Text style props without weight (bold/dim) - these are handled separately
|
||||||
type BaseTextStyleProps = {
|
type BaseTextStyleProps = {
|
||||||
color?: Color
|
color?: Color;
|
||||||
backgroundColor?: Color
|
backgroundColor?: Color;
|
||||||
italic?: boolean
|
italic?: boolean;
|
||||||
underline?: boolean
|
underline?: boolean;
|
||||||
strikethrough?: boolean
|
strikethrough?: boolean;
|
||||||
inverse?: boolean
|
inverse?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Wrapper component that handles bold/dim mutual exclusivity for Text
|
// Wrapper component that handles bold/dim mutual exclusivity for Text
|
||||||
function StyledText({
|
function StyledText({
|
||||||
@@ -284,9 +268,9 @@ function StyledText({
|
|||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: BaseTextStyleProps & {
|
}: BaseTextStyleProps & {
|
||||||
bold?: boolean
|
bold?: boolean;
|
||||||
dim?: boolean
|
dim?: boolean;
|
||||||
children: string
|
children: string;
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
|
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
|
||||||
if (dim) {
|
if (dim) {
|
||||||
@@ -294,14 +278,14 @@ function StyledText({
|
|||||||
<Text {...rest} dim>
|
<Text {...rest} dim>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
if (bold) {
|
if (bold) {
|
||||||
return (
|
return (
|
||||||
<Text {...rest} bold>
|
<Text {...rest} bold>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return <Text {...rest}>{children}</Text>
|
return <Text {...rest}>{children}</Text>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,16 @@
|
|||||||
import bidiFactory from 'bidi-js'
|
import bidiFactory from 'bidi-js'
|
||||||
|
|
||||||
type BidiInstance = {
|
type BidiInstance = {
|
||||||
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
|
getEmbeddingLevels: (
|
||||||
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
|
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[]
|
getVisualOrder: (reorderSegments: [number, number][]) => number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// 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
|
// 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. */
|
/** Recompute local coords relative to the target Box. */
|
||||||
prepareForTarget(target: EventTarget): void {
|
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.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
|
||||||
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
|
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// 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
|
// 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 { screen } = frame
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
let currentStyles: AnsiCode[] = []
|
let currentStyles: AnsiCode[] = []
|
||||||
let currentHyperlink: Hyperlink = undefined
|
let currentHyperlink: Hyperlink
|
||||||
for (let y = 0; y < screen.height; y++) {
|
for (let y = 0; y < screen.height; y++) {
|
||||||
let line = ''
|
let line = ''
|
||||||
for (let x = 0; x < screen.width; x++) {
|
for (let x = 0; x < screen.width; x++) {
|
||||||
@@ -301,7 +301,7 @@ export class LogUpdate {
|
|||||||
cursorRestoreScroll
|
cursorRestoreScroll
|
||||||
|
|
||||||
let currentStyleId = stylePool.none
|
let currentStyleId = stylePool.none
|
||||||
let currentHyperlink: Hyperlink = undefined
|
let currentHyperlink: Hyperlink
|
||||||
|
|
||||||
// First pass: render changes to existing rows (rows < prev.screen.height)
|
// First pass: render changes to existing rows (rows < prev.screen.height)
|
||||||
let needsFullReset = false
|
let needsFullReset = false
|
||||||
@@ -533,7 +533,7 @@ function renderFrameSlice(
|
|||||||
stylePool: StylePool,
|
stylePool: StylePool,
|
||||||
): VirtualScreen {
|
): VirtualScreen {
|
||||||
let currentStyleId = stylePool.none
|
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).
|
// Track the styleId of the last rendered cell on this line (-1 if none).
|
||||||
// Passed to visibleCellAtIndex to enable fg-only space optimization.
|
// Passed to visibleCellAtIndex to enable fg-only space optimization.
|
||||||
let lastRenderedStyleId = -1
|
let lastRenderedStyleId = -1
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ import { Buffer } from 'buffer'
|
|||||||
import { PASTE_END, PASTE_START } from './termio/csi.js'
|
import { PASTE_END, PASTE_START } from './termio/csi.js'
|
||||||
import { createTokenizer, type Tokenizer } from './termio/tokenize.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])$/
|
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
|
||||||
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
const FN_KEY_RE =
|
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]))/
|
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
|
||||||
|
|
||||||
// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
|
// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
|
||||||
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
|
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
|
||||||
// Modifier is optional - when absent, defaults to 1 (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/
|
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
|
||||||
|
|
||||||
// xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
|
// 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
|
// modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
|
||||||
// TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
|
// TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
|
||||||
// Note param order is reversed vs CSI u (modifier first, keycode second).
|
// 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+)~/
|
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
|
||||||
|
|
||||||
// -- Terminal response patterns (inbound sequences from the terminal itself) --
|
// -- Terminal response patterns (inbound sequences from the terminal itself) --
|
||||||
// DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode)
|
// 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$/
|
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
|
||||||
// DA1: CSI ? Ps ; ... c — primary device attributes response
|
// 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$/
|
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
|
||||||
// DA2: CSI > Ps ; ... c — secondary device attributes response
|
// 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$/
|
const DA2_RE = /^\x1b\[>([\d;]*)c$/
|
||||||
// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
|
// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
|
||||||
// (private ? marker distinguishes from CSI u key events)
|
// (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$/
|
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
|
||||||
// DECXCPR cursor position: CSI ? row ; col R
|
// DECXCPR cursor position: CSI ? row ; col R
|
||||||
// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 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.
|
// 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$/
|
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
|
||||||
// OSC response: OSC code ; data (BEL|ST)
|
// 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
|
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
|
||||||
// XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q).
|
// 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
|
// 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
|
// their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
|
||||||
// goes through the pty, not the environment.
|
// 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
|
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
|
||||||
// SGR mouse event: CSI < button ; col ; row M (press) or m (release)
|
// SGR mouse event: CSI < button ; col ; row M (press) or m (release)
|
||||||
// Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
|
// 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.
|
// 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])$/
|
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
|
||||||
|
|
||||||
function createPasteKey(content: string): ParsedKey {
|
function createPasteKey(content: string): ParsedKey {
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ if (process.env.NODE_ENV === 'development') {
|
|||||||
void import('./devtools.js')
|
void import('./devtools.js')
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') {
|
if (
|
||||||
// biome-ignore lint/suspicious/noConsole: intentional warning
|
error instanceof Error &&
|
||||||
|
(error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND'
|
||||||
|
) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`
|
`
|
||||||
The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
|
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. */
|
/** Debug log helper — replaces fs.appendFileSync with console.warn. */
|
||||||
function debugLog(message: string): void {
|
function debugLog(message: string): void {
|
||||||
// biome-ignore lint/suspicious/noConsole: debug instrumentation
|
|
||||||
console.warn(`[ink-commit] ${message}`)
|
console.warn(`[ink-commit] ${message}`)
|
||||||
}
|
}
|
||||||
// --- END ---
|
// --- END ---
|
||||||
@@ -304,9 +305,7 @@ const reconciler = createReconciler<
|
|||||||
if (COMMIT_LOG) {
|
if (COMMIT_LOG) {
|
||||||
const renderMs = performance.now() - _tr
|
const renderMs = performance.now() - _tr
|
||||||
if (renderMs > 10) {
|
if (renderMs > 10) {
|
||||||
debugLog(
|
debugLog(`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`)
|
||||||
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ const wrappedRender = async (
|
|||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
const instance = renderSync(node, options)
|
const instance = renderSync(node, options)
|
||||||
if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') {
|
if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') {
|
||||||
// biome-ignore lint/suspicious/noConsole: debug instrumentation
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
|
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,6 +119,44 @@ export class StylePool {
|
|||||||
this.none = this.intern([])
|
this.none = this.intern([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly CACHE_MAX = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest entries from derivative caches when they exceed the limit.
|
||||||
|
* ids/styles are never evicted (id is an array index).
|
||||||
|
*/
|
||||||
|
private evictCacheIfNeeded(): void {
|
||||||
|
if (this.transitionCache.size > StylePool.CACHE_MAX) {
|
||||||
|
const keys = this.transitionCache.keys()
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < this.transitionCache.size - StylePool.CACHE_MAX;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const k = keys.next().value
|
||||||
|
if (k !== undefined) this.transitionCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.inverseCache.size > StylePool.CACHE_MAX) {
|
||||||
|
const keys = this.inverseCache.keys()
|
||||||
|
for (let i = 0; i < this.inverseCache.size - StylePool.CACHE_MAX; i++) {
|
||||||
|
const k = keys.next().value
|
||||||
|
if (k !== undefined) this.inverseCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.currentMatchCache.size > StylePool.CACHE_MAX) {
|
||||||
|
const keys = this.currentMatchCache.keys()
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < this.currentMatchCache.size - StylePool.CACHE_MAX;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const k = keys.next().value
|
||||||
|
if (k !== undefined) this.currentMatchCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
|
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
|
||||||
* style has a visible effect on space characters (background, inverse,
|
* style has a visible effect on space characters (background, inverse,
|
||||||
@@ -136,6 +174,7 @@ export class StylePool {
|
|||||||
(rawId << 1) |
|
(rawId << 1) |
|
||||||
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
|
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
|
||||||
this.ids.set(key, id)
|
this.ids.set(key, id)
|
||||||
|
this.evictCacheIfNeeded()
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -286,7 +325,7 @@ function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
|
|||||||
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
|
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
|
||||||
*/
|
*/
|
||||||
// const enum is inlined at compile time - no runtime object, no property access
|
// 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
|
// Not a wide character, cell width 1
|
||||||
Narrow = 0,
|
Narrow = 0,
|
||||||
// Wide character, cell width 2. This cell contains the actual character.
|
// Wide character, cell width 2. This cell contains the actual character.
|
||||||
@@ -1144,7 +1183,7 @@ type DiffCallback = (
|
|||||||
y: number,
|
y: number,
|
||||||
removed: Cell | undefined,
|
removed: Cell | undefined,
|
||||||
added: Cell | undefined,
|
added: Cell | undefined,
|
||||||
) => boolean | void
|
) => boolean | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like diff(), but calls a callback for each change instead of building an array.
|
* 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 }> {
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const { input, timeout } = options
|
const { input, timeout } = options
|
||||||
const proc = nodeExecFile(command, args, { timeout }, (error, stdout, stderr) => {
|
const proc = nodeExecFile(
|
||||||
resolve({ code: error ? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
|
command,
|
||||||
})
|
args,
|
||||||
|
{ timeout },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
code: error ? 1 : 0,
|
||||||
|
stdout: stdout ?? '',
|
||||||
|
stderr: stderr ?? '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
if (input && proc.stdin) {
|
if (input && proc.stdin) {
|
||||||
proc.stdin.write(input)
|
proc.stdin.write(input)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ export default function sliceAnsi(
|
|||||||
// pass start/end in display cells (via stringWidth), so position must
|
// pass start/end in display cells (via stringWidth), so position must
|
||||||
// track the same units.
|
// track the same units.
|
||||||
const width =
|
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
|
// Break AFTER trailing zero-width marks — a combining mark attaches to
|
||||||
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at
|
// 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
|
// NaN-safe equality for layout-cache input comparison
|
||||||
function sameFloat(a: number, b: number): boolean {
|
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)
|
return a === b || (a !== a && b !== b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2372,12 +2373,14 @@ function boundAxis(
|
|||||||
if (v > maxV.value) v = maxV.value
|
if (v > maxV.value) v = maxV.value
|
||||||
} else if (maxU === 2) {
|
} else if (maxU === 2) {
|
||||||
const m = (maxV.value * owner) / 100
|
const m = (maxV.value * owner) / 100
|
||||||
|
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
|
||||||
if (m === m && v > m) v = m
|
if (m === m && v > m) v = m
|
||||||
}
|
}
|
||||||
if (minU === 1) {
|
if (minU === 1) {
|
||||||
if (v < minV.value) v = minV.value
|
if (v < minV.value) v = minV.value
|
||||||
} else if (minU === 2) {
|
} else if (minU === 2) {
|
||||||
const m = (minV.value * owner) / 100
|
const m = (minV.value * owner) / 100
|
||||||
|
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
|
||||||
if (m === m && v < m) v = m
|
if (m === m && v < m) v = m
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ export function useSearchInput({
|
|||||||
if (e.key === 'delete') {
|
if (e.key === 'delete') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (cursorOffset < query.length) {
|
if (cursorOffset < query.length) {
|
||||||
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
|
setQueryState(
|
||||||
|
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -159,7 +161,9 @@ export function useSearchInput({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (cursorOffset < query.length) {
|
if (cursorOffset < query.length) {
|
||||||
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
|
setQueryState(
|
||||||
|
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,7 +211,9 @@ export function useSearchInput({
|
|||||||
// Regular character input
|
// Regular character input
|
||||||
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
|
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
|
||||||
e.preventDefault()
|
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)
|
setCursorOffset(cursorOffset + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
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 { 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
|
type WriteRaw = (data: string) => void
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,16 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Core API (render/createRoot)
|
// 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 type { RenderOptions, Instance, Root } from './core/root.js'
|
||||||
export * from './theme/theme-types.js'
|
export * from './theme/theme-types.js'
|
||||||
// InkCore class
|
// InkCore class
|
||||||
export { default as Ink } from './core/ink.js'
|
export { default as Ink } from './core/ink.js'
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Keybindings
|
// Keybindings
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -68,8 +71,21 @@ export type {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Core types
|
// Core types
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export type { DOMElement, TextNode, ElementNames, DOMNodeAttribute } from './core/dom.js'
|
export type {
|
||||||
export type { Styles, TextStyles, Color, RGBColor, HexColor, Ansi256Color, AnsiColor } from './core/styles.js'
|
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 { Key } from './core/events/input-event.js'
|
||||||
export type { FlickerReason, FrameEvent } from './core/frame.js'
|
export type { FlickerReason, FrameEvent } from './core/frame.js'
|
||||||
export type { MatchPosition } from './core/render-to-screen.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 { EventEmitter } from './core/events/emitter.js'
|
||||||
export { Event } from './core/events/event.js'
|
export { Event } from './core/events/event.js'
|
||||||
export { InputEvent } from './core/events/input-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 { KeyboardEvent } from './core/events/keyboard-event.js'
|
||||||
export { FocusEvent } from './core/events/focus-event.js'
|
export { FocusEvent } from './core/events/focus-event.js'
|
||||||
export { FocusManager } from './core/focus.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 wrapText } from './core/wrap-text.js'
|
||||||
export { default as measureElement } from './core/measure-element.js'
|
export { default as measureElement } from './core/measure-element.js'
|
||||||
export { supportsTabStatus } from './core/termio/osc.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 {
|
||||||
export { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS } from './core/termio/csi.js'
|
setClipboard,
|
||||||
export { SHOW_CURSOR, DBP, DFE, DISABLE_MOUSE_TRACKING, EXIT_ALT_SCREEN, HIDE_CURSOR, ENTER_ALT_SCREEN, ENABLE_MOUSE_TRACKING } from './core/termio/dec.js'
|
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 instances } from './core/instances.js'
|
||||||
export { default as renderBorder, type BorderTextOptions } from './core/render-border.js'
|
export {
|
||||||
export { isSynchronizedOutputSupported, isXtermJs, hasCursorUpViewportYankBug, writeDiffToTerminal } from './core/terminal.js'
|
default as renderBorder,
|
||||||
export { colorize, applyColor, applyTextStyles, type ColorType } from './core/colorize.js'
|
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 { wrapAnsi } from './core/wrapAnsi.js'
|
||||||
export { default as styles } from './core/styles.js'
|
export { default as styles } from './core/styles.js'
|
||||||
export { clamp } from './core/layout/geometry.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'
|
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 type { Props as BaseBoxProps } from './components/Box.js'
|
||||||
export { default as BaseText } from './components/Text.js'
|
export { default as BaseText } from './components/Text.js'
|
||||||
export type { Props as BaseTextProps } 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 { default as Link } from './components/Link.js'
|
||||||
export type { Props as LinkProps } from './components/Link.js'
|
export type { Props as LinkProps } from './components/Link.js'
|
||||||
export { default as Newline } from './components/Newline.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 { default as Spacer } from './components/Spacer.js'
|
||||||
export { NoSelect } from './components/NoSelect.js'
|
export { NoSelect } from './components/NoSelect.js'
|
||||||
export { RawAnsi } from './components/RawAnsi.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'
|
export { AlternateScreen } from './components/AlternateScreen.js'
|
||||||
|
|
||||||
// App types
|
// App types
|
||||||
export type { Props as AppProps } from './components/AppContext.js'
|
export type { Props as AppProps } from './components/AppContext.js'
|
||||||
export type { Props as StdinProps } from './components/StdinContext.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
|
// Hooks
|
||||||
@@ -140,20 +205,28 @@ export { default as useStdin } from './hooks/use-stdin.js'
|
|||||||
export { useTerminalSize } from './hooks/useTerminalSize.js'
|
export { useTerminalSize } from './hooks/useTerminalSize.js'
|
||||||
export { useTimeout } from './hooks/useTimeout.js'
|
export { useTimeout } from './hooks/useTimeout.js'
|
||||||
export { useMinDisplayTime } from './hooks/useMinDisplayTime.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 { useTabStatus, type TabStatusKind } from './hooks/use-tab-status.js'
|
||||||
export { useTerminalFocus } from './hooks/use-terminal-focus.js'
|
export { useTerminalFocus } from './hooks/use-terminal-focus.js'
|
||||||
export { useTerminalTitle } from './hooks/use-terminal-title.js'
|
export { useTerminalTitle } from './hooks/use-terminal-title.js'
|
||||||
export { useTerminalViewport } from './hooks/use-terminal-viewport.js'
|
export { useTerminalViewport } from './hooks/use-terminal-viewport.js'
|
||||||
export { useSearchHighlight } from './hooks/use-search-highlight.js'
|
export { useSearchHighlight } from './hooks/use-search-highlight.js'
|
||||||
export { useDeclaredCursor } from './hooks/use-declared-cursor.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)
|
// Theme (Layer 3)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export {
|
export {
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
|
setThemeConfigCallbacks,
|
||||||
usePreviewTheme,
|
usePreviewTheme,
|
||||||
useTheme,
|
useTheme,
|
||||||
useThemeSetting,
|
useThemeSetting,
|
||||||
|
|||||||
@@ -1,84 +1,63 @@
|
|||||||
import React, {
|
import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react';
|
||||||
createContext,
|
import type { Key } from '../core/events/input-event.js';
|
||||||
type RefObject,
|
import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js';
|
||||||
useContext,
|
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js';
|
||||||
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 */
|
/** Handler registration for action callbacks */
|
||||||
type HandlerRegistration = {
|
type HandlerRegistration = {
|
||||||
action: string
|
action: string;
|
||||||
context: KeybindingContextName
|
context: KeybindingContextName;
|
||||||
handler: () => void
|
handler: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
type KeybindingContextValue = {
|
type KeybindingContextValue = {
|
||||||
/** Resolve a key input to an action name (with chord support) */
|
/** Resolve a key input to an action name (with chord support) */
|
||||||
resolve: (
|
resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult;
|
||||||
input: string,
|
|
||||||
key: Key,
|
|
||||||
activeContexts: KeybindingContextName[],
|
|
||||||
) => ChordResolveResult
|
|
||||||
|
|
||||||
/** Update the pending chord state */
|
/** 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") */
|
/** Get display text for an action (e.g., "ctrl+t") */
|
||||||
getDisplayText: (
|
getDisplayText: (action: string, context: KeybindingContextName) => string | undefined;
|
||||||
action: string,
|
|
||||||
context: KeybindingContextName,
|
|
||||||
) => string | undefined
|
|
||||||
|
|
||||||
/** All parsed bindings (for help display) */
|
/** All parsed bindings (for help display) */
|
||||||
bindings: ParsedBinding[]
|
bindings: ParsedBinding[];
|
||||||
|
|
||||||
/** Current pending chord keystrokes (null if not in a chord) */
|
/** Current pending chord keystrokes (null if not in a chord) */
|
||||||
pendingChord: ParsedKeystroke[] | null
|
pendingChord: ParsedKeystroke[] | null;
|
||||||
|
|
||||||
/** Currently active keybinding contexts (for priority resolution) */
|
/** Currently active keybinding contexts (for priority resolution) */
|
||||||
activeContexts: Set<KeybindingContextName>
|
activeContexts: Set<KeybindingContextName>;
|
||||||
|
|
||||||
/** Register a context as active (call on mount) */
|
/** Register a context as active (call on mount) */
|
||||||
registerActiveContext: (context: KeybindingContextName) => void
|
registerActiveContext: (context: KeybindingContextName) => void;
|
||||||
|
|
||||||
/** Unregister a context (call on unmount) */
|
/** Unregister a context (call on unmount) */
|
||||||
unregisterActiveContext: (context: KeybindingContextName) => void
|
unregisterActiveContext: (context: KeybindingContextName) => void;
|
||||||
|
|
||||||
/** Register a handler for an action (used by useKeybinding) */
|
/** 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) */
|
/** 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 = {
|
type ProviderProps = {
|
||||||
bindings: ParsedBinding[]
|
bindings: ParsedBinding[];
|
||||||
/** Ref for immediate access to pending chord (avoids React state delay) */
|
/** 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) */
|
/** State value for re-renders (UI updates) */
|
||||||
pendingChord: ParsedKeystroke[] | null
|
pendingChord: ParsedKeystroke[] | null;
|
||||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
|
||||||
activeContexts: Set<KeybindingContextName>
|
activeContexts: Set<KeybindingContextName>;
|
||||||
registerActiveContext: (context: KeybindingContextName) => void
|
registerActiveContext: (context: KeybindingContextName) => void;
|
||||||
unregisterActiveContext: (context: KeybindingContextName) => void
|
unregisterActiveContext: (context: KeybindingContextName) => void;
|
||||||
/** Ref to handler registry (used by ChordInterceptor) */
|
/** Ref to handler registry (used by ChordInterceptor) */
|
||||||
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
|
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function KeybindingProvider({
|
export function KeybindingProvider({
|
||||||
bindings,
|
bindings,
|
||||||
@@ -93,60 +72,54 @@ export function KeybindingProvider({
|
|||||||
}: ProviderProps): React.ReactNode {
|
}: ProviderProps): React.ReactNode {
|
||||||
const value = useMemo<KeybindingContextValue>(() => {
|
const value = useMemo<KeybindingContextValue>(() => {
|
||||||
const getDisplay = (action: string, context: KeybindingContextName) =>
|
const getDisplay = (action: string, context: KeybindingContextName) =>
|
||||||
getBindingDisplayText(action, context, bindings)
|
getBindingDisplayText(action, context, bindings);
|
||||||
|
|
||||||
// Register a handler for an action
|
// Register a handler for an action
|
||||||
const registerHandler = (registration: HandlerRegistration) => {
|
const registerHandler = (registration: HandlerRegistration) => {
|
||||||
const registry = handlerRegistryRef.current
|
const registry = handlerRegistryRef.current;
|
||||||
if (!registry) return () => {}
|
if (!registry) return () => {};
|
||||||
|
|
||||||
if (!registry.has(registration.action)) {
|
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 unregister function
|
||||||
return () => {
|
return () => {
|
||||||
const handlers = registry.get(registration.action)
|
const handlers = registry.get(registration.action);
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
handlers.delete(registration)
|
handlers.delete(registration);
|
||||||
if (handlers.size === 0) {
|
if (handlers.size === 0) {
|
||||||
registry.delete(registration.action)
|
registry.delete(registration.action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Invoke all handlers for an action
|
// Invoke all handlers for an action
|
||||||
const invokeAction = (action: string): boolean => {
|
const invokeAction = (action: string): boolean => {
|
||||||
const registry = handlerRegistryRef.current
|
const registry = handlerRegistryRef.current;
|
||||||
if (!registry) return false
|
if (!registry) return false;
|
||||||
|
|
||||||
const handlers = registry.get(action)
|
const handlers = registry.get(action);
|
||||||
if (!handlers || handlers.size === 0) return false
|
if (!handlers || handlers.size === 0) return false;
|
||||||
|
|
||||||
// Find handlers whose context is active
|
// Find handlers whose context is active
|
||||||
for (const registration of handlers) {
|
for (const registration of handlers) {
|
||||||
if (activeContexts.has(registration.context)) {
|
if (activeContexts.has(registration.context)) {
|
||||||
registration.handler()
|
registration.handler();
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Use ref for immediate access to pending chord, avoiding React state delay
|
// 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
|
// This is critical for chord sequences where the second key might be pressed
|
||||||
// before React re-renders with the updated pendingChord state
|
// before React re-renders with the updated pendingChord state
|
||||||
resolve: (input, key, contexts) =>
|
resolve: (input, key, contexts) =>
|
||||||
resolveKeyWithChordState(
|
resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current),
|
||||||
input,
|
|
||||||
key,
|
|
||||||
contexts,
|
|
||||||
bindings,
|
|
||||||
pendingChordRef.current,
|
|
||||||
),
|
|
||||||
setPendingChord,
|
setPendingChord,
|
||||||
getDisplayText: getDisplay,
|
getDisplayText: getDisplay,
|
||||||
bindings,
|
bindings,
|
||||||
@@ -156,7 +129,7 @@ export function KeybindingProvider({
|
|||||||
unregisterActiveContext,
|
unregisterActiveContext,
|
||||||
registerHandler,
|
registerHandler,
|
||||||
invokeAction,
|
invokeAction,
|
||||||
}
|
};
|
||||||
}, [
|
}, [
|
||||||
bindings,
|
bindings,
|
||||||
pendingChordRef,
|
pendingChordRef,
|
||||||
@@ -166,23 +139,17 @@ export function KeybindingProvider({
|
|||||||
registerActiveContext,
|
registerActiveContext,
|
||||||
unregisterActiveContext,
|
unregisterActiveContext,
|
||||||
handlerRegistryRef,
|
handlerRegistryRef,
|
||||||
])
|
]);
|
||||||
|
|
||||||
return (
|
return <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>;
|
||||||
<KeybindingContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</KeybindingContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeybindingContext(): KeybindingContextValue {
|
export function useKeybindingContext(): KeybindingContextValue {
|
||||||
const ctx = useContext(KeybindingContext)
|
const ctx = useContext(KeybindingContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error(
|
throw new Error('useKeybindingContext must be used within KeybindingProvider');
|
||||||
'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.
|
* Useful for components that may render before provider is available.
|
||||||
*/
|
*/
|
||||||
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
||||||
return useContext(KeybindingContext)
|
return useContext(KeybindingContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,18 +175,15 @@ export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
|||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useRegisterKeybindingContext(
|
export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void {
|
||||||
context: KeybindingContextName,
|
const keybindingContext = useOptionalKeybindingContext();
|
||||||
isActive: boolean = true,
|
|
||||||
): void {
|
|
||||||
const keybindingContext = useOptionalKeybindingContext()
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!keybindingContext || !isActive) return
|
if (!keybindingContext || !isActive) return;
|
||||||
|
|
||||||
keybindingContext.registerActiveContext(context)
|
keybindingContext.registerActiveContext(context);
|
||||||
return () => {
|
return () => {
|
||||||
keybindingContext.unregisterActiveContext(context)
|
keybindingContext.unregisterActiveContext(context);
|
||||||
}
|
};
|
||||||
}, [context, keybindingContext, isActive])
|
}, [context, keybindingContext, isActive]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,49 +5,47 @@
|
|||||||
* wrapper. App-specific dependencies (binding loading, change subscription,
|
* wrapper. App-specific dependencies (binding loading, change subscription,
|
||||||
* warning display, debug logging) are injected via props.
|
* warning display, debug logging) are injected via props.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { InputEvent } from '../core/events/input-event.js'
|
import type { InputEvent } from '../core/events/input-event.js';
|
||||||
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
|
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
|
||||||
// other handlers process them - this is required for chord sequence support
|
// other handlers process them - this is required for chord sequence support
|
||||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings
|
// eslint-disable-next-line custom-rules/prefer-use-keybindings
|
||||||
import useInput from '../hooks/use-input.js'
|
import useInput from '../hooks/use-input.js';
|
||||||
import type { Key } from '../core/events/input-event.js'
|
import type { Key } from '../core/events/input-event.js';
|
||||||
import { KeybindingProvider } from './KeybindingContext.js'
|
import { KeybindingProvider } from './KeybindingContext.js';
|
||||||
import { resolveKeyWithChordState } from './resolver.js'
|
import { resolveKeyWithChordState } from './resolver.js';
|
||||||
import type {
|
import type {
|
||||||
KeybindingContextName,
|
KeybindingContextName,
|
||||||
KeybindingsLoadResult,
|
KeybindingsLoadResult,
|
||||||
ParsedBinding,
|
ParsedBinding,
|
||||||
ParsedKeystroke,
|
ParsedKeystroke,
|
||||||
KeybindingWarning,
|
KeybindingWarning,
|
||||||
} from './types.js'
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeout for chord sequences in milliseconds.
|
* Timeout for chord sequences in milliseconds.
|
||||||
* If the user doesn't complete the chord within this time, it's cancelled.
|
* 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 = {
|
export type KeybindingSetupProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
|
|
||||||
/** Load bindings synchronously for initial render */
|
/** Load bindings synchronously for initial render */
|
||||||
loadBindings: () => KeybindingsLoadResult
|
loadBindings: () => KeybindingsLoadResult;
|
||||||
|
|
||||||
/** Subscribe to binding changes; return an unsubscribe function */
|
/** Subscribe to binding changes; return an unsubscribe function */
|
||||||
subscribeToChanges: (
|
subscribeToChanges: (callback: (result: KeybindingsLoadResult) => void) => () => void;
|
||||||
callback: (result: KeybindingsLoadResult) => void,
|
|
||||||
) => () => void
|
|
||||||
|
|
||||||
/** Initialize any file watcher (idempotent). Called once on mount. */
|
/** 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) */
|
/** 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 */
|
/** Optional debug logger */
|
||||||
onDebugLog?: (message: string) => void
|
onDebugLog?: (message: string) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function KeybindingSetup({
|
export function KeybindingSetup({
|
||||||
children,
|
children,
|
||||||
@@ -59,115 +57,105 @@ export function KeybindingSetup({
|
|||||||
}: KeybindingSetupProps): React.ReactNode {
|
}: KeybindingSetupProps): React.ReactNode {
|
||||||
// Load bindings synchronously for initial render
|
// Load bindings synchronously for initial render
|
||||||
const [loadResult, setLoadResult] = useState<KeybindingsLoadResult>(() => {
|
const [loadResult, setLoadResult] = useState<KeybindingsLoadResult>(() => {
|
||||||
const result = loadBindings()
|
const result = loadBindings();
|
||||||
onDebugLog?.(
|
onDebugLog?.(
|
||||||
`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
|
`[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)
|
// Track if this is a reload (not initial load)
|
||||||
const [isReload, setIsReload] = useState(false)
|
const [isReload, setIsReload] = useState(false);
|
||||||
|
|
||||||
// Notify about warnings
|
// Notify about warnings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWarnings?.(warnings, isReload)
|
onWarnings?.(warnings, isReload);
|
||||||
}, [warnings, isReload, onWarnings])
|
}, [warnings, isReload, onWarnings]);
|
||||||
|
|
||||||
// Chord state management - use ref for immediate access, state for re-renders
|
// Chord state management - use ref for immediate access, state for re-renders
|
||||||
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
|
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null);
|
||||||
const [pendingChord, setPendingChordState] = useState<
|
const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null);
|
||||||
ParsedKeystroke[] | null
|
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
>(null)
|
|
||||||
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
|
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
|
||||||
const handlerRegistryRef = useRef(
|
const handlerRegistryRef = useRef(
|
||||||
new Map<
|
new Map<
|
||||||
string,
|
string,
|
||||||
Set<{
|
Set<{
|
||||||
action: string
|
action: string;
|
||||||
context: KeybindingContextName
|
context: KeybindingContextName;
|
||||||
handler: () => void
|
handler: () => void;
|
||||||
}>
|
}>
|
||||||
>(),
|
>(),
|
||||||
)
|
);
|
||||||
|
|
||||||
// Active context tracking for keybinding priority resolution
|
// Active context tracking for keybinding priority resolution
|
||||||
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
|
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set());
|
||||||
|
|
||||||
const registerActiveContext = useCallback(
|
const registerActiveContext = useCallback((context: KeybindingContextName) => {
|
||||||
(context: KeybindingContextName) => {
|
activeContextsRef.current.add(context);
|
||||||
activeContextsRef.current.add(context)
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const unregisterActiveContext = useCallback(
|
const unregisterActiveContext = useCallback((context: KeybindingContextName) => {
|
||||||
(context: KeybindingContextName) => {
|
activeContextsRef.current.delete(context);
|
||||||
activeContextsRef.current.delete(context)
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Clear chord timeout when component unmounts or chord changes
|
// Clear chord timeout when component unmounts or chord changes
|
||||||
const clearChordTimeout = useCallback(() => {
|
const clearChordTimeout = useCallback(() => {
|
||||||
if (chordTimeoutRef.current) {
|
if (chordTimeoutRef.current) {
|
||||||
clearTimeout(chordTimeoutRef.current)
|
clearTimeout(chordTimeoutRef.current);
|
||||||
chordTimeoutRef.current = null
|
chordTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Wrapper for setPendingChord that manages timeout and syncs ref+state
|
// Wrapper for setPendingChord that manages timeout and syncs ref+state
|
||||||
const setPendingChord = useCallback(
|
const setPendingChord = useCallback(
|
||||||
(pending: ParsedKeystroke[] | null) => {
|
(pending: ParsedKeystroke[] | null) => {
|
||||||
clearChordTimeout()
|
clearChordTimeout();
|
||||||
|
|
||||||
if (pending !== null) {
|
if (pending !== null) {
|
||||||
// Set timeout to cancel chord if not completed
|
// Set timeout to cancel chord if not completed
|
||||||
chordTimeoutRef.current = setTimeout(
|
chordTimeoutRef.current = setTimeout(
|
||||||
(pendingChordRef, setPendingChordState) => {
|
(pendingChordRef, setPendingChordState) => {
|
||||||
onDebugLog?.('[keybindings] Chord timeout - cancelling')
|
onDebugLog?.('[keybindings] Chord timeout - cancelling');
|
||||||
pendingChordRef.current = null
|
pendingChordRef.current = null;
|
||||||
setPendingChordState(null)
|
setPendingChordState(null);
|
||||||
},
|
},
|
||||||
CHORD_TIMEOUT_MS,
|
CHORD_TIMEOUT_MS,
|
||||||
pendingChordRef,
|
pendingChordRef,
|
||||||
setPendingChordState,
|
setPendingChordState,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ref immediately for synchronous access in resolve()
|
// Update ref immediately for synchronous access in resolve()
|
||||||
pendingChordRef.current = pending
|
pendingChordRef.current = pending;
|
||||||
// Update state to trigger re-renders for UI updates
|
// Update state to trigger re-renders for UI updates
|
||||||
setPendingChordState(pending)
|
setPendingChordState(pending);
|
||||||
},
|
},
|
||||||
[clearChordTimeout, onDebugLog],
|
[clearChordTimeout, onDebugLog],
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize file watcher (idempotent - only runs once)
|
// Initialize file watcher (idempotent - only runs once)
|
||||||
void initWatcher?.()
|
void initWatcher?.();
|
||||||
|
|
||||||
// Subscribe to changes
|
// Subscribe to changes
|
||||||
const unsubscribe = subscribeToChanges(result => {
|
const unsubscribe = subscribeToChanges(result => {
|
||||||
// Any callback invocation is a reload since initial load happens
|
// Any callback invocation is a reload since initial load happens
|
||||||
// synchronously in useState, not via this subscription
|
// synchronously in useState, not via this subscription
|
||||||
setIsReload(true)
|
setIsReload(true);
|
||||||
|
|
||||||
setLoadResult(result)
|
setLoadResult(result);
|
||||||
onDebugLog?.(
|
onDebugLog?.(`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`);
|
||||||
`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
|
});
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe()
|
unsubscribe();
|
||||||
clearChordTimeout()
|
clearChordTimeout();
|
||||||
}
|
};
|
||||||
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog])
|
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeybindingProvider
|
<KeybindingProvider
|
||||||
@@ -189,7 +177,7 @@ export function KeybindingSetup({
|
|||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</KeybindingProvider>
|
</KeybindingProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,10 +191,10 @@ export function KeybindingSetup({
|
|||||||
* system could recognize it as completing a chord.
|
* system could recognize it as completing a chord.
|
||||||
*/
|
*/
|
||||||
type HandlerRegistration = {
|
type HandlerRegistration = {
|
||||||
action: string
|
action: string;
|
||||||
context: KeybindingContextName
|
context: KeybindingContextName;
|
||||||
handler: () => void
|
handler: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
function ChordInterceptor({
|
function ChordInterceptor({
|
||||||
bindings,
|
bindings,
|
||||||
@@ -215,11 +203,11 @@ function ChordInterceptor({
|
|||||||
activeContexts,
|
activeContexts,
|
||||||
handlerRegistryRef,
|
handlerRegistryRef,
|
||||||
}: {
|
}: {
|
||||||
bindings: ParsedBinding[]
|
bindings: ParsedBinding[];
|
||||||
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>
|
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>;
|
||||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
|
||||||
activeContexts: Set<KeybindingContextName>
|
activeContexts: Set<KeybindingContextName>;
|
||||||
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
|
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>;
|
||||||
}): null {
|
}): null {
|
||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
(input: string, key: Key, event: InputEvent) => {
|
(input: string, key: Key, event: InputEvent) => {
|
||||||
@@ -228,94 +216,78 @@ function ChordInterceptor({
|
|||||||
// here. Skip the registry scan. Mid-chord wheel still falls through so
|
// here. Skip the registry scan. Mid-chord wheel still falls through so
|
||||||
// scrolling cancels the pending chord like any other non-matching key.
|
// scrolling cancels the pending chord like any other non-matching key.
|
||||||
if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) {
|
if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build context list from registered handlers + activeContexts + Global
|
// Build context list from registered handlers + activeContexts + Global
|
||||||
const registry = handlerRegistryRef.current
|
const registry = handlerRegistryRef.current;
|
||||||
const handlerContexts = new Set<KeybindingContextName>()
|
const handlerContexts = new Set<KeybindingContextName>();
|
||||||
if (registry) {
|
if (registry) {
|
||||||
for (const handlers of registry.values()) {
|
for (const handlers of registry.values()) {
|
||||||
for (const registration of handlers) {
|
for (const registration of handlers) {
|
||||||
handlerContexts.add(registration.context)
|
handlerContexts.add(registration.context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const contexts: KeybindingContextName[] = [
|
const contexts: KeybindingContextName[] = [...handlerContexts, ...activeContexts, 'Global'];
|
||||||
...handlerContexts,
|
|
||||||
...activeContexts,
|
|
||||||
'Global',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Track whether we're completing a chord (pending was non-null)
|
// 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
|
// Check if this keystroke is part of a chord sequence
|
||||||
const result = resolveKeyWithChordState(
|
const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current);
|
||||||
input,
|
|
||||||
key,
|
|
||||||
contexts,
|
|
||||||
bindings,
|
|
||||||
pendingChordRef.current,
|
|
||||||
)
|
|
||||||
|
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case 'chord_started':
|
case 'chord_started':
|
||||||
// This key starts a chord - store pending state and stop propagation
|
// This key starts a chord - store pending state and stop propagation
|
||||||
setPendingChord(result.pending)
|
setPendingChord(result.pending);
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation();
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'match': {
|
case 'match': {
|
||||||
// Clear pending state
|
// Clear pending state
|
||||||
setPendingChord(null)
|
setPendingChord(null);
|
||||||
|
|
||||||
// Only invoke handlers and stop propagation for chord completions
|
// Only invoke handlers and stop propagation for chord completions
|
||||||
// (multi-keystroke sequences). Single-keystroke matches should propagate
|
// (multi-keystroke sequences). Single-keystroke matches should propagate
|
||||||
// to per-hook handlers to avoid interfering with other input handling.
|
// to per-hook handlers to avoid interfering with other input handling.
|
||||||
if (wasInChord) {
|
if (wasInChord) {
|
||||||
const contextsSet = new Set(contexts)
|
const contextsSet = new Set(contexts);
|
||||||
if (registry) {
|
if (registry) {
|
||||||
const handlers = registry.get(result.action)
|
const handlers = registry.get(result.action);
|
||||||
if (handlers && handlers.size > 0) {
|
if (handlers && handlers.size > 0) {
|
||||||
for (const registration of handlers) {
|
for (const registration of handlers) {
|
||||||
if (contextsSet.has(registration.context)) {
|
if (contextsSet.has(registration.context)) {
|
||||||
registration.handler()
|
registration.handler();
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation();
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'chord_cancelled':
|
case 'chord_cancelled':
|
||||||
setPendingChord(null)
|
setPendingChord(null);
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation();
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'unbound':
|
case 'unbound':
|
||||||
setPendingChord(null)
|
setPendingChord(null);
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation();
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'none':
|
case 'none':
|
||||||
// No chord involvement - let other handlers process
|
// 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 React, { Children, isValidElement } from 'react';
|
||||||
import { Text } from '../index.js'
|
import { Text } from '../index.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The items to join with a middot separator */
|
/** The items to join with a middot separator */
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Joins children with a middot separator (" · ") for inline metadata display.
|
* Joins children with a middot separator (" · ") for inline metadata display.
|
||||||
@@ -36,22 +36,20 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export function Byline({ children }: Props): React.ReactNode {
|
export function Byline({ children }: Props): React.ReactNode {
|
||||||
// Children.toArray already filters out null, undefined, and booleans
|
// Children.toArray already filters out null, undefined, and booleans
|
||||||
const validChildren = Children.toArray(children)
|
const validChildren = Children.toArray(children);
|
||||||
|
|
||||||
if (validChildren.length === 0) {
|
if (validChildren.length === 0) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{validChildren.map((child, index) => (
|
{validChildren.map((child, index) => (
|
||||||
<React.Fragment
|
<React.Fragment key={isValidElement(child) ? (child.key ?? index) : index}>
|
||||||
key={isValidElement(child) ? (child.key ?? index) : index}
|
|
||||||
>
|
|
||||||
{index > 0 && <Text dimColor> · </Text>}
|
{index > 0 && <Text dimColor> · </Text>}
|
||||||
{child}
|
{child}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,18 @@
|
|||||||
* internal theme components.
|
* internal theme components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
action: string
|
action: string;
|
||||||
context: string
|
context: string;
|
||||||
fallback: string
|
fallback: string;
|
||||||
description: string
|
description: string;
|
||||||
parens?: boolean
|
parens?: boolean;
|
||||||
bold?: boolean
|
bold?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function ConfigurableShortcutHint({
|
export function ConfigurableShortcutHint({ fallback, description, parens, bold }: Props): React.ReactNode {
|
||||||
fallback,
|
return <KeyboardShortcutHint shortcut={fallback} action={description} parens={parens} bold={bold} />;
|
||||||
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 React from 'react';
|
||||||
import {
|
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCD.js';
|
||||||
type ExitState,
|
import { Box, Text } from '../index.js';
|
||||||
useExitOnCtrlCDWithKeybindings,
|
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||||
} from '../hooks/useExitOnCtrlCD.js'
|
import type { Theme } from './theme-types.js';
|
||||||
import { Box, Text } from '../index.js'
|
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
|
||||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
import { Byline } from './Byline.js';
|
||||||
import type { Theme } from './theme-types.js'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
import { Pane } from './Pane.js';
|
||||||
import { Byline } from './Byline.js'
|
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
|
|
||||||
import { Pane } from './Pane.js'
|
|
||||||
|
|
||||||
type DialogProps = {
|
type DialogProps = {
|
||||||
title: React.ReactNode
|
title: React.ReactNode;
|
||||||
subtitle?: React.ReactNode
|
subtitle?: React.ReactNode;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
onCancel: () => void
|
onCancel: () => void;
|
||||||
color?: keyof Theme
|
color?: keyof Theme;
|
||||||
hideInputGuide?: boolean
|
hideInputGuide?: boolean;
|
||||||
hideBorder?: boolean
|
hideBorder?: boolean;
|
||||||
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
|
/** 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
|
* 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
|
* (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
|
* consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on
|
||||||
* press, delete-forward on ctrl+d with text). Defaults to `true`.
|
* press, delete-forward on ctrl+d with text). Defaults to `true`.
|
||||||
*/
|
*/
|
||||||
isCancelActive?: boolean
|
isCancelActive?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function Dialog({
|
export function Dialog({
|
||||||
title,
|
title,
|
||||||
@@ -42,11 +39,7 @@ export function Dialog({
|
|||||||
inputGuide,
|
inputGuide,
|
||||||
isCancelActive = true,
|
isCancelActive = true,
|
||||||
}: DialogProps): React.ReactNode {
|
}: DialogProps): React.ReactNode {
|
||||||
const exitState = useExitOnCtrlCDWithKeybindings(
|
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
isCancelActive,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use configurable keybinding for ESC to cancel.
|
// Use configurable keybinding for ESC to cancel.
|
||||||
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
|
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
|
||||||
@@ -55,21 +48,16 @@ export function Dialog({
|
|||||||
useKeybinding('confirm:no', onCancel, {
|
useKeybinding('confirm:no', onCancel, {
|
||||||
context: 'Confirmation',
|
context: 'Confirmation',
|
||||||
isActive: isCancelActive,
|
isActive: isCancelActive,
|
||||||
})
|
});
|
||||||
|
|
||||||
const defaultInputGuide = exitState.pending ? (
|
const defaultInputGuide = exitState.pending ? (
|
||||||
<Text>Press {exitState.keyName} again to exit</Text>
|
<Text>Press {exitState.keyName} again to exit</Text>
|
||||||
) : (
|
) : (
|
||||||
<Byline>
|
<Byline>
|
||||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||||
<ConfigurableShortcutHint
|
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||||
action="confirm:no"
|
|
||||||
context="Confirmation"
|
|
||||||
fallback="Esc"
|
|
||||||
description="cancel"
|
|
||||||
/>
|
|
||||||
</Byline>
|
</Byline>
|
||||||
)
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
@@ -90,11 +78,11 @@ export function Dialog({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (hideBorder) {
|
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 React from 'react';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { stringWidth } from '../core/stringWidth.js'
|
import { stringWidth } from '../core/stringWidth.js';
|
||||||
import { Ansi, Text } from '../index.js'
|
import { Ansi, Text } from '../index.js';
|
||||||
import type { Theme } from './theme-types.js'
|
import type { Theme } from './theme-types.js';
|
||||||
|
|
||||||
type DividerProps = {
|
type DividerProps = {
|
||||||
/**
|
/**
|
||||||
* Width of the divider in characters.
|
* Width of the divider in characters.
|
||||||
* Defaults to terminal width.
|
* Defaults to terminal width.
|
||||||
*/
|
*/
|
||||||
width?: number
|
width?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Theme color for the divider.
|
* Theme color for the divider.
|
||||||
* If not provided, dimColor is used.
|
* If not provided, dimColor is used.
|
||||||
*/
|
*/
|
||||||
color?: keyof Theme
|
color?: keyof Theme;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Character to use for the divider line.
|
* Character to use for the divider line.
|
||||||
* @default '─'
|
* @default '─'
|
||||||
*/
|
*/
|
||||||
char?: string
|
char?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Padding to subtract from the width (e.g., for indentation).
|
* Padding to subtract from the width (e.g., for indentation).
|
||||||
* @default 0
|
* @default 0
|
||||||
*/
|
*/
|
||||||
padding?: number
|
padding?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Title shown in the middle of the divider.
|
* Title shown in the middle of the divider.
|
||||||
@@ -37,8 +37,8 @@ type DividerProps = {
|
|||||||
* // ─────────── Title ───────────
|
* // ─────────── Title ───────────
|
||||||
* <Divider title="Title" />
|
* <Divider title="Title" />
|
||||||
*/
|
*/
|
||||||
title?: string
|
title?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A horizontal divider line.
|
* A horizontal divider line.
|
||||||
@@ -63,21 +63,15 @@ type DividerProps = {
|
|||||||
* // With centered title
|
* // With centered title
|
||||||
* <Divider title="3 new messages" />
|
* <Divider title="3 new messages" />
|
||||||
*/
|
*/
|
||||||
export function Divider({
|
export function Divider({ width, color, char = '─', padding = 0, title }: DividerProps): React.ReactNode {
|
||||||
width,
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
color,
|
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding);
|
||||||
char = '─',
|
|
||||||
padding = 0,
|
|
||||||
title,
|
|
||||||
}: DividerProps): React.ReactNode {
|
|
||||||
const { columns: terminalWidth } = useTerminalSize()
|
|
||||||
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
|
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
|
const titleWidth = stringWidth(title) + 2; // +2 for spaces around title
|
||||||
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
|
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
|
||||||
const leftWidth = Math.floor(sideWidth / 2)
|
const leftWidth = Math.floor(sideWidth / 2);
|
||||||
const rightWidth = sideWidth - leftWidth
|
const rightWidth = sideWidth - leftWidth;
|
||||||
return (
|
return (
|
||||||
<Text color={color} dimColor={!color}>
|
<Text color={color} dimColor={!color}>
|
||||||
{char.repeat(leftWidth)}{' '}
|
{char.repeat(leftWidth)}{' '}
|
||||||
@@ -86,12 +80,12 @@ export function Divider({
|
|||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
{char.repeat(rightWidth)}
|
{char.repeat(rightWidth)}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text color={color} dimColor={!color}>
|
<Text color={color} dimColor={!color}>
|
||||||
{char.repeat(effectiveWidth)}
|
{char.repeat(effectiveWidth)}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,72 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchInput } from '../hooks/useSearchInput.js'
|
import { useSearchInput } from '../hooks/useSearchInput.js';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
|
||||||
import { clamp } from '../core/layout/geometry.js'
|
import { clamp } from '../core/layout/geometry.js';
|
||||||
import { Box, Text, useTerminalFocus } from '../index.js'
|
import { Box, Text, useTerminalFocus } from '../index.js';
|
||||||
import { SearchBox } from './SearchBox.js'
|
import { SearchBox } from './SearchBox.js';
|
||||||
import { Byline } from './Byline.js'
|
import { Byline } from './Byline.js';
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||||
import { ListItem } from './ListItem.js'
|
import { ListItem } from './ListItem.js';
|
||||||
import { Pane } from './Pane.js'
|
import { Pane } from './Pane.js';
|
||||||
|
|
||||||
type PickerAction<T> = {
|
type PickerAction<T> = {
|
||||||
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
|
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
|
||||||
action: string
|
action: string;
|
||||||
handler: (item: T) => void
|
handler: (item: T) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
type Props<T> = {
|
type Props<T> = {
|
||||||
title: string
|
title: string;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
initialQuery?: string
|
initialQuery?: string;
|
||||||
items: readonly T[]
|
items: readonly T[];
|
||||||
getKey: (item: T) => string
|
getKey: (item: T) => string;
|
||||||
/** Keep to one line — preview handles overflow. */
|
/** Keep to one line — preview handles overflow. */
|
||||||
renderItem: (item: T, isFocused: boolean) => React.ReactNode
|
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
|
||||||
renderPreview?: (item: T) => React.ReactNode
|
renderPreview?: (item: T) => React.ReactNode;
|
||||||
/** 'right' keeps hints stable (no bounce), but needs width. */
|
/** 'right' keeps hints stable (no bounce), but needs width. */
|
||||||
previewPosition?: 'bottom' | 'right'
|
previewPosition?: 'bottom' | 'right';
|
||||||
visibleCount?: number
|
visibleCount?: number;
|
||||||
/**
|
/**
|
||||||
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
|
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
|
||||||
* always match screen direction — ↑ walks visually up regardless.
|
* 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. */
|
/** Caller owns filtering: re-filter on each call and pass new items. */
|
||||||
onQueryChange: (query: string) => void
|
onQueryChange: (query: string) => void;
|
||||||
/** Enter key. Primary action. */
|
/** 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
|
* 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.
|
* 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. */
|
/** 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).
|
* Fires when the focused item changes (via arrows or when items reset).
|
||||||
* Useful for async preview loading — keeps I/O out of renderPreview.
|
* Useful for async preview loading — keeps I/O out of renderPreview.
|
||||||
*/
|
*/
|
||||||
onFocus?: (item: T | undefined) => void
|
onFocus?: (item: T | undefined) => void;
|
||||||
onCancel: () => void
|
onCancel: () => void;
|
||||||
/** Shown when items is empty. Caller bakes loading/searching state into this. */
|
/** 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…".
|
* Status line below the list, e.g. "500+ matches" or "42 matches…".
|
||||||
* Caller decides when to show it — pass undefined to hide.
|
* Caller decides when to show it — pass undefined to hide.
|
||||||
*/
|
*/
|
||||||
matchLabel?: string
|
matchLabel?: string;
|
||||||
selectAction?: string
|
selectAction?: string;
|
||||||
extraHints?: React.ReactNode
|
extraHints?: React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
const DEFAULT_VISIBLE = 8
|
const DEFAULT_VISIBLE = 8;
|
||||||
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
|
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
|
||||||
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
|
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
|
||||||
const CHROME_ROWS = 10
|
const CHROME_ROWS = 10;
|
||||||
const MIN_VISIBLE = 2
|
const MIN_VISIBLE = 2;
|
||||||
|
|
||||||
export function FuzzyPicker<T>({
|
export function FuzzyPicker<T>({
|
||||||
title,
|
title,
|
||||||
@@ -90,25 +90,22 @@ export function FuzzyPicker<T>({
|
|||||||
selectAction = 'select',
|
selectAction = 'select',
|
||||||
extraHints,
|
extraHints,
|
||||||
}: Props<T>): React.ReactNode {
|
}: Props<T>): React.ReactNode {
|
||||||
const isTerminalFocused = useTerminalFocus()
|
const isTerminalFocused = useTerminalFocus();
|
||||||
const { rows, columns } = useTerminalSize()
|
const { rows, columns } = useTerminalSize();
|
||||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
|
||||||
// Cap visibleCount so the picker never exceeds the terminal height. When it
|
// 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
|
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
|
||||||
// by the overflow amount and a previously-drawn line flashes blank.
|
// by the overflow amount and a previously-drawn line flashes blank.
|
||||||
const visibleCount = Math.max(
|
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
|
||||||
MIN_VISIBLE,
|
|
||||||
Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
|
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
|
||||||
// below that. Compact mode drops shift+tab and shortens labels.
|
// below that. Compact mode drops shift+tab and shortens labels.
|
||||||
const compact = columns < 120
|
const compact = columns < 120;
|
||||||
|
|
||||||
const step = (delta: 1 | -1) => {
|
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
|
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
|
||||||
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
|
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
|
||||||
@@ -120,67 +117,62 @@ export function FuzzyPicker<T>({
|
|||||||
onCancel,
|
onCancel,
|
||||||
initialQuery,
|
initialQuery,
|
||||||
backspaceExitsOnEmpty: false,
|
backspaceExitsOnEmpty: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopImmediatePropagation()
|
e.stopImmediatePropagation();
|
||||||
step(direction === 'up' ? 1 : -1)
|
step(direction === 'up' ? 1 : -1);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopImmediatePropagation()
|
e.stopImmediatePropagation();
|
||||||
step(direction === 'up' ? -1 : 1)
|
step(direction === 'up' ? -1 : 1);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'return') {
|
if (e.key === 'return') {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopImmediatePropagation()
|
e.stopImmediatePropagation();
|
||||||
const selected = items[focusedIndex]
|
const selected = items[focusedIndex];
|
||||||
if (selected) onSelect(selected)
|
if (selected) onSelect(selected);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'tab') {
|
if (e.key === 'tab') {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopImmediatePropagation()
|
e.stopImmediatePropagation();
|
||||||
const selected = items[focusedIndex]
|
const selected = items[focusedIndex];
|
||||||
if (!selected) return
|
if (!selected) return;
|
||||||
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab
|
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab;
|
||||||
if (tabAction) {
|
if (tabAction) {
|
||||||
tabAction.handler(selected)
|
tabAction.handler(selected);
|
||||||
} else {
|
} else {
|
||||||
onSelect(selected)
|
onSelect(selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onQueryChange(query)
|
onQueryChange(query);
|
||||||
setFocusedIndex(0)
|
setFocusedIndex(0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query])
|
}, [query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocusedIndex(i => clamp(i, 0, items.length - 1))
|
setFocusedIndex(i => clamp(i, 0, items.length - 1));
|
||||||
}, [items.length])
|
}, [items.length]);
|
||||||
|
|
||||||
const focused = items[focusedIndex]
|
const focused = items[focusedIndex];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFocus?.(focused)
|
onFocus?.(focused);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [focused])
|
}, [focused]);
|
||||||
|
|
||||||
const windowStart = clamp(
|
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
|
||||||
focusedIndex - visibleCount + 1,
|
const visible = items.slice(windowStart, windowStart + visibleCount);
|
||||||
0,
|
|
||||||
items.length - visibleCount,
|
|
||||||
)
|
|
||||||
const visible = items.slice(windowStart, windowStart + visibleCount)
|
|
||||||
|
|
||||||
const emptyText =
|
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
|
||||||
typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage
|
|
||||||
|
|
||||||
const searchBox = (
|
const searchBox = (
|
||||||
<SearchBox
|
<SearchBox
|
||||||
@@ -190,7 +182,7 @@ export function FuzzyPicker<T>({
|
|||||||
isFocused
|
isFocused
|
||||||
isTerminalFocused={isTerminalFocused}
|
isTerminalFocused={isTerminalFocused}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
const listBlock = (
|
const listBlock = (
|
||||||
<List
|
<List
|
||||||
@@ -204,25 +196,21 @@ export function FuzzyPicker<T>({
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
emptyText={emptyText}
|
emptyText={emptyText}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
const preview =
|
const preview =
|
||||||
renderPreview && focused ? (
|
renderPreview && focused ? (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{renderPreview(focused)}
|
{renderPreview(focused)}
|
||||||
</Box>
|
</Box>
|
||||||
) : null
|
) : null;
|
||||||
|
|
||||||
// Structure must not depend on preview truthiness — when focused goes
|
// Structure must not depend on preview truthiness — when focused goes
|
||||||
// undefined (e.g. delete clears matches), switching row→fragment would
|
// undefined (e.g. delete clears matches), switching row→fragment would
|
||||||
// change both layout AND gap count, bouncing the searchBox below.
|
// change both layout AND gap count, bouncing the searchBox below.
|
||||||
const listGroup =
|
const listGroup =
|
||||||
renderPreview && previewPosition === 'right' ? (
|
renderPreview && previewPosition === 'right' ? (
|
||||||
<Box
|
<Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
|
||||||
flexDirection="row"
|
|
||||||
gap={2}
|
|
||||||
height={visibleCount + (matchLabel ? 1 : 0)}
|
|
||||||
>
|
|
||||||
<Box flexDirection="column" flexShrink={0}>
|
<Box flexDirection="column" flexShrink={0}>
|
||||||
{listBlock}
|
{listBlock}
|
||||||
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
||||||
@@ -238,18 +226,12 @@ export function FuzzyPicker<T>({
|
|||||||
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
||||||
{preview}
|
{preview}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
|
|
||||||
const inputAbove = direction !== 'up'
|
const inputAbove = direction !== 'up';
|
||||||
return (
|
return (
|
||||||
<Pane color="permission">
|
<Pane color="permission">
|
||||||
<Box
|
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||||
flexDirection="column"
|
|
||||||
gap={1}
|
|
||||||
tabIndex={0}
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<Text bold color="permission">
|
<Text bold color="permission">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -258,42 +240,26 @@ export function FuzzyPicker<T>({
|
|||||||
{!inputAbove && searchBox}
|
{!inputAbove && searchBox}
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
<Byline>
|
<Byline>
|
||||||
<KeyboardShortcutHint
|
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
|
||||||
shortcut="↑/↓"
|
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
|
||||||
action={compact ? 'nav' : 'navigate'}
|
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
|
||||||
/>
|
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
|
||||||
<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" />
|
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
|
||||||
{extraHints}
|
{extraHints}
|
||||||
</Byline>
|
</Byline>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Pane>
|
</Pane>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListProps<T> = Pick<
|
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
|
||||||
Props<T>,
|
visible: readonly T[];
|
||||||
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
|
windowStart: number;
|
||||||
> & {
|
total: number;
|
||||||
visible: readonly T[]
|
focusedIndex: number;
|
||||||
windowStart: number
|
emptyText: string;
|
||||||
total: number
|
};
|
||||||
focusedIndex: number
|
|
||||||
emptyText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function List<T>({
|
function List<T>({
|
||||||
visible,
|
visible,
|
||||||
@@ -311,15 +277,14 @@ function List<T>({
|
|||||||
<Box height={visibleCount} flexShrink={0}>
|
<Box height={visibleCount} flexShrink={0}>
|
||||||
<Text dimColor>{emptyText}</Text>
|
<Text dimColor>{emptyText}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = visible.map((item, i) => {
|
const rows = visible.map((item, i) => {
|
||||||
const actualIndex = windowStart + i
|
const actualIndex = windowStart + i;
|
||||||
const isFocused = actualIndex === focusedIndex
|
const isFocused = actualIndex === focusedIndex;
|
||||||
const atLowEdge = i === 0 && windowStart > 0
|
const atLowEdge = i === 0 && windowStart > 0;
|
||||||
const atHighEdge =
|
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total;
|
||||||
i === visible.length - 1 && windowStart + visibleCount! < total
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={getKey(item)}
|
key={getKey(item)}
|
||||||
@@ -330,21 +295,17 @@ function List<T>({
|
|||||||
>
|
>
|
||||||
{renderItem(item, isFocused)}
|
{renderItem(item, isFocused)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box height={visibleCount} flexShrink={0} flexDirection={direction === 'up' ? 'column-reverse' : 'column'}>
|
||||||
height={visibleCount}
|
|
||||||
flexShrink={0}
|
|
||||||
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
|
|
||||||
>
|
|
||||||
{rows}
|
{rows}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstWord(s: string): string {
|
function firstWord(s: string): string {
|
||||||
const i = s.indexOf(' ')
|
const i = s.indexOf(' ');
|
||||||
return i === -1 ? s : s.slice(0, i)
|
return i === -1 ? s : s.slice(0, i);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import Text from '../components/Text.js'
|
import Text from '../components/Text.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */
|
/** 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") */
|
/** The action the key performs (e.g., "expand", "select", "navigate") */
|
||||||
action: string
|
action: string;
|
||||||
/** Whether to wrap the hint in parentheses. Default: false */
|
/** Whether to wrap the hint in parentheses. Default: false */
|
||||||
parens?: boolean
|
parens?: boolean;
|
||||||
/** Whether to render the shortcut in bold. Default: false */
|
/** 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)"
|
* Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)"
|
||||||
@@ -35,24 +35,19 @@ type Props = {
|
|||||||
* </Byline>
|
* </Byline>
|
||||||
* </Text>
|
* </Text>
|
||||||
*/
|
*/
|
||||||
export function KeyboardShortcutHint({
|
export function KeyboardShortcutHint({ shortcut, action, parens = false, bold = false }: Props): React.ReactNode {
|
||||||
shortcut,
|
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut;
|
||||||
action,
|
|
||||||
parens = false,
|
|
||||||
bold = false,
|
|
||||||
}: Props): React.ReactNode {
|
|
||||||
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
|
|
||||||
|
|
||||||
if (parens) {
|
if (parens) {
|
||||||
return (
|
return (
|
||||||
<Text>
|
<Text>
|
||||||
({shortcutText} to {action})
|
({shortcutText} to {action})
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Text>
|
<Text>
|
||||||
{shortcutText} to {action}
|
{shortcutText} to {action}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
import figures from 'figures'
|
import figures from 'figures';
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js'
|
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js';
|
||||||
import { Box, Text } from '../index.js'
|
import { Box, Text } from '../index.js';
|
||||||
|
|
||||||
type ListItemProps = {
|
type ListItemProps = {
|
||||||
/**
|
/**
|
||||||
* Whether this item is currently focused (keyboard selection).
|
* Whether this item is currently focused (keyboard selection).
|
||||||
* Shows the pointer indicator (❯) when true.
|
* Shows the pointer indicator (❯) when true.
|
||||||
*/
|
*/
|
||||||
isFocused: boolean
|
isFocused: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this item is selected (chosen/checked).
|
* Whether this item is selected (chosen/checked).
|
||||||
* Shows the checkmark indicator (✓) when true.
|
* Shows the checkmark indicator (✓) when true.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
isSelected?: boolean
|
isSelected?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The content to display for this item.
|
* The content to display for this item.
|
||||||
*/
|
*/
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional description text displayed below the main content.
|
* Optional description text displayed below the main content.
|
||||||
*/
|
*/
|
||||||
description?: string
|
description?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a down arrow indicator instead of pointer (for scroll hints).
|
* Show a down arrow indicator instead of pointer (for scroll hints).
|
||||||
* Only applies when not focused.
|
* Only applies when not focused.
|
||||||
*/
|
*/
|
||||||
showScrollDown?: boolean
|
showScrollDown?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show an up arrow indicator instead of pointer (for scroll hints).
|
* Show an up arrow indicator instead of pointer (for scroll hints).
|
||||||
* Only applies when not focused.
|
* Only applies when not focused.
|
||||||
*/
|
*/
|
||||||
showScrollUp?: boolean
|
showScrollUp?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to apply automatic styling to the children based on focus/selection state.
|
* 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
|
* - When false: children are rendered as-is, allowing custom styling
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
styled?: boolean
|
styled?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
|
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this ListItem should declare the terminal cursor position.
|
* Whether this ListItem should declare the terminal cursor position.
|
||||||
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
|
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
declareCursor?: boolean
|
declareCursor?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list item component for selection UIs (dropdowns, multi-selects, menus).
|
* A list item component for selection UIs (dropdowns, multi-selects, menus).
|
||||||
@@ -115,46 +115,46 @@ export function ListItem({
|
|||||||
// Determine which indicator to show
|
// Determine which indicator to show
|
||||||
function renderIndicator(): ReactNode {
|
function renderIndicator(): ReactNode {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return <Text> </Text>
|
return <Text> </Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
return <Text color="suggestion">{figures.pointer}</Text>
|
return <Text color="suggestion">{figures.pointer}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showScrollDown) {
|
if (showScrollDown) {
|
||||||
return <Text dimColor>{figures.arrowDown}</Text>
|
return <Text dimColor>{figures.arrowDown}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showScrollUp) {
|
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
|
// Determine text color based on state
|
||||||
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
|
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return 'inactive'
|
return 'inactive';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!styled) {
|
if (!styled) {
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return 'success'
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFocused) {
|
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
|
// Park the native terminal cursor on the pointer indicator so screen
|
||||||
// readers / magnifiers track the focused item. (0,0) is the top-left of
|
// readers / magnifiers track the focused item. (0,0) is the top-left of
|
||||||
@@ -163,7 +163,7 @@ export function ListItem({
|
|||||||
line: 0,
|
line: 0,
|
||||||
column: 0,
|
column: 0,
|
||||||
active: isFocused && !disabled && declareCursor !== false,
|
active: isFocused && !disabled && declareCursor !== false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={cursorRef} flexDirection="column">
|
<Box ref={cursorRef} flexDirection="column">
|
||||||
@@ -184,5 +184,5 @@ export function ListItem({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { Box, Text } from '../index.js'
|
import { Box, Text } from '../index.js';
|
||||||
import { Spinner } from './Spinner.js'
|
import { Spinner } from './Spinner.js';
|
||||||
|
|
||||||
type LoadingStateProps = {
|
type LoadingStateProps = {
|
||||||
/**
|
/**
|
||||||
* The loading message to display next to the spinner.
|
* The loading message to display next to the spinner.
|
||||||
*/
|
*/
|
||||||
message: string
|
message: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the message in bold.
|
* Display the message in bold.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
bold?: boolean
|
bold?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the message in dimmed color.
|
* Display the message in dimmed color.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
dimColor?: boolean
|
dimColor?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional subtitle displayed below the main message.
|
* Optional subtitle displayed below the main message.
|
||||||
*/
|
*/
|
||||||
subtitle?: string
|
subtitle?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A spinner with loading message for async operations.
|
* A spinner with loading message for async operations.
|
||||||
@@ -62,5 +62,5 @@ export function LoadingState({
|
|||||||
</Box>
|
</Box>
|
||||||
{subtitle && <Text dimColor>{subtitle}</Text>}
|
{subtitle && <Text dimColor>{subtitle}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { useIsInsideModal } from './modalContext.js'
|
import { useIsInsideModal } from './modalContext.js';
|
||||||
import { Box } from '../index.js'
|
import { Box } from '../index.js';
|
||||||
import type { Theme } from './theme-types.js'
|
import type { Theme } from './theme-types.js';
|
||||||
import { Divider } from './Divider.js'
|
import { Divider } from './Divider.js';
|
||||||
|
|
||||||
type PaneProps = {
|
type PaneProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Theme color for the top border line.
|
* 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,
|
* 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}>
|
<Box flexDirection="column" paddingX={1} flexShrink={0}>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingTop={1}>
|
<Box flexDirection="column" paddingTop={1}>
|
||||||
@@ -53,5 +53,5 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
|
|||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</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