Compare commits

..

3 Commits

Author SHA1 Message Date
unraid
379928fa10 fix: prevent agent communication bounds from hiding CI regressions
Tighten the UDS auth, framing, and response-reader boundaries while keeping the AgentSummary lifecycle covered so Codecov and CI fail on real regressions instead of missing coverage. The poorMode settings mock mirrors unrelated real settings defaults to avoid Bun mock retention changing later permission tests.

Constraint: PR #369 must fix Codecov/CI precisely without warning suppression, fallback masking, or mock pollution

Rejected: Delete AgentSummary lifecycle coverage | would hide Codecov loss and stale-summary behavior

Rejected: Store inline UDS rejection in a hidden input sentinel | cloned observable inputs can drop it and bypass rejection

Rejected: Ignore malformed UDS frames until timeout | leaves client slots and SendMessage calls open to exhaustion

Confidence: high

Scope-risk: moderate

Directive: Keep empty #token= markers rejected; do not require a non-empty token value in hasInlineUdsToken

Tested: bun test packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts src/utils/__tests__/udsMessaging.test.ts src/utils/__tests__/udsResponseReader.test.ts src/utils/__tests__/ndjsonFramer.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage

Tested: bun run test:all

Tested: bun audit

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted Codecov upload until pushed PR checks rerun
2026-04-27 14:51:22 +08:00
unraid
ee0d788e58 fix: harden bounded agent communication review fixes
CodeRabbit and Codecov surfaced real gaps in UDS framing, peer discovery, mailbox retention, and summary context coverage. This tightens those paths without suppressing review or coverage signals.

Constraint: PR #369 must address CodeRabbit and Codecov findings without warning suppression or fake fallbacks

Rejected: Suppress Codecov or CodeRabbit warnings | leaves real receive-path and test-isolation gaps

Rejected: Add unreachable feature-gated tests | bun:bundle keeps those branches compile-time gated in local tests

Confidence: high

Scope-risk: moderate

Directive: Keep UDS auth-token rejection outside feature flags; do not reintroduce inline token fallbacks

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run test:all; bun run lint; bun run build; bun run build:vite; bun audit; git diff --cached --check

Not-tested: Remote Codecov/CodeRabbit refreshed reports until pushed
2026-04-27 10:32:18 +08:00
unraid
f353eb056a fix: bound agent communication memory growth
UDS messaging now uses private local capabilities instead of exposing auth tokens through SDK metadata, environment variables, session registry, peer listing, or tool output. The receive path bounds NDJSON frames, response buffers, active clients, and pending inbox bytes, and strips auth metadata before messages enter the prompt queue.

Teammate mailboxes now validate file and message sizes, fail closed on corrupt mutation inputs, compact by count and retained bytes, and use stable message identity for in-process acknowledgements. Agent summaries now fork only a bounded recent context using lazy size estimation and content fingerprints instead of retaining or serializing unbounded histories.

Constraint: PR #361 was already merged; this branch is based on upstream/main@c2ac9a74.
Rejected: Default-disabling COORDINATOR_MODE/TEAMMEM only | explicit feature enablement still hit unbounded paths.
Rejected: Persisting UDS auth in SDK/env/session registry | bridge/remote metadata can leak local capability secrets.
Rejected: Inline uds #token addresses | observable/tool/classifier paths can reflect raw addresses outside the UDS request frame.
Rejected: Positional mailbox marking after compaction | compaction can shift indices across the lock boundary.
Confidence: high
Scope-risk: moderate
Directive: Do not expose UDS capability tokens through SDK messages, environment variables, session registry, peer-list output, or SendMessage result/classifier surfaces.
Directive: Do not reintroduce positional mailbox acknowledgements unless compaction is removed or read+mark is atomic under one lock.
Tested: bun test src/utils/__tests__/ndjsonFramer.test.ts src/utils/__tests__/udsMessaging.test.ts packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts
Tested: bunx tsc --noEmit --pretty false
Tested: bun run lint
Tested: bunx biome lint modified src/package files
Tested: bun run test:all (3704 pass, 0 fail, 6734 expects)
Tested: bun audit (No vulnerabilities found)
Tested: bun run build
Tested: bun run build:vite
Tested: git diff --check
Not-tested: End-to-end external UDS client driving a full production headless model turn.
2026-04-26 21:44:42 +08:00
1517 changed files with 86484 additions and 86123 deletions

View File

@@ -1,8 +1,8 @@
root = true
[*]
indent_style = space
indent_size = 2
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true

View File

@@ -31,9 +31,6 @@ jobs:
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
run: bun install --frozen-lockfile
- name: Lint and format check
run: bunx biome ci .
- name: Type check
run: bun run typecheck

3
.gitignore vendored
View File

@@ -5,8 +5,7 @@ coverage
.env
*.log
.idea
.vscode/*
!.vscode/extensions.json
.vscode
*.suo
*.lock
src/utils/vendor/

View File

@@ -1 +0,0 @@
bunx lint-staged

View File

@@ -1 +0,0 @@
bun 1.3.13

View File

@@ -1,8 +0,0 @@
{
"recommendations": [
"biomejs.biome",
"ms-typescript.typescript",
"oven.bun-vscode",
"editorconfig.editorconfig"
]
}

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) and other AI coding
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bun run precheck` 必须零错误通过**(包含 typecheck + lint fix + test
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
@@ -47,12 +47,10 @@ bun test # run all tests
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
# Lint & Format (Biome) — 日常开发用 precheck 代替单独调用
bun run lint # lint check (全项目)
bun run lint:fix # auto-fix lint issues
bun run format # format all (全项目)
bun run check # lint + format check (全项目)
bun run check:fix # lint + format auto-fix
# Lint & Format (Biome)
bun run lint # check only
bun run lint:fix # auto-fix
bun run format # format all src/
# Health check
bun run health
@@ -60,8 +58,9 @@ bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint fix + test) — 任务完成后必须运行
bun run precheck
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Remote Control Server
bun run rcs
@@ -83,11 +82,9 @@ bun run docs:dev
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。覆盖 `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 失败。
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`lint + 构建 + 测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
- **CI**: GitHub Actions — `ci.yml`构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
@@ -311,7 +308,7 @@ mock.module("src/utils/debug.ts", debugMock);
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bun run precheck
bun run typecheck
```
**类型规范**
@@ -324,16 +321,14 @@ bun run precheck
## Working with This Codebase
- **precheck must pass** — `bun run precheck`typecheck + lint fix + test必须零错误,任何修改都不能引入新的类型/lint/测试错误。
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
- **Biome 配置** — 42 条 lint 规则decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/``scripts/``packages/`,含 `packages/@ant/`)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐2-space 缩进)。修改任何代码后应运行 `bun run precheck` 确认无类型/lint/格式/测试问题pre-commit hook 会自动拦截不合格提交
- **tsc 与 Biome 冲突处理** — 当 tsc 要求声明属性(赋值使用)但 biome 报 `noUnusedPrivateClassMembers`(只写不读)时,用 `// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>` 抑制 lint 警告,保留类型声明。`biome ci` 必须零 warnings。
- **`@ts-expect-error` 维护** — 只在下方代码确实有类型错误时保留 `@ts-expect-error`。如果类型系统已更新导致 directive 变为 unusedTS2578直接移除注释。MACRO 替换产生的永假比较(如 `'production' === 'development'`)仍需保留 `@ts-expect-error`
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。

View File

@@ -34,7 +34,7 @@
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
- 🚀 [想要启动项目](#-快速开始源码版)
- 🚀 [想要启动项目](#快速开始源码版)
- 🐛 [想要调试项目](#vs-code-调试)
- 📖 [想要学习项目](#teach-me-学习项目)
@@ -55,8 +55,6 @@ ccb update # 更新到最新版本
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
```
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
## ⚡ 快速开始(源码版)
### ⚙️ 环境要求

1330
V6.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +1,114 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noRedeclare": "off",
"noImplicitAnyLet": "off",
"noGlobalIsNan": "off",
"noFallthroughSwitchClause": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "off",
"noConsole": "off",
"noConfusingLabels": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"useConst": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useDefaultParameterLast": "off",
"noUnusedTemplateLiteral": "off",
"useTemplate": "off",
"useNumberNamespace": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off"
},
"complexity": {
"noForEach": "off",
"noBannedTypes": "off",
"noUselessConstructor": "off",
"noStaticOnlyClass": "off",
"useOptionalChain": "off",
"noUselessSwitchCase": "off",
"noUselessFragments": "off",
"noUselessTernary": "off",
"noUselessLoneBlockStatements": "off",
"noUselessEmptyExport": "off",
"useArrowFunction": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off",
"useExhaustiveDependencies": "off",
"noSwitchDeclarations": "off",
"noUnreachable": "off",
"useHookAtTopLevel": "off",
"noVoidTypeReturn": "off",
"noConstantCondition": "off",
"noUnusedFunctionParameters": "off"
},
"a11y": {
"recommended": false
},
"nursery": {
"recommended": false
}
}
},
"json": {
"formatter": {
"enabled": true
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded",
"trailingCommas": "all"
}
},
"overrides": [
{
"includes": ["**/*.tsx"],
"javascript": {
"formatter": {
"semicolons": "always"
}
},
"formatter": {
"lineWidth": 120
}
}
],
"assist": {
"enabled": false
}
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist", "!!**/packages/@ant"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noRedeclare": "off",
"noImplicitAnyLet": "off",
"noGlobalIsNan": "off",
"noFallthroughSwitchClause": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "off",
"noConsole": "off",
"noConfusingLabels": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"useConst": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useDefaultParameterLast": "off",
"noUnusedTemplateLiteral": "off",
"useTemplate": "off",
"useNumberNamespace": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off"
},
"complexity": {
"noForEach": "off",
"noBannedTypes": "off",
"noUselessConstructor": "off",
"noStaticOnlyClass": "off",
"useOptionalChain": "off",
"noUselessSwitchCase": "off",
"noUselessFragments": "off",
"noUselessTernary": "off",
"noUselessLoneBlockStatements": "off",
"noUselessEmptyExport": "off",
"useArrowFunction": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off",
"useExhaustiveDependencies": "off",
"noSwitchDeclarations": "off",
"noUnreachable": "off",
"useHookAtTopLevel": "off",
"noVoidTypeReturn": "off",
"noConstantCondition": "off",
"noUnusedFunctionParameters": "off"
},
"a11y": {
"recommended": false
},
"nursery": {
"recommended": false
}
}
},
"json": {
"formatter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded",
"trailingCommas": "all"
}
},
"overrides": [
{
"includes": ["**/*.tsx"],
"javascript": {
"formatter": {
"semicolons": "always"
}
},
"formatter": {
"lineWidth": 120
}
},
{
"includes": ["scripts/**", "packages/**", "**/*.js", "**/*.mjs", "**/*.jsx"],
"formatter": {
"enabled": false
}
}
],
"assist": {
"enabled": false
}
}

View File

@@ -56,8 +56,7 @@ for (const file of files) {
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
let bunPatched = 0
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
const BUN_DESTRUCTURE_SAFE =
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
for (const file of files) {
if (!file.endsWith('.js')) continue
const filePath = join(outdir, file)

View File

@@ -103,13 +103,11 @@
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"https-proxy-agent": "^8.0.0",
"husky": "^9.1.7",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.4.1",
"lint-staged": "^16.4.0",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
@@ -1540,8 +1538,6 @@
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -1636,12 +1632,8 @@
"cli-boxes": ["cli-boxes@4.0.1", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-highlight": ["cli-highlight@2.1.11", "https://registry.npmmirror.com/cli-highlight/-/cli-highlight-2.1.11.tgz", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"cli-width": ["cli-width@4.1.0", "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"cliui": ["cliui@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
@@ -1828,8 +1820,6 @@
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -1850,8 +1840,6 @@
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
@@ -2026,8 +2014,6 @@
"human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -2154,10 +2140,6 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="],
"listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
"locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
@@ -2180,8 +2162,6 @@
"lodash.once": ["lodash.once@4.1.1", "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -2312,8 +2292,6 @@
"mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -2562,14 +2540,10 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
@@ -2630,8 +2604,6 @@
"simple-swizzle": ["simple-swizzle@0.2.4", "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
@@ -2650,8 +2622,6 @@
"streamdown": ["streamdown@1.6.11", "https://registry.npmmirror.com/streamdown/-/streamdown-1.6.11.tgz", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.2.0", "https://registry.npmmirror.com/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@@ -3310,14 +3280,6 @@
"katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"lint-staged/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"listr2/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
@@ -3348,8 +3310,6 @@
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
@@ -3604,10 +3564,6 @@
"is-admin/execa/strip-final-newline": ["strip-final-newline@2.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"listr2/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"qrcode/yargs/cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"qrcode/yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -185,4 +185,4 @@
"destination": "/docs/introduction/what-is-claude-code"
}
]
}
}

View File

@@ -1,492 +0,0 @@
# 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).

View File

@@ -1,91 +0,0 @@
# 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 (2001000 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.

View File

@@ -1,314 +0,0 @@
# 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.

View File

@@ -1,659 +0,0 @@
# 内存泄漏排查报告
> 基于官方 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、清理 turnSpan7 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+ sizeCalculation22 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 Map5 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 等)。清理依赖两条路径:
**路径 1API 返回 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

View File

@@ -1,103 +0,0 @@
# 内存与性能峰值分析报告
> 进程 bunRSS 基线 **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.5msReact/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 架构限制约束)。

View File

@@ -12,12 +12,12 @@ Claude Code 将文件操作拆分为三个独立工具——这不是功能划
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|------|---------|---------|---------|
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: 100,000` |
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` |
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
<Tip>
Read 的 `maxResultSizeChars` 为 100,000100KB。超出此阈值的结果会被持久化到磁盘减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
</Tip>
## FileRead多模态文件读取引擎

View File

@@ -1,22 +1,22 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/entrypoints/cli.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/types/**", "src/**/*.d.ts"],
"ignoreDependencies": [
"@ant/*",
"react-compiler-runtime",
"@anthropic-ai/mcpb",
"@anthropic-ai/sandbox-runtime"
],
"ignoreBinaries": ["bun"],
"workspaces": {
"packages/*": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
},
"packages/@ant/*": {
"ignore": ["**"]
}
}
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/entrypoints/cli.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/types/**", "src/**/*.d.ts"],
"ignoreDependencies": [
"@ant/*",
"react-compiler-runtime",
"@anthropic-ai/mcpb",
"@anthropic-ai/sandbox-runtime"
],
"ignoreBinaries": ["bun"],
"workspaces": {
"packages/*": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
},
"packages/@ant/*": {
"ignore": ["**"]
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "2.0.4",
"version": "1.10.2",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -22,7 +22,7 @@
"repl"
],
"engines": {
"bun": ">=1.3.0"
"bun": ">=1.2.0"
},
"bin": {
"ccb": "dist/cli-node.js",
@@ -48,12 +48,9 @@
"dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build:vite",
"lint": "biome lint .",
"lint:fix": "biome lint --fix .",
"format": "biome format --write .",
"check": "biome check .",
"check:fix": "biome check --fix .",
"prepare": "husky",
"lint": "biome lint src/",
"lint:fix": "biome lint --fix src/",
"format": "biome format --write src/",
"test": "bun test",
"test:production": "bun run scripts/production-test.ts",
"test:production:offline": "bun run scripts/production-test.ts --offline",
@@ -65,7 +62,7 @@
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"precheck": "bun run typecheck && bun run check:fix && bun test",
"test:all": "bun run typecheck && bun test",
"rcs": "bun run scripts/rcs.ts"
},
"dependencies": {
@@ -76,11 +73,11 @@
},
"devDependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
"@ant/model-provider": "workspace:*",
"@ant/claude-for-chrome-mcp": "workspace:*",
"@ant/computer-use-input": "workspace:*",
"@ant/computer-use-mcp": "workspace:*",
"@ant/computer-use-swift": "workspace:*",
"@ant/model-provider": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.29.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
@@ -167,13 +164,11 @@
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"https-proxy-agent": "^8.0.0",
"husky": "^9.1.7",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.4.1",
"lint-staged": "^16.4.0",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
@@ -221,13 +216,5 @@
"hono": "4.12.15",
"postcss": "8.5.10",
"uuid": "14.0.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs,jsx}": [
"biome check --fix --no-errors-on-unmatched"
],
"*.{json,jsonc}": [
"biome format --write --no-errors-on-unmatched"
]
}
}

View File

@@ -1,8 +1,8 @@
{
"name": "@ant/claude-for-chrome-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
"name": "@ant/claude-for-chrome-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,546 +1,546 @@
export const BROWSER_TOOLS = [
{
name: 'javascript_tool',
name: "javascript_tool",
description:
"Execute JavaScript code in the context of the current page. The code runs in the page's context and can interact with the DOM, window object, and page variables. Returns the result of the last expression or any thrown errors. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
action: {
type: 'string',
type: "string",
description: "Must be set to 'javascript_exec'",
},
text: {
type: 'string',
type: "string",
description:
"The JavaScript code to execute. The code will be evaluated in the page context. The result of the last expression will be returned automatically. Do NOT use 'return' statements - just write the expression you want to evaluate (e.g., 'window.myData.value' not 'return window.myData.value'). You can access and modify the DOM, call page functions, and interact with page variables.",
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to execute the code in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['action', 'text', 'tabId'],
required: ["action", "text", "tabId"],
},
},
{
name: 'read_page',
name: "read_page",
description:
"Get an accessibility tree representation of elements on the page. By default returns all elements including non-visible ones. Output is limited to 50000 characters by default. If the output exceeds this limit, you will receive an error asking you to specify a smaller depth or focus on a specific element using ref_id. Optionally filter for only interactive elements. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
filter: {
type: 'string',
enum: ['interactive', 'all'],
type: "string",
enum: ["interactive", "all"],
description:
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to read from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
depth: {
type: 'number',
type: "number",
description:
'Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.',
"Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.",
},
ref_id: {
type: 'string',
type: "string",
description:
'Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.',
"Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.",
},
max_chars: {
type: 'number',
type: "number",
description:
'Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.',
"Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.",
},
},
required: ['tabId'],
required: ["tabId"],
},
},
{
name: 'find',
name: "find",
description:
'Find elements on the page using natural language. Can search for elements by their purpose (e.g., "search bar", "login button") or by text content (e.g., "organic mango product"). Returns up to 20 matching elements with references that can be used with other tools. If more than 20 matches exist, you\'ll be notified to use a more specific query. If you don\'t have a valid tab ID, use tabs_context_mcp first to get available tabs.',
inputSchema: {
type: 'object',
type: "object",
properties: {
query: {
type: 'string',
type: "string",
description:
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to search in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['query', 'tabId'],
required: ["query", "tabId"],
},
},
{
name: 'form_input',
name: "form_input",
description:
"Set values in form elements using element reference ID from the read_page tool. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
ref: {
type: 'string',
type: "string",
description:
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
},
value: {
type: ['string', 'boolean', 'number'],
type: ["string", "boolean", "number"],
description:
'The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number',
"The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number",
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to set form value in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['ref', 'value', 'tabId'],
required: ["ref", "value", "tabId"],
},
},
{
name: 'computer',
name: "computer",
description: `Use a mouse and keyboard to interact with a web browser, and take screenshots. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.\n* Whenever you intend to click on an element like an icon, you should consult a screenshot to determine the coordinates of the element before moving the cursor.\n* If you tried clicking on a program or link but it failed to load, even after waiting, try adjusting your click location so that the tip of the cursor visually falls on the element that you want to click.\n* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.`,
inputSchema: {
type: 'object',
type: "object",
properties: {
action: {
type: 'string',
type: "string",
enum: [
'left_click',
'right_click',
'type',
'screenshot',
'wait',
'scroll',
'key',
'left_click_drag',
'double_click',
'triple_click',
'zoom',
'scroll_to',
'hover',
"left_click",
"right_click",
"type",
"screenshot",
"wait",
"scroll",
"key",
"left_click_drag",
"double_click",
"triple_click",
"zoom",
"scroll_to",
"hover",
],
description:
'The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.',
"The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.",
},
coordinate: {
type: 'array',
items: { type: 'number' },
type: "array",
items: { type: "number" },
minItems: 2,
maxItems: 2,
description:
'(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.',
"(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.",
},
text: {
type: 'string',
type: "string",
description:
'The text to type (for `type` action) or the key(s) to press (for `key` action). For `key` action: Provide space-separated keys (e.g., "Backspace Backspace Delete"). Supports keyboard shortcuts using the platform\'s modifier key (use "cmd" on Mac, "ctrl" on Windows/Linux, e.g., "cmd+a" or "ctrl+a" for select all).',
},
duration: {
type: 'number',
type: "number",
minimum: 0,
maximum: 30,
description:
'The number of seconds to wait. Required for `wait`. Maximum 30 seconds.',
"The number of seconds to wait. Required for `wait`. Maximum 30 seconds.",
},
scroll_direction: {
type: 'string',
enum: ['up', 'down', 'left', 'right'],
description: 'The direction to scroll. Required for `scroll`.',
type: "string",
enum: ["up", "down", "left", "right"],
description: "The direction to scroll. Required for `scroll`.",
},
scroll_amount: {
type: 'number',
type: "number",
minimum: 1,
maximum: 10,
description:
'The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.',
"The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.",
},
start_coordinate: {
type: 'array',
items: { type: 'number' },
type: "array",
items: { type: "number" },
minItems: 2,
maxItems: 2,
description:
'(x, y): The starting coordinates for `left_click_drag`.',
"(x, y): The starting coordinates for `left_click_drag`.",
},
region: {
type: 'array',
items: { type: 'number' },
type: "array",
items: { type: "number" },
minItems: 4,
maxItems: 4,
description:
'(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.',
"(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.",
},
repeat: {
type: 'number',
type: "number",
minimum: 1,
maximum: 100,
description:
'Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.',
"Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.",
},
ref: {
type: 'string',
type: "string",
description:
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Required for `scroll_to` action. Can be used as alternative to `coordinate` for click actions.',
},
modifiers: {
type: 'string',
type: "string",
description:
'Modifier keys for click actions. Supports: "ctrl", "shift", "alt", "cmd" (or "meta"), "win" (or "windows"). Can be combined with "+" (e.g., "ctrl+shift", "cmd+alt"). Optional.',
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to execute the action on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['action', 'tabId'],
required: ["action", "tabId"],
},
},
{
name: 'navigate',
name: "navigate",
description:
"Navigate to a URL, or go forward/back in browser history. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
url: {
type: 'string',
type: "string",
description:
'The URL to navigate to. Can be provided with or without protocol (defaults to https://). Use "forward" to go forward in history or "back" to go back in history.',
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to navigate. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['url', 'tabId'],
required: ["url", "tabId"],
},
},
{
name: 'resize_window',
name: "resize_window",
description:
"Resize the current browser window to specified dimensions. Useful for testing responsive designs or setting up specific screen sizes. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
width: {
type: 'number',
description: 'Target window width in pixels',
type: "number",
description: "Target window width in pixels",
},
height: {
type: 'number',
description: 'Target window height in pixels',
type: "number",
description: "Target window height in pixels",
},
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to get the window for. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['width', 'height', 'tabId'],
required: ["width", "height", "tabId"],
},
},
{
name: 'gif_creator',
name: "gif_creator",
description:
"Manage GIF recording and export for browser automation sessions. Control when to start/stop recording browser actions (clicks, scrolls, navigation), then export as an animated GIF with visual overlays (click indicators, action labels, progress bar, watermark). All operations are scoped to the tab's group. When starting recording, take a screenshot immediately after to capture the initial state as the first frame. When stopping recording, take a screenshot immediately before to capture the final state as the last frame. For export, either provide 'coordinate' to drag/drop upload to a page element, or set 'download: true' to download the GIF.",
inputSchema: {
type: 'object',
type: "object",
properties: {
action: {
type: 'string',
enum: ['start_recording', 'stop_recording', 'export', 'clear'],
type: "string",
enum: ["start_recording", "stop_recording", "export", "clear"],
description:
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
},
tabId: {
type: 'number',
type: "number",
description:
'Tab ID to identify which tab group this operation applies to',
"Tab ID to identify which tab group this operation applies to",
},
download: {
type: 'boolean',
type: "boolean",
description:
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
},
filename: {
type: 'string',
type: "string",
description:
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
},
options: {
type: 'object',
type: "object",
description:
"Optional GIF enhancement options for 'export' action. Properties: showClickIndicators (bool), showDragPaths (bool), showActionLabels (bool), showProgressBar (bool), showWatermark (bool), quality (number 1-30). All default to true except quality (default: 10).",
properties: {
showClickIndicators: {
type: 'boolean',
type: "boolean",
description:
'Show orange circles at click locations (default: true)',
"Show orange circles at click locations (default: true)",
},
showDragPaths: {
type: 'boolean',
description: 'Show red arrows for drag actions (default: true)',
type: "boolean",
description: "Show red arrows for drag actions (default: true)",
},
showActionLabels: {
type: 'boolean',
type: "boolean",
description:
'Show black labels describing actions (default: true)',
"Show black labels describing actions (default: true)",
},
showProgressBar: {
type: 'boolean',
description: 'Show orange progress bar at bottom (default: true)',
type: "boolean",
description: "Show orange progress bar at bottom (default: true)",
},
showWatermark: {
type: 'boolean',
description: 'Show Claude logo watermark (default: true)',
type: "boolean",
description: "Show Claude logo watermark (default: true)",
},
quality: {
type: 'number',
type: "number",
description:
'GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10',
"GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10",
},
},
},
},
required: ['action', 'tabId'],
required: ["action", "tabId"],
},
},
{
name: 'upload_image',
name: "upload_image",
description:
'Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.',
"Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.",
inputSchema: {
type: 'object',
type: "object",
properties: {
imageId: {
type: 'string',
type: "string",
description:
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
},
ref: {
type: 'string',
type: "string",
description:
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Use this for file inputs (especially hidden ones) or specific elements. Provide either ref or coordinate, not both.',
},
coordinate: {
type: 'array',
type: "array",
items: {
type: 'number',
type: "number",
},
description:
'Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.',
"Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.",
},
tabId: {
type: 'number',
type: "number",
description:
'Tab ID where the target element is located. This is where the image will be uploaded to.',
"Tab ID where the target element is located. This is where the image will be uploaded to.",
},
filename: {
type: 'string',
type: "string",
description:
'Optional filename for the uploaded file (default: "image.png")',
},
},
required: ['imageId', 'tabId'],
required: ["imageId", "tabId"],
},
},
{
name: 'get_page_text',
name: "get_page_text",
description:
"Extract raw text content from the page, prioritizing article content. Ideal for reading articles, blog posts, or other text-heavy pages. Returns plain text without HTML formatting. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to extract text from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['tabId'],
required: ["tabId"],
},
},
{
name: 'tabs_context_mcp',
title: 'Tabs Context',
name: "tabs_context_mcp",
title: "Tabs Context",
description:
'Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.',
"Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.",
inputSchema: {
type: 'object',
type: "object",
properties: {
createIfEmpty: {
type: 'boolean',
type: "boolean",
description:
'Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.',
"Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.",
},
},
required: [],
},
},
{
name: 'tabs_create_mcp',
title: 'Tabs Create',
name: "tabs_create_mcp",
title: "Tabs Create",
description:
'Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.',
"Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.",
inputSchema: {
type: 'object',
type: "object",
properties: {},
required: [],
},
},
{
name: 'update_plan',
name: "update_plan",
description:
'Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.',
"Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.",
inputSchema: {
type: 'object' as const,
type: "object" as const,
properties: {
domains: {
type: 'array' as const,
items: { type: 'string' as const },
type: "array" as const,
items: { type: "string" as const },
description:
"List of domains you will visit (e.g., ['github.com', 'stackoverflow.com']). These domains will be approved for the session when the user accepts the plan.",
},
approach: {
type: 'array' as const,
items: { type: 'string' as const },
type: "array" as const,
items: { type: "string" as const },
description:
'High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.',
"High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.",
},
},
required: ['domains', 'approach'],
required: ["domains", "approach"],
},
},
{
name: 'read_console_messages',
name: "read_console_messages",
description:
"Read browser console messages (console.log, console.error, console.warn, etc.) from a specific tab. Useful for debugging JavaScript errors, viewing application logs, or understanding what's happening in the browser console. Returns console messages from the current domain only. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs. IMPORTANT: Always provide a pattern to filter messages - without a pattern, you may get too many irrelevant messages.",
inputSchema: {
type: 'object',
type: "object",
properties: {
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to read console messages from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
onlyErrors: {
type: 'boolean',
type: "boolean",
description:
'If true, only return error and exception messages. Default is false (return all message types).',
"If true, only return error and exception messages. Default is false (return all message types).",
},
clear: {
type: 'boolean',
type: "boolean",
description:
'If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.',
"If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.",
},
pattern: {
type: 'string',
type: "string",
description:
"Regex pattern to filter console messages. Only messages matching this pattern will be returned (e.g., 'error|warning' to find errors and warnings, 'MyApp' to filter app-specific logs). You should always provide a pattern to avoid getting too many irrelevant messages.",
},
limit: {
type: 'number',
type: "number",
description:
'Maximum number of messages to return. Defaults to 100. Increase only if you need more results.',
"Maximum number of messages to return. Defaults to 100. Increase only if you need more results.",
},
},
required: ['tabId'],
required: ["tabId"],
},
},
{
name: 'read_network_requests',
name: "read_network_requests",
description:
"Read HTTP network requests (XHR, Fetch, documents, images, etc.) from a specific tab. Useful for debugging API calls, monitoring network activity, or understanding what requests a page is making. Returns all network requests made by the current page, including cross-origin requests. Requests are automatically cleared when the page navigates to a different domain. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: 'object',
type: "object",
properties: {
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to read network requests from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
urlPattern: {
type: 'string',
type: "string",
description:
"Optional URL pattern to filter requests. Only requests whose URL contains this string will be returned (e.g., '/api/' to filter API calls, 'example.com' to filter by domain).",
},
clear: {
type: 'boolean',
type: "boolean",
description:
'If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.',
"If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.",
},
limit: {
type: 'number',
type: "number",
description:
'Maximum number of requests to return. Defaults to 100. Increase only if you need more results.',
"Maximum number of requests to return. Defaults to 100. Increase only if you need more results.",
},
},
required: ['tabId'],
required: ["tabId"],
},
},
{
name: 'shortcuts_list',
name: "shortcuts_list",
description:
'List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.',
"List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.",
inputSchema: {
type: 'object',
type: "object",
properties: {
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to list shortcuts from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ['tabId'],
required: ["tabId"],
},
},
{
name: 'shortcuts_execute',
name: "shortcuts_execute",
description:
'Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.',
"Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.",
inputSchema: {
type: 'object',
type: "object",
properties: {
tabId: {
type: 'number',
type: "number",
description:
"Tab ID to execute the shortcut on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
shortcutId: {
type: 'string',
description: 'The ID of the shortcut to execute',
type: "string",
description: "The ID of the shortcut to execute",
},
command: {
type: 'string',
type: "string",
description:
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
},
},
required: ['tabId'],
required: ["tabId"],
},
},
{
name: 'switch_browser',
name: "switch_browser",
description:
"Switch which Chrome browser is used for browser automation. Call this when the user wants to connect to a different Chrome browser. Broadcasts a connection request to all Chrome browsers with the extension installed — the user clicks 'Connect' in the desired browser.",
inputSchema: {
type: 'object',
type: "object",
properties: {},
required: [],
},
},
]
];

View File

@@ -1,10 +1,10 @@
export { BridgeClient, createBridgeClient } from './bridgeClient.js'
export { BROWSER_TOOLS } from './browserTools.js'
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
export { BROWSER_TOOLS } from "./browserTools.js";
export {
createChromeSocketClient,
createClaudeForChromeMcpServer,
} from './mcpServer.js'
export { localPlatformLabel } from './types.js'
} from "./mcpServer.js";
export { localPlatformLabel } from "./types.js";
export type {
BridgeConfig,
ChromeExtensionInfo,
@@ -12,4 +12,4 @@ export type {
Logger,
PermissionMode,
SocketClient,
} from './types.js'
} from "./types.js";

View File

@@ -1,16 +1,16 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
} from "@modelcontextprotocol/sdk/types.js";
import { createBridgeClient } from './bridgeClient.js'
import { BROWSER_TOOLS } from './browserTools.js'
import { createMcpSocketClient } from './mcpSocketClient.js'
import { createMcpSocketPool } from './mcpSocketPool.js'
import { handleToolCall } from './toolCalls.js'
import type { ClaudeForChromeContext, SocketClient } from './types.js'
import { createBridgeClient } from "./bridgeClient.js";
import { BROWSER_TOOLS } from "./browserTools.js";
import { createMcpSocketClient } from "./mcpSocketClient.js";
import { createMcpSocketPool } from "./mcpSocketPool.js";
import { handleToolCall } from "./toolCalls.js";
import type { ClaudeForChromeContext, SocketClient } from "./types.js";
/**
* Create the socket/bridge client for the Chrome extension MCP server.
@@ -24,22 +24,23 @@ export function createChromeSocketClient(
? createBridgeClient(context)
: context.getSocketPaths
? createMcpSocketPool(context)
: createMcpSocketClient(context)
: createMcpSocketClient(context);
}
export function createClaudeForChromeMcpServer(
context: ClaudeForChromeContext,
existingSocketClient?: SocketClient,
): Server {
const { serverName, logger } = context
const { serverName, logger } = context;
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
const socketClient = existingSocketClient ?? createChromeSocketClient(context)
const socketClient =
existingSocketClient ?? createChromeSocketClient(context);
const server = new Server(
{
name: serverName,
version: '1.0.0',
version: "1.0.0",
},
{
capabilities: {
@@ -47,49 +48,49 @@ export function createClaudeForChromeMcpServer(
logging: {},
},
},
)
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
if (context.isDisabled?.()) {
return { tools: [] }
return { tools: [] };
}
return {
tools: context.bridgeConfig
? BROWSER_TOOLS
: BROWSER_TOOLS.filter(t => t.name !== 'switch_browser'),
}
})
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
};
});
server.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
logger.info(`[${serverName}] Executing tool: ${request.params.name}`)
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
return handleToolCall(
context,
socketClient,
request.params.name,
request.params.arguments || {},
)
);
},
)
);
socketClient.setNotificationHandler(notification => {
socketClient.setNotificationHandler((notification) => {
logger.info(
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
)
);
server
.notification({
method: notification.method,
params: notification.params,
})
.catch(error => {
.catch((error) => {
// Server may not be connected yet (e.g., during startup or after disconnect)
logger.info(
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
)
})
})
);
});
});
return server
return server;
}

View File

@@ -1,324 +1,327 @@
import { promises as fsPromises } from 'fs'
import { createConnection } from 'net'
import type { Socket } from 'net'
import { platform } from 'os'
import { dirname } from 'path'
import { promises as fsPromises } from "fs";
import { createConnection } from "net";
import type { Socket } from "net";
import { platform } from "os";
import { dirname } from "path";
import type {
ClaudeForChromeContext,
PermissionMode,
PermissionOverrides,
} from './types.js'
} from "./types.js";
export class SocketConnectionError extends Error {
constructor(message: string) {
super(message)
this.name = 'SocketConnectionError'
super(message);
this.name = "SocketConnectionError";
}
}
interface ToolRequest {
method: string // "execute_tool"
method: string; // "execute_tool"
params?: {
client_id?: string // "desktop" | "claude-code"
tool?: string
args?: Record<string, unknown>
}
client_id?: string; // "desktop" | "claude-code"
tool?: string;
args?: Record<string, unknown>;
};
}
interface ToolResponse {
result?: unknown
error?: string
result?: unknown;
error?: string;
}
interface Notification {
method: string
params?: Record<string, unknown>
method: string;
params?: Record<string, unknown>;
}
type SocketMessage = ToolResponse | Notification
type SocketMessage = ToolResponse | Notification;
function isToolResponse(message: SocketMessage): message is ToolResponse {
return 'result' in message || 'error' in message
return "result" in message || "error" in message;
}
function isNotification(message: SocketMessage): message is Notification {
return 'method' in message && typeof message.method === 'string'
return "method" in message && typeof message.method === "string";
}
class McpSocketClient {
private socket: Socket | null = null
private connected = false
private connecting = false
private responseCallback: ((response: ToolResponse) => void) | null = null
private socket: Socket | null = null;
private connected = false;
private connecting = false;
private responseCallback: ((response: ToolResponse) => void) | null = null;
private notificationHandler: ((notification: Notification) => void) | null =
null
private responseBuffer = Buffer.alloc(0)
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private reconnectTimer: NodeJS.Timeout | null = null
private context: ClaudeForChromeContext
null;
private responseBuffer = Buffer.alloc(0);
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000;
private reconnectTimer: NodeJS.Timeout | null = null;
private context: ClaudeForChromeContext;
// When true, disables automatic reconnection. Used by McpSocketPool which
// manages reconnection externally by rescanning available sockets.
public disableAutoReconnect = false
public disableAutoReconnect = false;
constructor(context: ClaudeForChromeContext) {
this.context = context
this.context = context;
}
private async connect(): Promise<void> {
const { serverName, logger } = this.context
const { serverName, logger } = this.context;
if (this.connecting) {
logger.info(
`[${serverName}] Already connecting, skipping duplicate attempt`,
)
return
);
return;
}
this.closeSocket()
this.connecting = true
this.closeSocket();
this.connecting = true;
const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`)
const socketPath =
this.context.getSocketPath?.() ?? this.context.socketPath;
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
try {
await this.validateSocketSecurity(socketPath)
await this.validateSocketSecurity(socketPath);
} catch (error) {
this.connecting = false
logger.info(`[${serverName}] Security validation failed:`, error)
this.connecting = false;
logger.info(`[${serverName}] Security validation failed:`, error);
// Don't retry on security failures (wrong perms/owner) - those won't
// self-resolve. Only the error handler retries on transient errors.
return
return;
}
this.socket = createConnection(socketPath)
this.socket = createConnection(socketPath);
// Timeout the initial connection attempt - if socket file exists but native
// host is dead, the connect can hang indefinitely
const connectTimeout = setTimeout(() => {
if (!this.connected) {
logger.info(`[${serverName}] Connection attempt timed out after 5000ms`)
this.closeSocket()
this.scheduleReconnect()
logger.info(
`[${serverName}] Connection attempt timed out after 5000ms`,
);
this.closeSocket();
this.scheduleReconnect();
}
}, 5000)
}, 5000);
this.socket.on('connect', () => {
clearTimeout(connectTimeout)
this.connected = true
this.connecting = false
this.reconnectAttempts = 0
logger.info(`[${serverName}] Successfully connected to bridge server`)
})
this.socket.on("connect", () => {
clearTimeout(connectTimeout);
this.connected = true;
this.connecting = false;
this.reconnectAttempts = 0;
logger.info(`[${serverName}] Successfully connected to bridge server`);
});
this.socket.on('data', (data: Buffer) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, data])
this.socket.on("data", (data: Buffer) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
while (this.responseBuffer.length >= 4) {
const length = this.responseBuffer.readUInt32LE(0)
const length = this.responseBuffer.readUInt32LE(0);
if (this.responseBuffer.length < 4 + length) {
break
break;
}
const messageBytes = this.responseBuffer.slice(4, 4 + length)
this.responseBuffer = this.responseBuffer.slice(4 + length)
const messageBytes = this.responseBuffer.slice(4, 4 + length);
this.responseBuffer = this.responseBuffer.slice(4 + length);
try {
const message = JSON.parse(
messageBytes.toString('utf-8'),
) as SocketMessage
messageBytes.toString("utf-8"),
) as SocketMessage;
if (isNotification(message)) {
logger.info(
`[${serverName}] Received notification: ${message.method}`,
)
);
if (this.notificationHandler) {
this.notificationHandler(message)
this.notificationHandler(message);
}
} else if (isToolResponse(message)) {
logger.info(`[${serverName}] Received tool response: ${message}`)
this.handleResponse(message)
logger.info(`[${serverName}] Received tool response: ${message}`);
this.handleResponse(message);
} else {
logger.info(`[${serverName}] Received unknown message: ${message}`)
logger.info(`[${serverName}] Received unknown message: ${message}`);
}
} catch (error) {
logger.info(`[${serverName}] Failed to parse message:`, error)
logger.info(`[${serverName}] Failed to parse message:`, error);
}
}
})
});
this.socket.on('error', (error: Error & { code?: string }) => {
clearTimeout(connectTimeout)
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
this.connected = false
this.connecting = false
this.socket.on("error", (error: Error & { code?: string }) => {
clearTimeout(connectTimeout);
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error);
this.connected = false;
this.connecting = false;
if (
error.code &&
[
'ECONNREFUSED', // Native host not listening (stale socket)
'ECONNRESET', // Connection reset by peer
'EPIPE', // Broken pipe (native host died mid-write)
'ENOENT', // Socket file was deleted
'EOPNOTSUPP', // Socket file exists but is not a valid socket
'ECONNABORTED', // Connection aborted
"ECONNREFUSED", // Native host not listening (stale socket)
"ECONNRESET", // Connection reset by peer
"EPIPE", // Broken pipe (native host died mid-write)
"ENOENT", // Socket file was deleted
"EOPNOTSUPP", // Socket file exists but is not a valid socket
"ECONNABORTED", // Connection aborted
].includes(error.code)
) {
this.scheduleReconnect()
this.scheduleReconnect();
}
})
});
this.socket.on('close', () => {
clearTimeout(connectTimeout)
this.connected = false
this.connecting = false
this.scheduleReconnect()
})
this.socket.on("close", () => {
clearTimeout(connectTimeout);
this.connected = false;
this.connecting = false;
this.scheduleReconnect();
});
}
private scheduleReconnect(): void {
const { serverName, logger } = this.context
const { serverName, logger } = this.context;
if (this.disableAutoReconnect) {
return
return;
}
if (this.reconnectTimer) {
logger.info(`[${serverName}] Reconnect already scheduled, skipping`)
return
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
return;
}
this.reconnectAttempts++
this.reconnectAttempts++;
// Give up after extended polling (~50 min). A new ensureConnected() call
// from a tool request will restart the cycle if needed.
const maxTotalAttempts = 100
const maxTotalAttempts = 100;
if (this.reconnectAttempts > maxTotalAttempts) {
logger.info(
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
)
this.reconnectAttempts = 0
return
);
this.reconnectAttempts = 0;
return;
}
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
const delay = Math.min(
this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1),
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
30000,
)
);
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
logger.info(
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
this.reconnectAttempts
})`,
)
);
} else if (this.reconnectAttempts % 10 === 0) {
// Log every 10th slow-poll attempt to avoid log spam
logger.info(
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
)
);
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
void this.connect()
}, delay)
this.reconnectTimer = null;
void this.connect();
}, delay);
}
private handleResponse(response: ToolResponse): void {
if (this.responseCallback) {
const callback = this.responseCallback
this.responseCallback = null
callback(response)
const callback = this.responseCallback;
this.responseCallback = null;
callback(response);
}
}
public setNotificationHandler(
handler: (notification: Notification) => void,
): void {
this.notificationHandler = handler
this.notificationHandler = handler;
}
public async ensureConnected(): Promise<boolean> {
const { serverName } = this.context
const { serverName } = this.context;
if (this.connected && this.socket) {
return true
return true;
}
if (!this.socket && !this.connecting) {
await this.connect()
await this.connect();
}
// Wait for connection with timeout
return new Promise((resolve, reject) => {
let checkTimeoutId: NodeJS.Timeout | null = null
let checkTimeoutId: NodeJS.Timeout | null = null;
const timeout = setTimeout(() => {
if (checkTimeoutId) {
clearTimeout(checkTimeoutId)
clearTimeout(checkTimeoutId);
}
reject(
new SocketConnectionError(
`[${serverName}] Connection attempt timed out after 5000ms`,
),
)
}, 5000)
);
}, 5000);
const checkConnection = () => {
if (this.connected) {
clearTimeout(timeout)
resolve(true)
clearTimeout(timeout);
resolve(true);
} else {
checkTimeoutId = setTimeout(checkConnection, 500)
checkTimeoutId = setTimeout(checkConnection, 500);
}
}
checkConnection()
})
};
checkConnection();
});
}
private async sendRequest(
request: ToolRequest,
timeoutMs = 30000,
): Promise<ToolResponse> {
const { serverName } = this.context
const { serverName } = this.context;
if (!this.socket) {
throw new SocketConnectionError(
`[${serverName}] Cannot send request: not connected`,
)
);
}
const socket = this.socket
const socket = this.socket;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.responseCallback = null
this.responseCallback = null;
reject(
new SocketConnectionError(
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
),
)
}, timeoutMs)
);
}, timeoutMs);
this.responseCallback = response => {
clearTimeout(timeout)
resolve(response)
}
this.responseCallback = (response) => {
clearTimeout(timeout);
resolve(response);
};
const requestJson = JSON.stringify(request)
const requestBytes = Buffer.from(requestJson, 'utf-8')
const requestJson = JSON.stringify(request);
const requestBytes = Buffer.from(requestJson, "utf-8");
const lengthPrefix = Buffer.allocUnsafe(4)
lengthPrefix.writeUInt32LE(requestBytes.length, 0)
const lengthPrefix = Buffer.allocUnsafe(4);
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
const message = Buffer.concat([lengthPrefix, requestBytes])
socket.write(message)
})
const message = Buffer.concat([lengthPrefix, requestBytes]);
socket.write(message);
});
}
public async callTool(
@@ -327,15 +330,15 @@ class McpSocketClient {
_permissionOverrides?: PermissionOverrides,
): Promise<unknown> {
const request: ToolRequest = {
method: 'execute_tool',
method: "execute_tool",
params: {
client_id: this.context.clientTypeId,
tool: name,
args,
},
}
};
return this.sendRequestWithRetry(request)
return this.sendRequestWithRetry(request);
}
/**
@@ -346,23 +349,23 @@ class McpSocketClient {
* and retry once.
*/
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
const { serverName, logger } = this.context
const { serverName, logger } = this.context;
try {
return await this.sendRequest(request)
return await this.sendRequest(request);
} catch (error) {
if (!(error instanceof SocketConnectionError)) {
throw error
throw error;
}
logger.info(
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
)
);
this.closeSocket()
await this.ensureConnected()
this.closeSocket();
await this.ensureConnected();
return await this.sendRequest(request)
return await this.sendRequest(request);
}
}
@@ -374,109 +377,109 @@ class McpSocketClient {
}
public isConnected(): boolean {
return this.connected
return this.connected;
}
private closeSocket(): void {
if (this.socket) {
this.socket.removeAllListeners()
this.socket.end()
this.socket.destroy()
this.socket = null
this.socket.removeAllListeners();
this.socket.end();
this.socket.destroy();
this.socket = null;
}
this.connected = false
this.connecting = false
this.connected = false;
this.connecting = false;
}
private cleanup(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.closeSocket()
this.reconnectAttempts = 0
this.responseBuffer = Buffer.alloc(0)
this.responseCallback = null
this.closeSocket();
this.reconnectAttempts = 0;
this.responseBuffer = Buffer.alloc(0);
this.responseCallback = null;
}
public disconnect(): void {
this.cleanup()
this.cleanup();
}
private async validateSocketSecurity(socketPath: string): Promise<void> {
const { serverName, logger } = this.context
if (platform() === 'win32') {
return
const { serverName, logger } = this.context;
if (platform() === "win32") {
return;
}
try {
// Validate the parent directory permissions if it's the socket directory
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
const dirPath = dirname(socketPath)
const dirBasename = dirPath.split('/').pop() || ''
const isSocketDir = dirBasename.startsWith('claude-mcp-browser-bridge-')
const dirPath = dirname(socketPath);
const dirBasename = dirPath.split("/").pop() || "";
const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-");
if (isSocketDir) {
try {
const dirStats = await fsPromises.stat(dirPath)
const dirStats = await fsPromises.stat(dirPath);
if (dirStats.isDirectory()) {
const dirMode = dirStats.mode & 0o777
const dirMode = dirStats.mode & 0o777;
if (dirMode !== 0o700) {
throw new Error(
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
8,
)} (expected 0700). Directory may have been tampered with.`,
)
);
}
const currentUid = process.getuid?.()
const currentUid = process.getuid?.();
if (currentUid !== undefined && dirStats.uid !== currentUid) {
throw new Error(
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
`Potential security risk.`,
)
);
}
}
} catch (dirError) {
if ((dirError as NodeJS.ErrnoException).code !== 'ENOENT') {
throw dirError
if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") {
throw dirError;
}
// Directory doesn't exist yet - native host will create it
}
}
const stats = await fsPromises.stat(socketPath)
const stats = await fsPromises.stat(socketPath);
if (!stats.isSocket()) {
throw new Error(
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
)
);
}
const mode = stats.mode & 0o777
const mode = stats.mode & 0o777;
if (mode !== 0o600) {
throw new Error(
`[${serverName}] Insecure socket permissions: ${mode.toString(
8,
)} (expected 0600). Socket may have been tampered with.`,
)
);
}
const currentUid = process.getuid?.()
const currentUid = process.getuid?.();
if (currentUid !== undefined && stats.uid !== currentUid) {
throw new Error(
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
`Potential security risk.`,
)
);
}
logger.info(`[${serverName}] Socket security validation passed`)
logger.info(`[${serverName}] Socket security validation passed`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
logger.info(
`[${serverName}] Socket not found, will be created by server`,
)
return
);
return;
}
throw error
throw error;
}
}
}
@@ -484,7 +487,7 @@ class McpSocketClient {
export function createMcpSocketClient(
context: ClaudeForChromeContext,
): McpSocketClient {
return new McpSocketClient(context)
return new McpSocketClient(context);
}
export type { McpSocketClient }
export type { McpSocketClient };

View File

@@ -1,13 +1,13 @@
import {
createMcpSocketClient,
SocketConnectionError,
} from './mcpSocketClient.js'
import type { McpSocketClient } from './mcpSocketClient.js'
} from "./mcpSocketClient.js";
import type { McpSocketClient } from "./mcpSocketClient.js";
import type {
ClaudeForChromeContext,
PermissionMode,
PermissionOverrides,
} from './types.js'
} from "./types.js";
/**
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
@@ -18,29 +18,26 @@ import type {
* built from tabs_context_mcp responses.
*/
export class McpSocketPool {
private clients: Map<string, McpSocketClient> = new Map()
private tabRoutes: Map<number, string> = new Map()
private context: ClaudeForChromeContext
private clients: Map<string, McpSocketClient> = new Map();
private tabRoutes: Map<number, string> = new Map();
private context: ClaudeForChromeContext;
private notificationHandler:
| ((notification: {
method: string
params?: Record<string, unknown>
}) => void)
| null = null
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
| null = null;
constructor(context: ClaudeForChromeContext) {
this.context = context
this.context = context;
}
public setNotificationHandler(
handler: (notification: {
method: string
params?: Record<string, unknown>
method: string;
params?: Record<string, unknown>;
}) => void,
): void {
this.notificationHandler = handler
this.notificationHandler = handler;
for (const client of this.clients.values()) {
client.setNotificationHandler(handler)
client.setNotificationHandler(handler);
}
}
@@ -48,30 +45,32 @@ export class McpSocketPool {
* Discover available sockets and ensure at least one is connected.
*/
public async ensureConnected(): Promise<boolean> {
const { logger, serverName } = this.context
const { logger, serverName } = this.context;
this.refreshClients()
this.refreshClients();
// Try to connect any disconnected clients
const connectPromises: Promise<boolean>[] = []
const connectPromises: Promise<boolean>[] = [];
for (const client of this.clients.values()) {
if (!client.isConnected()) {
connectPromises.push(client.ensureConnected().catch(() => false))
connectPromises.push(
client.ensureConnected().catch(() => false),
);
}
}
if (connectPromises.length > 0) {
await Promise.all(connectPromises)
await Promise.all(connectPromises);
}
const connectedCount = this.getConnectedClients().length
const connectedCount = this.getConnectedClients().length;
if (connectedCount === 0) {
logger.info(`[${serverName}] No connected sockets in pool`)
return false
logger.info(`[${serverName}] No connected sockets in pool`);
return false;
}
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`)
return true
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
return true;
}
/**
@@ -83,57 +82,57 @@ export class McpSocketPool {
args: Record<string, unknown>,
_permissionOverrides?: PermissionOverrides,
): Promise<unknown> {
if (name === 'tabs_context_mcp') {
return this.callTabsContext(args)
if (name === "tabs_context_mcp") {
return this.callTabsContext(args);
}
// Route by tabId if present
const tabId = args.tabId as number | undefined
const tabId = args.tabId as number | undefined;
if (tabId !== undefined) {
const socketPath = this.tabRoutes.get(tabId)
const socketPath = this.tabRoutes.get(tabId);
if (socketPath) {
const client = this.clients.get(socketPath)
const client = this.clients.get(socketPath);
if (client?.isConnected()) {
return client.callTool(name, args)
return client.callTool(name, args);
}
}
// Tab route not found or client disconnected — fall through to any connected
}
// Fallback: use first connected client
const connected = this.getConnectedClients()
const connected = this.getConnectedClients();
if (connected.length === 0) {
throw new SocketConnectionError(
`[${this.context.serverName}] No connected sockets available`,
)
);
}
return connected[0]!.callTool(name, args)
return connected[0]!.callTool(name, args);
}
public async setPermissionMode(
mode: PermissionMode,
allowedDomains?: string[],
): Promise<void> {
const connected = this.getConnectedClients()
const connected = this.getConnectedClients();
await Promise.all(
connected.map(client => client.setPermissionMode(mode, allowedDomains)),
)
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
);
}
public isConnected(): boolean {
return this.getConnectedClients().length > 0
return this.getConnectedClients().length > 0;
}
public disconnect(): void {
for (const client of this.clients.values()) {
client.disconnect()
client.disconnect();
}
this.clients.clear()
this.tabRoutes.clear()
this.clients.clear();
this.tabRoutes.clear();
}
private getConnectedClients(): McpSocketClient[] {
return [...this.clients.values()].filter(c => c.isConnected())
return [...this.clients.values()].filter((c) => c.isConnected());
}
/**
@@ -143,173 +142,173 @@ export class McpSocketPool {
private async callTabsContext(
args: Record<string, unknown>,
): Promise<unknown> {
const { logger, serverName } = this.context
const connected = this.getConnectedClients()
const { logger, serverName } = this.context;
const connected = this.getConnectedClients();
if (connected.length === 0) {
throw new SocketConnectionError(
`[${serverName}] No connected sockets available`,
)
);
}
// If only one client, skip merging overhead
if (connected.length === 1) {
const result = await connected[0]!.callTool('tabs_context_mcp', args)
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!))
return result
const result = await connected[0]!.callTool("tabs_context_mcp", args);
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!));
return result;
}
// Query all connected clients in parallel
const results = await Promise.allSettled(
connected.map(async client => {
const result = await client.callTool('tabs_context_mcp', args)
const socketPath = this.getSocketPathForClient(client)
return { result, socketPath }
connected.map(async (client) => {
const result = await client.callTool("tabs_context_mcp", args);
const socketPath = this.getSocketPathForClient(client);
return { result, socketPath };
}),
)
);
// Merge tab results
const mergedTabs: unknown[] = []
this.tabRoutes.clear()
const mergedTabs: unknown[] = [];
this.tabRoutes.clear();
for (const settledResult of results) {
if (settledResult.status !== 'fulfilled') {
if (settledResult.status !== "fulfilled") {
logger.info(
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
)
continue
);
continue;
}
const { result, socketPath } = settledResult.value
this.updateTabRoutes(result, socketPath)
const { result, socketPath } = settledResult.value;
this.updateTabRoutes(result, socketPath);
const tabs = this.extractTabs(result)
const tabs = this.extractTabs(result);
if (tabs) {
mergedTabs.push(...tabs)
mergedTabs.push(...tabs);
}
}
// Return merged result in the same format as the extension response
if (mergedTabs.length > 0) {
const tabListText = mergedTabs
.map(t => {
const tab = t as { tabId: number; title: string; url: string }
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`
.map((t) => {
const tab = t as { tabId: number; title: string; url: string };
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
})
.join('\n')
.join("\n");
return {
result: {
content: [
{
type: 'text',
type: "text",
text: JSON.stringify({ availableTabs: mergedTabs }),
},
{
type: 'text',
type: "text",
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
},
],
},
}
};
}
// Fallback: return first successful result as-is
for (const settledResult of results) {
if (settledResult.status === 'fulfilled') {
return settledResult.value.result
if (settledResult.status === "fulfilled") {
return settledResult.value.result;
}
}
throw new SocketConnectionError(
`[${serverName}] All sockets failed for tabs_context_mcp`,
)
);
}
/**
* Extract tab objects from a tool response to update routing table.
*/
private updateTabRoutes(result: unknown, socketPath: string): void {
const tabs = this.extractTabs(result)
if (!tabs) return
const tabs = this.extractTabs(result);
if (!tabs) return;
for (const tab of tabs) {
if (typeof tab === 'object' && tab !== null && 'tabId' in tab) {
const tabId = (tab as { tabId: number }).tabId
this.tabRoutes.set(tabId, socketPath)
if (typeof tab === "object" && tab !== null && "tabId" in tab) {
const tabId = (tab as { tabId: number }).tabId;
this.tabRoutes.set(tabId, socketPath);
}
}
}
private extractTabs(result: unknown): unknown[] | null {
if (!result || typeof result !== 'object') return null
if (!result || typeof result !== "object") return null;
// Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } }
const asResponse = result as {
result?: { content?: Array<{ type: string; text?: string }> }
}
const content = asResponse.result?.content
if (!content || !Array.isArray(content)) return null
result?: { content?: Array<{ type: string; text?: string }> };
};
const content = asResponse.result?.content;
if (!content || !Array.isArray(content)) return null;
for (const item of content) {
if (item.type === 'text' && item.text) {
if (item.type === "text" && item.text) {
try {
const parsed = JSON.parse(item.text)
if (Array.isArray(parsed)) return parsed
const parsed = JSON.parse(item.text);
if (Array.isArray(parsed)) return parsed;
// Handle { availableTabs: [...] } format
if (parsed && Array.isArray(parsed.availableTabs)) {
return parsed.availableTabs
return parsed.availableTabs;
}
} catch {
// Not JSON, skip
}
}
}
return null
return null;
}
private getSocketPathForClient(client: McpSocketClient): string {
for (const [path, c] of this.clients.entries()) {
if (c === client) return path
if (c === client) return path;
}
return ''
return "";
}
/**
* Scan for available sockets and create/remove clients as needed.
*/
private refreshClients(): void {
const socketPaths = this.getAvailableSocketPaths()
const { logger, serverName } = this.context
const socketPaths = this.getAvailableSocketPaths();
const { logger, serverName } = this.context;
// Add new clients for newly discovered sockets
for (const path of socketPaths) {
if (!this.clients.has(path)) {
logger.info(`[${serverName}] Adding socket to pool: ${path}`)
logger.info(`[${serverName}] Adding socket to pool: ${path}`);
const clientContext: ClaudeForChromeContext = {
...this.context,
socketPath: path,
getSocketPath: undefined,
getSocketPaths: undefined,
}
const client = createMcpSocketClient(clientContext)
client.disableAutoReconnect = true
};
const client = createMcpSocketClient(clientContext);
client.disableAutoReconnect = true;
if (this.notificationHandler) {
client.setNotificationHandler(this.notificationHandler)
client.setNotificationHandler(this.notificationHandler);
}
this.clients.set(path, client)
this.clients.set(path, client);
}
}
// Remove clients for sockets that no longer exist
for (const [path, client] of this.clients.entries()) {
if (!socketPaths.includes(path)) {
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`)
client.disconnect()
this.clients.delete(path)
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`);
client.disconnect();
this.clients.delete(path);
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
if (socketPath === path) {
this.tabRoutes.delete(tabId)
this.tabRoutes.delete(tabId);
}
}
}
@@ -317,12 +316,12 @@ export class McpSocketPool {
}
private getAvailableSocketPaths(): string[] {
return this.context.getSocketPaths?.() ?? []
return this.context.getSocketPaths?.() ?? [];
}
}
export function createMcpSocketPool(
context: ClaudeForChromeContext,
): McpSocketPool {
return new McpSocketPool(context)
return new McpSocketPool(context);
}

View File

@@ -1,12 +1,12 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { SocketConnectionError } from './mcpSocketClient.js'
import { SocketConnectionError } from "./mcpSocketClient.js";
import type {
ClaudeForChromeContext,
PermissionMode,
PermissionOverrides,
SocketClient,
} from './types.js'
} from "./types.js";
export const handleToolCall = async (
context: ClaudeForChromeContext,
@@ -16,21 +16,21 @@ export const handleToolCall = async (
permissionOverrides?: PermissionOverrides,
): Promise<CallToolResult> => {
// Handle permission mode changes locally (not forwarded to extension)
if (name === 'set_permission_mode') {
return handleSetPermissionMode(socketClient, args)
if (name === "set_permission_mode") {
return handleSetPermissionMode(socketClient, args);
}
// Handle switch_browser outside the normal tool call flow (manages its own connection)
if (name === 'switch_browser') {
return handleSwitchBrowser(context, socketClient)
if (name === "switch_browser") {
return handleSwitchBrowser(context, socketClient);
}
try {
const isConnected = await socketClient.ensureConnected()
const isConnected = await socketClient.ensureConnected();
context.logger.silly(
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
)
);
if (isConnected) {
return await handleToolCallConnected(
@@ -39,28 +39,28 @@ export const handleToolCall = async (
name,
args,
permissionOverrides,
)
);
}
return handleToolCallDisconnected(context)
return handleToolCallDisconnected(context);
} catch (error) {
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
if (error instanceof SocketConnectionError) {
return handleToolCallDisconnected(context)
return handleToolCallDisconnected(context);
}
return {
content: [
{
type: 'text',
type: "text",
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
};
}
}
};
async function handleToolCallConnected(
context: ClaudeForChromeContext,
@@ -69,119 +69,119 @@ async function handleToolCallConnected(
args: Record<string, unknown>,
permissionOverrides?: PermissionOverrides,
): Promise<CallToolResult> {
const response = await socketClient.callTool(name, args, permissionOverrides)
const response = await socketClient.callTool(name, args, permissionOverrides);
context.logger.silly(
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
)
);
if (response === null || response === undefined) {
return {
content: [{ type: 'text', text: 'Tool execution completed' }],
}
content: [{ type: "text", text: "Tool execution completed" }],
};
}
// Response will have either result or error field
const { result, error } = response as {
result?: { content: unknown[] | string }
error?: { content: unknown[] | string }
}
result?: { content: unknown[] | string };
error?: { content: unknown[] | string };
};
// Determine which field has the content and whether it's an error
const contentData = error || result
const isError = !!error
const contentData = error || result;
const isError = !!error;
if (!contentData) {
return {
content: [{ type: 'text', text: 'Tool execution completed' }],
}
content: [{ type: "text", text: "Tool execution completed" }],
};
}
if (isError && isAuthenticationError(contentData.content)) {
context.onAuthenticationError()
context.onAuthenticationError();
}
const { content } = contentData
const { content } = contentData;
if (content && Array.isArray(content)) {
if (isError) {
return {
content: content.map((item: unknown) => {
if (typeof item === 'object' && item !== null && 'type' in item) {
return item
if (typeof item === "object" && item !== null && "type" in item) {
return item;
}
return { type: 'text', text: String(item) }
return { type: "text", text: String(item) };
}),
isError: true,
} as CallToolResult
} as CallToolResult;
}
const convertedContent = content.map((item: unknown) => {
if (
typeof item === 'object' &&
typeof item === "object" &&
item !== null &&
'type' in item &&
'source' in item
"type" in item &&
"source" in item
) {
const typedItem = item
const typedItem = item;
if (
typedItem.type === 'image' &&
typeof typedItem.source === 'object' &&
typedItem.type === "image" &&
typeof typedItem.source === "object" &&
typedItem.source !== null &&
'data' in typedItem.source
"data" in typedItem.source
) {
return {
type: 'image',
type: "image",
data: typedItem.source.data,
mimeType:
'media_type' in typedItem.source
? typedItem.source.media_type || 'image/png'
: 'image/png',
}
"media_type" in typedItem.source
? typedItem.source.media_type || "image/png"
: "image/png",
};
}
}
if (typeof item === 'object' && item !== null && 'type' in item) {
return item
if (typeof item === "object" && item !== null && "type" in item) {
return item;
}
return { type: 'text', text: String(item) }
})
return { type: "text", text: String(item) };
});
return {
content: convertedContent,
isError,
} as CallToolResult
} as CallToolResult;
}
// Handle string content
if (typeof content === 'string') {
if (typeof content === "string") {
return {
content: [{ type: 'text', text: content }],
content: [{ type: "text", text: content }],
isError,
} as CallToolResult
} as CallToolResult;
}
// Fallback for unexpected result format
context.logger.warn(
`[${context.serverName}] Unexpected result format from socket bridge`,
response,
)
);
return {
content: [{ type: 'text', text: JSON.stringify(response) }],
content: [{ type: "text", text: JSON.stringify(response) }],
isError,
}
};
}
function handleToolCallDisconnected(
context: ClaudeForChromeContext,
): CallToolResult {
const text = context.onToolCallDisconnected()
const text = context.onToolCallDisconnected();
return {
content: [{ type: 'text', text }],
}
content: [{ type: "text", text }],
};
}
/**
@@ -194,28 +194,28 @@ async function handleSetPermissionMode(
): Promise<CallToolResult> {
// Validate permission mode at runtime
const validModes = [
'ask',
'skip_all_permission_checks',
'follow_a_plan',
] as const
const mode = args.mode as string | undefined
"ask",
"skip_all_permission_checks",
"follow_a_plan",
] as const;
const mode = args.mode as string | undefined;
const permissionMode: PermissionMode =
mode && validModes.includes(mode as PermissionMode)
? (mode as PermissionMode)
: 'ask'
: "ask";
if (socketClient.setPermissionMode) {
await socketClient.setPermissionMode(
permissionMode,
args.allowed_domains as string[] | undefined,
)
);
}
return {
content: [
{ type: 'text', text: `Permission mode set to: ${permissionMode}` },
{ type: "text", text: `Permission mode set to: ${permissionMode}` },
],
}
};
}
/**
@@ -230,50 +230,50 @@ async function handleSwitchBrowser(
return {
content: [
{
type: 'text',
text: 'Browser switching is only available with bridge connections.',
type: "text",
text: "Browser switching is only available with bridge connections.",
},
],
isError: true,
}
};
}
const isConnected = await socketClient.ensureConnected()
const isConnected = await socketClient.ensureConnected();
if (!isConnected) {
return handleToolCallDisconnected(context)
return handleToolCallDisconnected(context);
}
const result = (await socketClient.switchBrowser?.()) ?? null
const result = (await socketClient.switchBrowser?.()) ?? null;
if (result === 'no_other_browsers') {
if (result === "no_other_browsers") {
return {
content: [
{
type: 'text',
text: 'No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.',
type: "text",
text: "No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.",
},
],
isError: true,
}
};
}
if (result) {
return {
content: [
{ type: 'text', text: `Connected to browser "${result.name}".` },
{ type: "text", text: `Connected to browser "${result.name}".` },
],
}
};
}
return {
content: [
{
type: 'text',
text: 'No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.',
type: "text",
text: "No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.",
},
],
isError: true,
}
};
}
/**
@@ -282,20 +282,20 @@ async function handleSwitchBrowser(
function isAuthenticationError(content: unknown[] | string): boolean {
const errorText = Array.isArray(content)
? content
.map(item => {
if (typeof item === 'string') return item
.map((item) => {
if (typeof item === "string") return item;
if (
typeof item === 'object' &&
typeof item === "object" &&
item !== null &&
'text' in item &&
typeof item.text === 'string'
"text" in item &&
typeof item.text === "string"
) {
return item.text
return item.text;
}
return ''
return "";
})
.join(' ')
: String(content)
.join(" ")
: String(content);
return errorText.toLowerCase().includes('re-authenticated')
return errorText.toLowerCase().includes("re-authenticated");
}

View File

@@ -1,64 +1,64 @@
export interface Logger {
info: (message: string, ...args: unknown[]) => void
error: (message: string, ...args: unknown[]) => void
warn: (message: string, ...args: unknown[]) => void
debug: (message: string, ...args: unknown[]) => void
silly: (message: string, ...args: unknown[]) => void
info: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug: (message: string, ...args: unknown[]) => void;
silly: (message: string, ...args: unknown[]) => void;
}
export type PermissionMode =
| 'ask'
| 'skip_all_permission_checks'
| 'follow_a_plan'
| "ask"
| "skip_all_permission_checks"
| "follow_a_plan";
export interface BridgeConfig {
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
url: string
url: string;
/** Returns the user's account UUID for the connection path */
getUserId: () => Promise<string | undefined>
getUserId: () => Promise<string | undefined>;
/** Returns a valid OAuth token for bridge authentication */
getOAuthToken: () => Promise<string | undefined>
getOAuthToken: () => Promise<string | undefined>;
/** Optional dev user ID for local development (bypasses OAuth) */
devUserId?: string
devUserId?: string;
}
/** Metadata about a connected Chrome extension instance. */
export interface ChromeExtensionInfo {
deviceId: string
osPlatform?: string
connectedAt: number
name?: string
deviceId: string;
osPlatform?: string;
connectedAt: number;
name?: string;
}
export interface ClaudeForChromeContext {
serverName: string
logger: Logger
socketPath: string
serverName: string;
logger: Logger;
socketPath: string;
// Optional dynamic resolver for socket path. When provided, called on each
// connection attempt to handle runtime conditions (e.g., TMPDIR mismatch).
getSocketPath?: () => string
getSocketPath?: () => string;
// Optional resolver returning all available socket paths (for multi-profile support).
// When provided, a socket pool connects to all sockets and routes by tab ID.
getSocketPaths?: () => string[]
clientTypeId: string // "desktop" | "claude-code"
onToolCallDisconnected: () => string
onAuthenticationError: () => void
isDisabled?: () => boolean
getSocketPaths?: () => string[];
clientTypeId: string; // "desktop" | "claude-code"
onToolCallDisconnected: () => string;
onAuthenticationError: () => void;
isDisabled?: () => boolean;
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
bridgeConfig?: BridgeConfig
bridgeConfig?: BridgeConfig;
/** If set, permission mode is sent to the extension immediately on bridge connection. */
initialPermissionMode?: PermissionMode
initialPermissionMode?: PermissionMode;
/** Optional callback to track telemetry events for bridge connections */
trackEvent?: <K extends string>(
eventName: K,
metadata: Record<string, unknown> | null,
) => void
) => void;
/** Called when user pairs with an extension via the browser pairing flow. */
onExtensionPaired?: (deviceId: string, name: string) => void
onExtensionPaired?: (deviceId: string, name: string) => void;
/** Returns the previously paired deviceId, if any. */
getPersistedDeviceId?: () => string | undefined
getPersistedDeviceId?: () => string | undefined;
/** Called when a remote extension is auto-selected (only option available). */
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void;
}
/**
@@ -66,69 +66,69 @@ export interface ClaudeForChromeContext {
* via navigator.userAgentData.platform.
*/
export function localPlatformLabel(): string {
return process.platform === 'darwin'
? 'macOS'
: process.platform === 'win32'
? 'Windows'
: 'Linux'
return process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: "Linux";
}
/** Permission request forwarded from the extension to the desktop for user approval. */
export interface BridgePermissionRequest {
/** Links to the pending tool_call */
toolUseId: string
toolUseId: string;
/** Unique ID for this permission request */
requestId: string
requestId: string;
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
toolType: string
toolType: string;
/** The URL/domain context */
url: string
url: string;
/** Additional action data (click coordinates, text, etc.) */
actionData?: Record<string, unknown>
actionData?: Record<string, unknown>;
}
/** Desktop response to a bridge permission request. */
export interface BridgePermissionResponse {
requestId: string
allowed: boolean
requestId: string;
allowed: boolean;
}
/** Per-call permission overrides, allowing each session to use its own permission state. */
export interface PermissionOverrides {
permissionMode: PermissionMode
allowedDomains?: string[]
permissionMode: PermissionMode;
allowedDomains?: string[];
/** Callback invoked when the extension requests user permission via the bridge. */
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
}
/** Shared interface for McpSocketClient and McpSocketPool */
export interface SocketClient {
ensureConnected(): Promise<boolean>
ensureConnected(): Promise<boolean>;
callTool(
name: string,
args: Record<string, unknown>,
permissionOverrides?: PermissionOverrides,
): Promise<unknown>
isConnected(): boolean
disconnect(): void
): Promise<unknown>;
isConnected(): boolean;
disconnect(): void;
setNotificationHandler(
handler: (notification: {
method: string
params?: Record<string, unknown>
method: string;
params?: Record<string, unknown>;
}) => void,
): void
): void;
/** Set permission mode for the current session. Only effective on BridgeClient. */
setPermissionMode?(
mode: PermissionMode,
allowedDomains?: string[],
): Promise<void>
): Promise<void>;
/** Switch to a different browser. Only available on BridgeClient. */
switchBrowser?(): Promise<
| {
deviceId: string
name: string
deviceId: string;
name: string;
}
| 'no_other_browsers'
| "no_other_browsers"
| null
>
>;
}

View File

@@ -1,7 +1,7 @@
{
"name": "@ant/computer-use-input",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
"name": "@ant/computer-use-input",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
}

View File

@@ -12,46 +12,19 @@ import type { FrontmostAppInfo, InputBackend } from '../types.js'
const execFileAsync = promisify(execFile)
const KEY_MAP: Record<string, number> = {
return: 36,
enter: 36,
tab: 48,
space: 49,
delete: 51,
backspace: 51,
escape: 53,
esc: 53,
left: 123,
right: 124,
down: 125,
up: 126,
f1: 122,
f2: 120,
f3: 99,
f4: 118,
f5: 96,
f6: 97,
f7: 98,
f8: 100,
f9: 101,
f10: 109,
f11: 103,
f12: 111,
home: 115,
end: 119,
pageup: 116,
pagedown: 121,
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
escape: 53, esc: 53,
left: 123, right: 124, down: 125, up: 126,
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
home: 115, end: 119, pageup: 116, pagedown: 121,
}
const MODIFIER_MAP: Record<string, string> = {
command: 'command down',
cmd: 'command down',
meta: 'command down',
super: 'command down',
command: 'command down', cmd: 'command down', meta: 'command down', super: 'command down',
shift: 'shift down',
option: 'option down',
alt: 'option down',
control: 'control down',
ctrl: 'control down',
option: 'option down', alt: 'option down',
control: 'control down', ctrl: 'control down',
}
async function osascript(script: string): Promise<string> {
@@ -62,23 +35,13 @@ async function osascript(script: string): Promise<string> {
}
async function jxa(script: string): Promise<string> {
const { stdout } = await execFileAsync(
'osascript',
['-l', 'JavaScript', '-e', script],
{
encoding: 'utf-8',
},
)
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
encoding: 'utf-8',
})
return stdout.trim()
}
function buildMouseJxa(
eventType: string,
x: number,
y: number,
btn: number,
clickState?: number,
): string {
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
let script = `ObjC.import("CoreGraphics"); var p = $.CGPointMake(${x},${y}); var e = $.CGEventCreateMouseEvent(null, $.${eventType}, p, ${btn});`
if (clickState !== undefined) {
script += ` $.CGEventSetIntegerValueField(e, $.kCGMouseEventClickState, ${clickState});`
@@ -98,13 +61,11 @@ export const key: InputBackend['key'] = async (keyName, action) => {
if (keyCode !== undefined) {
await osascript(`tell application "System Events" to key code ${keyCode}`)
} else {
await osascript(
`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`,
)
await osascript(`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`)
}
}
export const keys: InputBackend['keys'] = async parts => {
export const keys: InputBackend['keys'] = async (parts) => {
const modifiers: string[] = []
let finalKey: string | null = null
for (const part of parts) {
@@ -117,43 +78,23 @@ export const keys: InputBackend['keys'] = async parts => {
const keyCode = KEY_MAP[lower]
const modStr = modifiers.length > 0 ? ` using {${modifiers.join(', ')}}` : ''
if (keyCode !== undefined) {
await osascript(
`tell application "System Events" to key code ${keyCode}${modStr}`,
)
await osascript(`tell application "System Events" to key code ${keyCode}${modStr}`)
} else {
await osascript(
`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`,
)
await osascript(`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`)
}
}
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
const result = await jxa(
'ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y',
)
const result = await jxa('ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y')
const [xStr, yStr] = result.split(',')
return { x: Math.round(Number(xStr)), y: Math.round(Number(yStr)) }
}
export const mouseButton: InputBackend['mouseButton'] = async (
button,
action,
count,
) => {
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
const pos = await mouseLocation()
const btn = button === 'left' ? 0 : button === 'right' ? 1 : 2
const downType =
btn === 0
? 'kCGEventLeftMouseDown'
: btn === 1
? 'kCGEventRightMouseDown'
: 'kCGEventOtherMouseDown'
const upType =
btn === 0
? 'kCGEventLeftMouseUp'
: btn === 1
? 'kCGEventRightMouseUp'
: 'kCGEventOtherMouseUp'
const downType = btn === 0 ? 'kCGEventLeftMouseDown' : btn === 1 ? 'kCGEventRightMouseDown' : 'kCGEventOtherMouseDown'
const upType = btn === 0 ? 'kCGEventLeftMouseUp' : btn === 1 ? 'kCGEventRightMouseUp' : 'kCGEventOtherMouseUp'
if (action === 'click') {
for (let i = 0; i < (count ?? 1); i++) {
@@ -167,39 +108,28 @@ export const mouseButton: InputBackend['mouseButton'] = async (
}
}
export const mouseScroll: InputBackend['mouseScroll'] = async (
amount,
direction,
) => {
const script =
direction === 'vertical'
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
const script = direction === 'vertical'
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
await jxa(script)
}
export const typeText: InputBackend['typeText'] = async text => {
export const typeText: InputBackend['typeText'] = async (text) => {
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
}
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
try {
const output = execFileSync(
'osascript',
[
'-e',
`
const output = execFileSync('osascript', ['-e', `
tell application "System Events"
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
set bundleId to bundle identifier of frontApp
return bundleId & "|" & appName
end tell
`,
],
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim()
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
if (!output || !output.includes('|')) return null
const [bundleId, appName] = output.split('|', 2)
return { bundleId: bundleId!, appName: appName! }

View File

@@ -32,75 +32,23 @@ async function runAsync(cmd: string[]): Promise<string> {
// ---------------------------------------------------------------------------
const KEY_MAP: Record<string, string> = {
return: 'Return',
enter: 'Return',
tab: 'Tab',
space: 'space',
backspace: 'BackSpace',
delete: 'Delete',
escape: 'Escape',
esc: 'Escape',
left: 'Left',
up: 'Up',
right: 'Right',
down: 'Down',
home: 'Home',
end: 'End',
pageup: 'Prior',
pagedown: 'Next',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12',
shift: 'shift',
lshift: 'shift',
rshift: 'shift',
control: 'ctrl',
ctrl: 'ctrl',
lcontrol: 'ctrl',
rcontrol: 'ctrl',
alt: 'alt',
option: 'alt',
lalt: 'alt',
ralt: 'alt',
win: 'super',
meta: 'super',
command: 'super',
cmd: 'super',
super: 'super',
insert: 'Insert',
printscreen: 'Print',
pause: 'Pause',
numlock: 'Num_Lock',
capslock: 'Caps_Lock',
scrolllock: 'Scroll_Lock',
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
backspace: 'BackSpace', delete: 'Delete', escape: 'Escape', esc: 'Escape',
left: 'Left', up: 'Up', right: 'Right', down: 'Down',
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
f1: 'F1', f2: 'F2', f3: 'F3', f4: 'F4', f5: 'F5', f6: 'F6',
f7: 'F7', f8: 'F8', f9: 'F9', f10: 'F10', f11: 'F11', f12: 'F12',
shift: 'shift', lshift: 'shift', rshift: 'shift',
control: 'ctrl', ctrl: 'ctrl', lcontrol: 'ctrl', rcontrol: 'ctrl',
alt: 'alt', option: 'alt', lalt: 'alt', ralt: 'alt',
win: 'super', meta: 'super', command: 'super', cmd: 'super', super: 'super',
insert: 'Insert', printscreen: 'Print', pause: 'Pause',
numlock: 'Num_Lock', capslock: 'Caps_Lock', scrolllock: 'Scroll_Lock',
}
const MODIFIER_KEYS = new Set([
'shift',
'lshift',
'rshift',
'control',
'ctrl',
'lcontrol',
'rcontrol',
'alt',
'option',
'lalt',
'ralt',
'win',
'meta',
'command',
'cmd',
'super',
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
])
function mapKey(name: string): string {
@@ -120,13 +68,7 @@ function mouseButtonNum(button: 'left' | 'right' | 'middle'): string {
// ---------------------------------------------------------------------------
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
run([
'xdotool',
'mousemove',
'--sync',
String(Math.round(x)),
String(Math.round(y)),
])
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
}
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
@@ -140,11 +82,7 @@ export const mouseLocation: InputBackend['mouseLocation'] = async () => {
}
}
export const mouseButton: InputBackend['mouseButton'] = async (
button,
action,
count,
) => {
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
const btn = mouseButtonNum(button)
if (action === 'click') {
const n = count ?? 1
@@ -156,10 +94,7 @@ export const mouseButton: InputBackend['mouseButton'] = async (
}
}
export const mouseScroll: InputBackend['mouseScroll'] = async (
amount,
direction,
) => {
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
// xdotool click 4=scroll up, 5=scroll down, 6=scroll left, 7=scroll right
// Positive amount = down/right, negative = up/left
if (direction === 'vertical') {
@@ -186,7 +121,7 @@ export const key: InputBackend['key'] = async (keyName, action) => {
}
}
export const keys: InputBackend['keys'] = async parts => {
export const keys: InputBackend['keys'] = async (parts) => {
// xdotool key accepts "modifier+modifier+key" format
const modifiers: string[] = []
let finalKey: string | null = null
@@ -204,7 +139,7 @@ export const keys: InputBackend['keys'] = async parts => {
run(['xdotool', 'key', combo])
}
export const typeText: InputBackend['typeText'] = async text => {
export const typeText: InputBackend['typeText'] = async (text) => {
run(['xdotool', 'type', '--delay', '12', text])
}
@@ -222,23 +157,16 @@ export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
let exePath = ''
try {
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
} catch {
/* ignore */
}
} catch { /* ignore */ }
// Read the process name from /proc/comm
let appName = ''
try {
appName = run(['cat', `/proc/${pid}/comm`])
} catch {
/* ignore */
}
} catch { /* ignore */ }
if (!exePath && !appName) return null
return {
bundleId: exePath || `/proc/${pid}/exe`,
appName: appName || 'unknown',
}
return { bundleId: exePath || `/proc/${pid}/exe`, appName: appName || 'unknown' }
} catch {
return null
}

View File

@@ -92,112 +92,43 @@ public class CuWin32 {
// ---------------------------------------------------------------------------
const VK_MAP: Record<string, number> = {
return: 0x0d,
enter: 0x0d,
tab: 0x09,
space: 0x20,
backspace: 0x08,
delete: 0x2e,
escape: 0x1b,
esc: 0x1b,
left: 0x25,
up: 0x26,
right: 0x27,
down: 0x28,
home: 0x24,
end: 0x23,
pageup: 0x21,
pagedown: 0x22,
f1: 0x70,
f2: 0x71,
f3: 0x72,
f4: 0x73,
f5: 0x74,
f6: 0x75,
f7: 0x76,
f8: 0x77,
f9: 0x78,
f10: 0x79,
f11: 0x7a,
f12: 0x7b,
shift: 0xa0,
lshift: 0xa0,
rshift: 0xa1,
control: 0xa2,
ctrl: 0xa2,
lcontrol: 0xa2,
rcontrol: 0xa3,
alt: 0xa4,
option: 0xa4,
lalt: 0xa4,
ralt: 0xa5,
win: 0x5b,
meta: 0x5b,
command: 0x5b,
cmd: 0x5b,
super: 0x5b,
insert: 0x2d,
printscreen: 0x2c,
pause: 0x13,
numlock: 0x90,
capslock: 0x14,
scrolllock: 0x91,
return: 0x0D, enter: 0x0D, tab: 0x09, space: 0x20,
backspace: 0x08, delete: 0x2E, escape: 0x1B, esc: 0x1B,
left: 0x25, up: 0x26, right: 0x27, down: 0x28,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7A, f12: 0x7B,
shift: 0xA0, lshift: 0xA0, rshift: 0xA1,
control: 0xA2, ctrl: 0xA2, lcontrol: 0xA2, rcontrol: 0xA3,
alt: 0xA4, option: 0xA4, lalt: 0xA4, ralt: 0xA5,
win: 0x5B, meta: 0x5B, command: 0x5B, cmd: 0x5B, super: 0x5B,
insert: 0x2D, printscreen: 0x2C, pause: 0x13,
numlock: 0x90, capslock: 0x14, scrolllock: 0x91,
}
const MODIFIER_KEYS = new Set([
'shift',
'lshift',
'rshift',
'control',
'ctrl',
'lcontrol',
'rcontrol',
'alt',
'option',
'lalt',
'ralt',
'win',
'meta',
'command',
'cmd',
'super',
])
const MODIFIER_KEYS = new Set(['shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol', 'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super'])
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
ps(
`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`,
)
ps(`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`)
}
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
const out = ps(
`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`,
)
const out = ps(`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`)
const [xStr, yStr] = out.split(',')
return { x: Number(xStr), y: Number(yStr) }
}
export const mouseButton: InputBackend['mouseButton'] = async (
button,
action,
count,
) => {
const downFlag =
button === 'left'
? 'MOUSEEVENTF_LEFTDOWN'
: button === 'right'
? 'MOUSEEVENTF_RIGHTDOWN'
: 'MOUSEEVENTF_MIDDLEDOWN'
const upFlag =
button === 'left'
? 'MOUSEEVENTF_LEFTUP'
: button === 'right'
? 'MOUSEEVENTF_RIGHTUP'
: 'MOUSEEVENTF_MIDDLEUP'
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
const downFlag = button === 'left' ? 'MOUSEEVENTF_LEFTDOWN'
: button === 'right' ? 'MOUSEEVENTF_RIGHTDOWN'
: 'MOUSEEVENTF_MIDDLEDOWN'
const upFlag = button === 'left' ? 'MOUSEEVENTF_LEFTUP'
: button === 'right' ? 'MOUSEEVENTF_RIGHTUP'
: 'MOUSEEVENTF_MIDDLEUP'
if (action === 'click') {
const n = count ?? 1
@@ -205,29 +136,17 @@ export const mouseButton: InputBackend['mouseButton'] = async (
for (let i = 0; i < n; i++) {
clicks += `$i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; `
}
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`,
)
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`)
} else if (action === 'press') {
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
)
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
} else {
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
)
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
}
}
export const mouseScroll: InputBackend['mouseScroll'] = async (
amount,
direction,
) => {
const flag =
direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
)
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
const flag = direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
}
export const key: InputBackend['key'] = async (keyName, action) => {
@@ -235,19 +154,15 @@ export const key: InputBackend['key'] = async (keyName, action) => {
const vk = VK_MAP[lower]
const flags = action === 'release' ? '2' : '0'
if (vk !== undefined) {
ps(
`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`,
)
ps(`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`)
} else if (keyName.length === 1) {
// Single character — use VkKeyScan to resolve
const charCode = keyName.charCodeAt(0)
ps(
`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`,
)
ps(`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`)
}
}
export const keys: InputBackend['keys'] = async parts => {
export const keys: InputBackend['keys'] = async (parts) => {
const modifiers: number[] = []
let finalKey: string | null = null
@@ -281,11 +196,9 @@ export const keys: InputBackend['keys'] = async parts => {
ps(script)
}
export const typeText: InputBackend['typeText'] = async text => {
export const typeText: InputBackend['typeText'] = async (text) => {
const escaped = text.replace(/'/g, "''")
ps(
`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`,
)
ps(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`)
}
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {

View File

@@ -15,15 +15,8 @@ export interface InputBackend {
key(key: string, action: 'press' | 'release'): Promise<void>
keys(parts: string[]): Promise<void>
mouseLocation(): Promise<{ x: number; y: number }>
mouseButton(
button: 'left' | 'right' | 'middle',
action: 'click' | 'press' | 'release',
count?: number,
): Promise<void>
mouseScroll(
amount: number,
direction: 'vertical' | 'horizontal',
): Promise<void>
mouseButton(button: 'left' | 'right' | 'middle', action: 'click' | 'press' | 'release', count?: number): Promise<void>
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
typeText(text: string): Promise<void>
getFrontmostAppInfo(): FrontmostAppInfo | null
}
@@ -67,7 +60,5 @@ export class ComputerUseInputAPI {
declare isSupported: true
}
interface ComputerUseInputUnsupported {
isSupported: false
}
interface ComputerUseInputUnsupported { isSupported: false }
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported

View File

@@ -1,5 +1,5 @@
export interface FrontmostAppInfo {
bundleId: string // macOS: bundle ID, Windows: exe path
bundleId: string // macOS: bundle ID, Windows: exe path
appName: string
}
@@ -13,10 +13,7 @@ export interface InputBackend {
action: 'click' | 'press' | 'release',
count?: number,
): Promise<void>
mouseScroll(
amount: number,
direction: 'vertical' | 'horizontal',
): Promise<void>
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
typeText(text: string): Promise<void>
getFrontmostAppInfo(): FrontmostAppInfo | null
}

View File

@@ -1,13 +1,13 @@
{
"name": "@ant/computer-use-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./sentinelApps": "./src/sentinelApps.ts",
"./types": "./src/types.ts"
}
"name": "@ant/computer-use-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./sentinelApps": "./src/sentinelApps.ts",
"./types": "./src/types.ts"
}
}

View File

@@ -31,7 +31,7 @@
* duplicated as a string literal below rather than imported.
*/
export type DeniedCategory = 'browser' | 'terminal' | 'trading'
export type DeniedCategory = "browser" | "terminal" | "trading";
/**
* Map a category to its hardcoded tier. Return-type is the string-literal
@@ -44,54 +44,54 @@ export type DeniedCategory = 'browser' | 'terminal' | 'trading'
*/
export function categoryToTier(
category: DeniedCategory | null,
): 'read' | 'click' | 'full' {
if (category === 'browser' || category === 'trading') return 'read'
if (category === 'terminal') return 'click'
return 'full'
): "read" | "click" | "full" {
if (category === "browser" || category === "trading") return "read";
if (category === "terminal") return "click";
return "full";
}
// ─── Bundle-ID deny sets (macOS) ─────────────────────────────────────────
const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Apple
'com.apple.Safari',
'com.apple.SafariTechnologyPreview',
"com.apple.Safari",
"com.apple.SafariTechnologyPreview",
// Google
'com.google.Chrome',
'com.google.Chrome.beta',
'com.google.Chrome.dev',
'com.google.Chrome.canary',
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.dev",
"com.google.Chrome.canary",
// Microsoft
'com.microsoft.edgemac',
'com.microsoft.edgemac.Beta',
'com.microsoft.edgemac.Dev',
'com.microsoft.edgemac.Canary',
"com.microsoft.edgemac",
"com.microsoft.edgemac.Beta",
"com.microsoft.edgemac.Dev",
"com.microsoft.edgemac.Canary",
// Mozilla
'org.mozilla.firefox',
'org.mozilla.firefoxdeveloperedition',
'org.mozilla.nightly',
"org.mozilla.firefox",
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.nightly",
// Chromium-based
'org.chromium.Chromium',
'com.brave.Browser',
'com.brave.Browser.beta',
'com.brave.Browser.nightly',
'com.operasoftware.Opera',
'com.operasoftware.OperaGX',
'com.operasoftware.OperaDeveloper',
'com.vivaldi.Vivaldi',
"org.chromium.Chromium",
"com.brave.Browser",
"com.brave.Browser.beta",
"com.brave.Browser.nightly",
"com.operasoftware.Opera",
"com.operasoftware.OperaGX",
"com.operasoftware.OperaDeveloper",
"com.vivaldi.Vivaldi",
// The Browser Company
'company.thebrowser.Browser', // Arc
'company.thebrowser.dia', // Dia (agentic)
"company.thebrowser.Browser", // Arc
"company.thebrowser.dia", // Dia (agentic)
// Privacy-focused
'org.torproject.torbrowser',
'com.duckduckgo.macos.browser',
'ru.yandex.desktop.yandex-browser',
"org.torproject.torbrowser",
"com.duckduckgo.macos.browser",
"ru.yandex.desktop.yandex-browser",
// Agentic / AI browsers — newer entrants with LLM integrations
'ai.perplexity.comet',
'com.sigmaos.sigmaos.macos', // SigmaOS
"ai.perplexity.comet",
"com.sigmaos.sigmaos.macos", // SigmaOS
// Webkit-based misc
'com.kagi.kagimacOS', // Orion
])
"com.kagi.kagimacOS", // Orion
]);
/**
* Terminals + IDEs with integrated terminals. Supersets
@@ -101,66 +101,66 @@ const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
*/
const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Dedicated terminals
'com.apple.Terminal',
'com.googlecode.iterm2',
'dev.warp.Warp-Stable',
'dev.warp.Warp-Beta',
'com.github.wez.wezterm',
'org.alacritty',
'io.alacritty', // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
'net.kovidgoyal.kitty',
'co.zeit.hyper',
'com.mitchellh.ghostty',
'org.tabby',
'com.termius-dmg.mac', // Termius
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable",
"dev.warp.Warp-Beta",
"com.github.wez.wezterm",
"org.alacritty",
"io.alacritty", // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
"net.kovidgoyal.kitty",
"co.zeit.hyper",
"com.mitchellh.ghostty",
"org.tabby",
"com.termius-dmg.mac", // Termius
// IDEs with integrated terminals — we can't distinguish "type in the
// editor" from "type in the integrated terminal" via screenshot+click.
// VS Code family
'com.microsoft.VSCode',
'com.microsoft.VSCodeInsiders',
'com.vscodium', // VSCodium
'com.todesktop.230313mzl4w4u92', // Cursor
'com.exafunction.windsurf', // Windsurf / Codeium
'dev.zed.Zed',
'dev.zed.Zed-Preview',
"com.microsoft.VSCode",
"com.microsoft.VSCodeInsiders",
"com.vscodium", // VSCodium
"com.todesktop.230313mzl4w4u92", // Cursor
"com.exafunction.windsurf", // Windsurf / Codeium
"dev.zed.Zed",
"dev.zed.Zed-Preview",
// JetBrains family (all have integrated terminals)
'com.jetbrains.intellij',
'com.jetbrains.intellij.ce',
'com.jetbrains.pycharm',
'com.jetbrains.pycharm.ce',
'com.jetbrains.WebStorm',
'com.jetbrains.CLion',
'com.jetbrains.goland',
'com.jetbrains.rubymine',
'com.jetbrains.PhpStorm',
'com.jetbrains.datagrip',
'com.jetbrains.rider',
'com.jetbrains.AppCode',
'com.jetbrains.rustrover',
'com.jetbrains.fleet',
'com.google.android.studio', // Android Studio (JetBrains-based)
"com.jetbrains.intellij",
"com.jetbrains.intellij.ce",
"com.jetbrains.pycharm",
"com.jetbrains.pycharm.ce",
"com.jetbrains.WebStorm",
"com.jetbrains.CLion",
"com.jetbrains.goland",
"com.jetbrains.rubymine",
"com.jetbrains.PhpStorm",
"com.jetbrains.datagrip",
"com.jetbrains.rider",
"com.jetbrains.AppCode",
"com.jetbrains.rustrover",
"com.jetbrains.fleet",
"com.google.android.studio", // Android Studio (JetBrains-based)
// Other IDEs
'com.axosoft.gitkraken', // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
'com.sublimetext.4',
'com.sublimetext.3',
'org.vim.MacVim',
'com.neovim.neovim',
'org.gnu.Emacs',
"com.axosoft.gitkraken", // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
"com.sublimetext.4",
"com.sublimetext.3",
"org.vim.MacVim",
"com.neovim.neovim",
"org.gnu.Emacs",
// Xcode's previous carve-out (full tier for Interface Builder / simulator)
// was reversed — at tier "click" IB and simulator taps still work (both are
// plain clicks) while the integrated terminal is blocked from keyboard input.
'com.apple.dt.Xcode',
'org.eclipse.platform.ide',
'org.netbeans.ide',
'com.microsoft.visual-studio', // Visual Studio for Mac
"com.apple.dt.Xcode",
"org.eclipse.platform.ide",
"org.netbeans.ide",
"com.microsoft.visual-studio", // Visual Studio for Mac
// AppleScript/automation execution surfaces — same threat as terminals:
// type(script) → key("cmd+r") runs arbitrary code. Added after #28011
// removed the osascript MCP server, making CU the only tool-call route
// to AppleScript.
'com.apple.ScriptEditor2',
'com.apple.Automator',
'com.apple.shortcuts',
])
"com.apple.ScriptEditor2",
"com.apple.Automator",
"com.apple.shortcuts",
]);
/**
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
@@ -178,29 +178,29 @@ const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Verified via Homebrew quit/zap stanzas + mdls + electron-builder source.
// Trading
'com.webull.desktop.v1', // Webull (direct download, Qt)
'com.webull.trade.mac.v1', // Webull (Mac App Store)
'com.tastytrade.desktop',
'com.tradingview.tradingviewapp.desktop',
'com.fidelity.activetrader', // Fidelity Trader+ (new)
'com.fmr.activetrader', // Fidelity Active Trader Pro (legacy)
"com.webull.desktop.v1", // Webull (direct download, Qt)
"com.webull.trade.mac.v1", // Webull (Mac App Store)
"com.tastytrade.desktop",
"com.tradingview.tradingviewapp.desktop",
"com.fidelity.activetrader", // Fidelity Trader+ (new)
"com.fmr.activetrader", // Fidelity Active Trader Pro (legacy)
// Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is
// authoritative for this exact value but install4j IDs can drift across
// major versions — name-substring "trader workstation" is the fallback.
'com.install4j.5889-6375-8446-2021',
"com.install4j.5889-6375-8446-2021",
// Crypto
'com.binance.BinanceDesktop',
'com.electron.exodus',
"com.binance.BinanceDesktop",
"com.electron.exodus",
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
// org.pythonmac.unspecified.<AppName>. Confirmed in spesmilo/electrum
// source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork.
'org.pythonmac.unspecified.Electrum',
'com.ledger.live',
'io.trezor.TrezorSuite',
"org.pythonmac.unspecified.Electrum",
"com.ledger.live",
"io.trezor.TrezorSuite",
// No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation,
// Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim
// install4j ID drifts per-install — substring safer.
])
]);
// ─── Policy-deny (not a tier — cannot be granted at all) ─────────────────
//
@@ -215,78 +215,78 @@ const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
const POLICY_DENIED_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew.
// Apple built-ins
'com.apple.TV',
'com.apple.Music',
'com.apple.iBooksX',
'com.apple.podcasts',
"com.apple.TV",
"com.apple.Music",
"com.apple.iBooksX",
"com.apple.podcasts",
// Music
'com.spotify.client',
'com.amazon.music',
'com.tidal.desktop',
'com.deezer.deezer-desktop',
'com.pandora.desktop',
'com.electron.pocket-casts', // direct-download Electron wrapper
'au.com.shiftyjelly.PocketCasts', // Mac App Store
"com.spotify.client",
"com.amazon.music",
"com.tidal.desktop",
"com.deezer.deezer-desktop",
"com.pandora.desktop",
"com.electron.pocket-casts", // direct-download Electron wrapper
"au.com.shiftyjelly.PocketCasts", // Mac App Store
// Video
'tv.plex.desktop',
'tv.plex.htpc',
'tv.plex.plexamp',
'com.amazon.aiv.AIVApp', // Prime Video (iOS-on-Apple-Silicon)
"tv.plex.desktop",
"tv.plex.htpc",
"tv.plex.plexamp",
"com.amazon.aiv.AIVApp", // Prime Video (iOS-on-Apple-Silicon)
// Ebooks
'net.kovidgoyal.calibre',
'com.amazon.Kindle', // legacy desktop, discontinued
'com.amazon.Lassen', // current Mac App Store (iOS-on-Mac)
'com.kobo.desktop.Kobo',
"net.kovidgoyal.calibre",
"com.amazon.Kindle", // legacy desktop, discontinued
"com.amazon.Lassen", // current Mac App Store (iOS-on-Mac)
"com.kobo.desktop.Kobo",
// No native macOS app (name-substring only): Netflix, Disney+, Hulu,
// HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu,
// Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac.
])
]);
const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [
// Video streaming
'netflix',
'disney+',
'hulu',
'prime video',
'apple tv',
'peacock',
'paramount+',
"netflix",
"disney+",
"hulu",
"prime video",
"apple tv",
"peacock",
"paramount+",
// "plex" is too generic — would match "Perplexity". Covered by
// tv.plex.* bundle IDs on macOS.
'tubi',
'crunchyroll',
'vudu',
"tubi",
"crunchyroll",
"vudu",
// E-readers / audiobooks
'kindle',
'apple books',
'kobo',
'play books',
'calibre',
'libby',
'readium',
'audible',
'libro.fm',
'speechify',
"kindle",
"apple books",
"kobo",
"play books",
"calibre",
"libby",
"readium",
"audible",
"libro.fm",
"speechify",
// Music
'spotify',
'apple music',
'amazon music',
'youtube music',
'tidal',
'deezer',
'pandora',
'pocket casts',
"spotify",
"apple music",
"amazon music",
"youtube music",
"tidal",
"deezer",
"pandora",
"pocket casts",
// Publisher / social apps (from the same blocklist tab)
'naver',
'reddit',
'sony music',
'vegas pro',
'pitchfork',
'economist',
'nytimes',
"naver",
"reddit",
"sony music",
"vegas pro",
"pitchfork",
"economist",
"nytimes",
// Skipped (too generic for substring matching — need bundle ID):
// HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired
]
];
/**
* Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings
@@ -298,19 +298,19 @@ export function isPolicyDenied(
bundleId: string | undefined,
displayName: string,
): boolean {
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true
const lower = displayName.toLowerCase()
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true;
const lower = displayName.toLowerCase();
for (const sub of POLICY_DENIED_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return true
if (lower.includes(sub)) return true;
}
return false
return false;
}
export function getDeniedCategory(bundleId: string): DeniedCategory | null {
if (BROWSER_BUNDLE_IDS.has(bundleId)) return 'browser'
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return 'terminal'
if (TRADING_BUNDLE_IDS.has(bundleId)) return 'trading'
return null
if (BROWSER_BUNDLE_IDS.has(bundleId)) return "browser";
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return "terminal";
if (TRADING_BUNDLE_IDS.has(bundleId)) return "trading";
return null;
}
// ─── Display-name fallback (cross-platform) ──────────────────────────────
@@ -325,160 +325,160 @@ export function getDeniedCategory(bundleId: string): DeniedCategory | null {
* first match, but groupings are by category for readability).
*/
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
'safari',
'chrome',
'firefox',
'microsoft edge',
'brave',
'opera',
'vivaldi',
'chromium',
"safari",
"chrome",
"firefox",
"microsoft edge",
"brave",
"opera",
"vivaldi",
"chromium",
// Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for
// substring matching (false-positives: "Arcade", "Diagram"). Covered by
// bundle ID on macOS. The "... browser" entries below catch natural-language
// phrasings ("the arc browser") but NOT the canonical short name.
'arc browser',
'tor browser',
'duckduckgo',
'yandex',
'orion browser',
"arc browser",
"tor browser",
"duckduckgo",
"yandex",
"orion browser",
// Agentic / AI browsers
'comet', // Perplexity's browser — "Comet" substring risks false positives
"comet", // Perplexity's browser — "Comet" substring risks false positives
// but leaving for now; "comet" in an app name is rare
'sigmaos',
'dia browser',
]
"sigmaos",
"dia browser",
];
const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [
// macOS / cross-platform terminals
'terminal', // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
'iterm',
'wezterm',
'alacritty',
'kitty',
'ghostty',
'tabby',
'termius',
"terminal", // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
"iterm",
"wezterm",
"alacritty",
"kitty",
"ghostty",
"tabby",
"termius",
// AppleScript runners — see bundle-ID comment above. "shortcuts" is too
// generic for substring matching (many apps have "shortcuts" in the name);
// covered by bundle ID only, like warp/hyper.
'script editor',
'automator',
"script editor",
"automator",
// NOTE: "warp" and "hyper" are too generic for substring matching —
// they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID
// (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name
// matching can be added when Windows CU ships.
// Windows shells (activate when the darwin gate lifts)
'powershell',
'cmd.exe',
'command prompt',
'git bash',
'conemu',
'cmder',
"powershell",
"cmd.exe",
"command prompt",
"git bash",
"conemu",
"cmder",
// IDEs (VS Code family)
'visual studio code',
'visual studio', // catches VS for Mac + Windows
'vscode',
'vs code',
'vscodium',
'cursor', // Cursor IDE — "cursor" is generic but IDE is the only common app
'windsurf',
"visual studio code",
"visual studio", // catches VS for Mac + Windows
"vscode",
"vs code",
"vscodium",
"cursor", // Cursor IDE — "cursor" is generic but IDE is the only common app
"windsurf",
// Zed: display name is just "Zed" — too short for substring matching
// (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS.
// IDEs (JetBrains family)
'intellij',
'pycharm',
'webstorm',
'clion',
'goland',
'rubymine',
'phpstorm',
'datagrip',
'rider',
'appcode',
'rustrover',
'fleet',
'android studio',
"intellij",
"pycharm",
"webstorm",
"clion",
"goland",
"rubymine",
"phpstorm",
"datagrip",
"rider",
"appcode",
"rustrover",
"fleet",
"android studio",
// Other IDEs
'sublime text',
'macvim',
'neovim',
'emacs',
'xcode',
'eclipse',
'netbeans',
]
"sublime text",
"macvim",
"neovim",
"emacs",
"xcode",
"eclipse",
"netbeans",
];
const TRADING_NAME_SUBSTRINGS: readonly string[] = [
// Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx
// ("Read Only" tab). Name-substring safe for proper nouns below; generic
// names (IG, Delta, HTX) are skipped and need bundle-ID matching once
// verified.
'bloomberg',
'ameritrade',
'thinkorswim',
'schwab',
'fidelity',
'e*trade',
'interactive brokers',
'trader workstation', // Interactive Brokers TWS
'tradestation',
'webull',
'robinhood',
'tastytrade',
'ninjatrader',
'tradingview',
'moomoo',
'tradezero',
'prorealtime',
'plus500',
'saxotrader',
'oanda',
'metatrader',
'forex.com',
'avaoptions',
'ctrader',
'jforex',
'iq option',
'olymp trade',
'binomo',
'pocket option',
'raceoption',
'expertoption',
'quotex',
'naga',
'morgan stanley',
'ubs neo',
'eikon', // Thomson Reuters / LSEG Workspace
"bloomberg",
"ameritrade",
"thinkorswim",
"schwab",
"fidelity",
"e*trade",
"interactive brokers",
"trader workstation", // Interactive Brokers TWS
"tradestation",
"webull",
"robinhood",
"tastytrade",
"ninjatrader",
"tradingview",
"moomoo",
"tradezero",
"prorealtime",
"plus500",
"saxotrader",
"oanda",
"metatrader",
"forex.com",
"avaoptions",
"ctrader",
"jforex",
"iq option",
"olymp trade",
"binomo",
"pocket option",
"raceoption",
"expertoption",
"quotex",
"naga",
"morgan stanley",
"ubs neo",
"eikon", // Thomson Reuters / LSEG Workspace
// Crypto — exchanges, wallets, portfolio trackers
'coinbase',
'kraken',
'binance',
'okx',
'bybit',
"coinbase",
"kraken",
"binance",
"okx",
"bybit",
// "gate.io" is too generic — the ".io" TLD suffix is common in app names
// (e.g., "Draw.io"). Needs bundle-ID matching once verified.
'phemex',
'stormgain',
'crypto.com',
"phemex",
"stormgain",
"crypto.com",
// "exodus" is too generic — it's a common noun and would match unrelated
// apps/games. Needs bundle-ID matching once verified.
'electrum',
'ledger live',
'trezor',
'guarda',
'atomic wallet',
'bitpay',
'bisq',
'koinly',
'cointracker',
'blockfi',
'stripe cli',
"electrum",
"ledger live",
"trezor",
"guarda",
"atomic wallet",
"bitpay",
"bisq",
"koinly",
"cointracker",
"blockfi",
"stripe cli",
// Crypto games / metaverse (same trade-execution risk model)
'decentraland',
'axie infinity',
'gods unchained',
]
"decentraland",
"axie infinity",
"gods unchained",
];
/**
* Display-name substring match. Called when bundle-ID resolution returned
@@ -491,20 +491,20 @@ const TRADING_NAME_SUBSTRINGS: readonly string[] = [
export function getDeniedCategoryByDisplayName(
name: string,
): DeniedCategory | null {
const lower = name.toLowerCase()
const lower = name.toLowerCase();
// Trading first — proper-noun-only set, most specific. "Bloomberg Terminal"
// contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS
// ran first.
for (const sub of TRADING_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return 'trading'
if (lower.includes(sub)) return "trading";
}
for (const sub of BROWSER_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return 'browser'
if (lower.includes(sub)) return "browser";
}
for (const sub of TERMINAL_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return 'terminal'
if (lower.includes(sub)) return "terminal";
}
return null
return null;
}
/**
@@ -520,10 +520,10 @@ export function getDeniedCategoryForApp(
displayName: string,
): DeniedCategory | null {
if (bundleId) {
const byId = getDeniedCategory(bundleId)
if (byId) return byId
const byId = getDeniedCategory(bundleId);
if (byId) return byId;
}
return getDeniedCategoryByDisplayName(displayName)
return getDeniedCategoryByDisplayName(displayName);
}
/**
@@ -537,8 +537,8 @@ export function getDeniedCategoryForApp(
export function getDefaultTierForApp(
bundleId: string | undefined,
displayName: string,
): 'read' | 'click' | 'full' {
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName))
): "read" | "click" | "full" {
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
}
export const _test = {
@@ -550,4 +550,4 @@ export const _test = {
TERMINAL_NAME_SUBSTRINGS,
TRADING_NAME_SUBSTRINGS,
POLICY_DENIED_NAME_SUBSTRINGS,
}
};

View File

@@ -116,17 +116,9 @@ export interface ComputerExecutor {
// ── Window management (Windows only, optional) ──────────────────────────
/** Perform a window management action on the bound window. Win32 API only — no global shortcuts. */
manageWindow?(
action: string,
opts?: { x?: number; y?: number; width?: number; height?: number },
): Promise<boolean>
manageWindow?(action: string, opts?: { x?: number; y?: number; width?: number; height?: number }): Promise<boolean>
/** Get the current window rect of the bound window */
getWindowRect?(): Promise<{
x: number
y: number
width: number
height: number
} | null>
getWindowRect?(): Promise<{ x: number; y: number; width: number; height: number } | null>
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
/** Open terminal and launch an agent CLI */
@@ -137,32 +129,17 @@ export interface ComputerExecutor {
workingDirectory?: string
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
/** Bind to a window by hwnd/title/pid. Returns bound window info or null. */
bindToWindow?(query: {
hwnd?: string
title?: string
pid?: number
}): Promise<{ hwnd: string; title: string; pid: number } | null>
bindToWindow?(query: { hwnd?: string; title?: string; pid?: number }): Promise<{ hwnd: string; title: string; pid: number } | null>
/** Unbind from the current window */
unbindFromWindow?(): Promise<void>
/** Cheap binding-state check for window-targeted routing decisions. */
hasBoundWindow?(): Promise<boolean>
/** Get current binding status */
getBindingStatus?(): Promise<{
bound: boolean
hwnd?: string
title?: string
pid?: number
rect?: { x: number; y: number; width: number; height: number }
} | null>
getBindingStatus?(): Promise<{ bound: boolean; hwnd?: string; title?: string; pid?: number; rect?: { x: number; y: number; width: number; height: number } } | null>
/** List all visible windows */
listVisibleWindows?(): Promise<
Array<{ hwnd: string; pid: number; title: string }>
>
listVisibleWindows?(): Promise<Array<{ hwnd: string; pid: number; title: string }>>
/** Control the status indicator overlay */
statusIndicator?(
action: 'show' | 'hide' | 'status',
message?: string,
): Promise<{ active: boolean; message?: string }>
statusIndicator?(action: 'show' | 'hide' | 'status', message?: string): Promise<{ active: boolean; message?: string }>
/** Virtual keyboard — send keys/text/combos to bound window only */
virtualKeyboard?(opts: {
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
@@ -172,26 +149,12 @@ export interface ComputerExecutor {
}): Promise<boolean>
/** Virtual mouse — click/move/drag on bound window only */
virtualMouse?(opts: {
action:
| 'click'
| 'double_click'
| 'right_click'
| 'move'
| 'drag'
| 'down'
| 'up'
x: number
y: number
startX?: number
startY?: number
action: 'click' | 'double_click' | 'right_click' | 'move' | 'drag' | 'down' | 'up'
x: number; y: number
startX?: number; startY?: number
}): Promise<boolean>
/** Mouse wheel scroll at client coordinates (works on Excel, browsers, modern UI) */
mouseWheel?(
x: number,
y: number,
delta: number,
horizontal?: boolean,
): Promise<boolean>
mouseWheel?(x: number, y: number, delta: number, horizontal?: boolean): Promise<boolean>
/** Activate the bound window (foreground + click to focus) */
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
/** Handle a terminal prompt (yes/no/select/type + enter) */
@@ -202,14 +165,7 @@ export interface ComputerExecutor {
text?: string
}): Promise<boolean>
/** Click an element by name/role/automationId via UI Automation */
clickElement?(query: {
name?: string
role?: string
automationId?: string
}): Promise<boolean>
clickElement?(query: { name?: string; role?: string; automationId?: string }): Promise<boolean>
/** Type text into an element by name/role/automationId via UI Automation ValuePattern */
typeIntoElement?(
query: { name?: string; role?: string; automationId?: string },
text: string,
): Promise<boolean>
typeIntoElement?(query: { name?: string; role?: string; automationId?: string }, text: string): Promise<boolean>
}

View File

@@ -13,9 +13,9 @@
*/
export interface ResizeParams {
pxPerToken: number
maxTargetPx: number
maxTargetTokens: number
pxPerToken: number;
maxTargetPx: number;
maxTargetTokens: number;
}
/**
@@ -27,11 +27,11 @@ export const API_RESIZE_PARAMS: ResizeParams = {
pxPerToken: 28,
maxTargetPx: 1568,
maxTargetTokens: 1568,
}
};
/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */
export function nTokensForPx(px: number, pxPerToken: number): number {
return Math.floor((px - 1) / pxPerToken) + 1
return Math.floor((px - 1) / pxPerToken) + 1;
}
function nTokensForImg(
@@ -39,7 +39,7 @@ function nTokensForImg(
height: number,
pxPerToken: number,
): number {
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken)
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken);
}
/**
@@ -62,47 +62,47 @@ export function targetImageSize(
height: number,
params: ResizeParams,
): [number, number] {
const { pxPerToken, maxTargetPx, maxTargetTokens } = params
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
if (
width <= maxTargetPx &&
height <= maxTargetPx &&
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
) {
return [width, height]
return [width, height];
}
// Normalize to landscape for the search; transpose result back.
if (height > width) {
const [w, h] = targetImageSize(height, width, params)
return [h, w]
const [w, h] = targetImageSize(height, width, params);
return [h, w];
}
const aspectRatio = width / height
const aspectRatio = width / height;
// Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is
// always invalid. ~12 iterations for a 4000px image.
let upperBoundWidth = width
let lowerBoundWidth = 1
let upperBoundWidth = width;
let lowerBoundWidth = 1;
for (;;) {
if (lowerBoundWidth + 1 === upperBoundWidth) {
return [
lowerBoundWidth,
Math.max(Math.round(lowerBoundWidth / aspectRatio), 1),
]
];
}
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2)
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1)
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2);
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1);
if (
middleWidth <= maxTargetPx &&
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
) {
lowerBoundWidth = middleWidth
lowerBoundWidth = middleWidth;
} else {
upperBoundWidth = middleWidth
upperBoundWidth = middleWidth;
}
}
}

View File

@@ -6,7 +6,7 @@ export type {
ResolvePrepareCaptureResult,
RunningApp,
ScreenshotResult,
} from './executor.js'
} from "./executor.js";
export type {
AppGrant,
@@ -25,15 +25,15 @@ export type {
ScreenshotDims,
TeachStepRequest,
TeachStepResult,
} from './types.js'
} from "./types.js";
export { DEFAULT_GRANT_FLAGS } from './types.js'
export { DEFAULT_GRANT_FLAGS } from "./types.js";
export {
SENTINEL_BUNDLE_IDS,
getSentinelCategory,
} from './sentinelApps.js'
export type { SentinelCategory } from './sentinelApps.js'
} from "./sentinelApps.js";
export type { SentinelCategory } from "./sentinelApps.js";
export {
categoryToTier,
@@ -42,28 +42,28 @@ export {
getDeniedCategoryByDisplayName,
getDeniedCategoryForApp,
isPolicyDenied,
} from './deniedApps.js'
export type { DeniedCategory } from './deniedApps.js'
} from "./deniedApps.js";
export type { DeniedCategory } from "./deniedApps.js";
export { isSystemKeyCombo, normalizeKeySequence } from './keyBlocklist.js'
export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js";
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from './subGates.js'
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.js";
export { API_RESIZE_PARAMS, targetImageSize } from './imageResize.js'
export type { ResizeParams } from './imageResize.js'
export { API_RESIZE_PARAMS, targetImageSize } from "./imageResize.js";
export type { ResizeParams } from "./imageResize.js";
export { defersLockAcquire, handleToolCall } from './toolCalls.js'
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
export type {
CuCallTelemetry,
CuCallToolResult,
CuErrorKind,
} from './toolCalls.js'
} from "./toolCalls.js";
export { bindSessionContext, createComputerUseMcpServer } from './mcpServer.js'
export { buildComputerUseTools } from './tools.js'
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
export { buildComputerUseTools } from "./tools.js";
export {
comparePixelAtLocation,
validateClickTarget,
} from './pixelCompare.js'
export type { CropRawPatchFn, PixelCompareResult } from './pixelCompare.js'
} from "./pixelCompare.js";
export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js";

View File

@@ -21,32 +21,32 @@
*/
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
meta: 'meta',
super: 'meta',
command: 'meta',
cmd: 'meta',
windows: 'meta',
win: 'meta',
meta: "meta",
super: "meta",
command: "meta",
cmd: "meta",
windows: "meta",
win: "meta",
// Key::Control + LControl + RControl
ctrl: 'ctrl',
control: 'ctrl',
lctrl: 'ctrl',
lcontrol: 'ctrl',
rctrl: 'ctrl',
rcontrol: 'ctrl',
ctrl: "ctrl",
control: "ctrl",
lctrl: "ctrl",
lcontrol: "ctrl",
rctrl: "ctrl",
rcontrol: "ctrl",
// Key::Shift + LShift + RShift
shift: 'shift',
lshift: 'shift',
rshift: 'shift',
shift: "shift",
lshift: "shift",
rshift: "shift",
// Key::Alt and Key::Option — distinct Rust variants but same keycode on
// darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
// both Force Quit.
alt: 'alt',
option: 'alt',
}
alt: "alt",
option: "alt",
};
/** Sort order for canonicals. ctrl < alt < shift < meta. */
const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta']
const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
/**
* Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
@@ -54,21 +54,21 @@ const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta']
* The self-consistency test enforces this.
*/
const BLOCKED_DARWIN = new Set([
'meta+q', // Cmd+Q — quit frontmost app
'shift+meta+q', // Cmd+Shift+Q — log out
'alt+meta+escape', // Cmd+Option+Esc — Force Quit dialog
'meta+tab', // Cmd+Tab — app switcher
'meta+space', // Cmd+Space — Spotlight
'ctrl+meta+q', // Ctrl+Cmd+Q — lock screen
])
"meta+q", // Cmd+Q — quit frontmost app
"shift+meta+q", // Cmd+Shift+Q — log out
"alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog
"meta+tab", // Cmd+Tab — app switcher
"meta+space", // Cmd+Space — Spotlight
"ctrl+meta+q", // Ctrl+Cmd+Q — lock screen
]);
const BLOCKED_WIN32 = new Set([
'ctrl+alt+delete', // Secure Attention Sequence
'alt+f4', // close window
'alt+tab', // window switcher
'meta+l', // Win+L — lock
'meta+d', // Win+D — show desktop
])
"ctrl+alt+delete", // Secure Attention Sequence
"alt+f4", // close window
"alt+tab", // window switcher
"meta+l", // Win+L — lock
"meta+d", // Win+D — show desktop
]);
/**
* Partition into sorted-canonical modifiers and non-modifier keys.
@@ -78,25 +78,25 @@ const BLOCKED_WIN32 = new Set([
function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
const parts = seq
.toLowerCase()
.split('+')
.map(p => p.trim())
.filter(Boolean)
const mods: string[] = []
const keys: string[] = []
.split("+")
.map((p) => p.trim())
.filter(Boolean);
const mods: string[] = [];
const keys: string[] = [];
for (const p of parts) {
const canonical = CANONICAL_MODIFIER[p]
const canonical = CANONICAL_MODIFIER[p];
if (canonical !== undefined) {
mods.push(canonical)
mods.push(canonical);
} else {
keys.push(p)
keys.push(p);
}
}
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
const uniqueMods = [...new Set(mods)]
const uniqueMods = [...new Set(mods)];
uniqueMods.sort(
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
)
return { mods: uniqueMods, keys }
);
return { mods: uniqueMods, keys };
}
/**
@@ -104,8 +104,8 @@ function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
* canonical, dedupe, sort modifiers, non-modifiers last.
*/
export function normalizeKeySequence(seq: string): string {
const { mods, keys } = partitionKeys(seq)
return [...mods, ...keys].join('+')
const { mods, keys } = partitionKeys(seq);
return [...mods, ...keys].join("+");
}
/**
@@ -123,26 +123,26 @@ export function normalizeKeySequence(seq: string): string {
*/
export function isSystemKeyCombo(
seq: string,
platform: 'darwin' | 'win32',
platform: "darwin" | "win32",
): boolean {
const blocklist = platform === 'darwin' ? BLOCKED_DARWIN : BLOCKED_WIN32
const { mods, keys } = partitionKeys(seq)
const prefix = mods.length > 0 ? mods.join('+') + '+' : ''
const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
const { mods, keys } = partitionKeys(seq);
const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
// No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
// whole thing. Never matches (no blocklist entry is modifier-only) but
// keeps the contract simple: every call reaches a .has().
if (keys.length === 0) {
return blocklist.has(mods.join('+'))
return blocklist.has(mods.join("+"));
}
// mods + each key. Any hit blocks the whole sequence.
for (const key of keys) {
if (blocklist.has(prefix + key)) {
return true
return true;
}
}
return false
return false;
}
export const _test = {
@@ -150,4 +150,4 @@ export const _test = {
BLOCKED_DARWIN,
BLOCKED_WIN32,
MODIFIER_ORDER,
}
};

View File

@@ -17,21 +17,21 @@
* is the same either way.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
} from "@modelcontextprotocol/sdk/types.js";
import type { ScreenshotResult } from './executor.js'
import type { CuCallToolResult } from './toolCalls.js'
import type { ScreenshotResult } from "./executor.js";
import type { CuCallToolResult } from "./toolCalls.js";
import {
defersLockAcquire,
handleToolCall,
resetMouseButtonHeld,
} from './toolCalls.js'
import { buildComputerUseTools } from './tools.js'
} from "./toolCalls.js";
import { buildComputerUseTools } from "./tools.js";
import type {
AppGrant,
ComputerUseHostAdapter,
@@ -40,12 +40,12 @@ import type {
CoordinateMode,
CuGrantFlags,
CuPermissionResponse,
} from './types.js'
import { DEFAULT_GRANT_FLAGS } from './types.js'
} from "./types.js";
import { DEFAULT_GRANT_FLAGS } from "./types.js";
const DEFAULT_LOCK_HELD_MESSAGE =
'Another Claude session is currently using the computer. Wait for that ' +
'session to finish, or find a non-computer-use approach.'
"Another Claude session is currently using the computer. Wait for that " +
"session to finish, or find a non-computer-use approach.";
/**
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
@@ -60,20 +60,20 @@ function mergePermissionResponse(
existingFlags: CuGrantFlags,
response: CuPermissionResponse,
): { apps: AppGrant[]; flags: CuGrantFlags } {
const seen = new Set(existing.map(a => a.bundleId))
const seen = new Set(existing.map((a) => a.bundleId));
const apps = [
...existing,
...response.granted.filter(g => !seen.has(g.bundleId)),
]
...response.granted.filter((g) => !seen.has(g.bundleId)),
];
const truthyFlags = Object.fromEntries(
Object.entries(response.flags).filter(([, v]) => v === true),
)
);
const flags: CuGrantFlags = {
...DEFAULT_GRANT_FLAGS,
...existingFlags,
...truthyFlags,
}
return { apps, flags }
};
return { apps, flags };
}
/**
@@ -91,53 +91,53 @@ export function bindSessionContext(
coordinateMode: CoordinateMode,
ctx: ComputerUseSessionContext,
): (name: string, args: unknown) => Promise<CuCallToolResult> {
const { logger, serverName } = adapter
const { logger, serverName } = adapter;
// Screenshot blob persists here across calls — NOT on `ctx`. Hosts hold
// onto the returned dispatcher; that's the identity that matters.
let lastScreenshot: ScreenshotResult | undefined
let lastScreenshot: ScreenshotResult | undefined;
const wrapPermission = ctx.onPermissionRequest
? async (
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
signal: AbortSignal,
): Promise<CuPermissionResponse> => {
const response = await ctx.onPermissionRequest!(req, signal)
const response = await ctx.onPermissionRequest!(req, signal);
const { apps, flags } = mergePermissionResponse(
ctx.getAllowedApps(),
ctx.getGrantFlags(),
response,
)
);
logger.debug(
`[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`,
)
ctx.onAllowedAppsChanged?.(apps, flags)
return response
);
ctx.onAllowedAppsChanged?.(apps, flags);
return response;
}
: undefined
: undefined;
const wrapTeachPermission = ctx.onTeachPermissionRequest
? async (
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
signal: AbortSignal,
): Promise<CuPermissionResponse> => {
const response = await ctx.onTeachPermissionRequest!(req, signal)
const response = await ctx.onTeachPermissionRequest!(req, signal);
logger.debug(
`[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`,
)
);
// Teach doesn't request grant flags — preserve existing.
const { apps } = mergePermissionResponse(
ctx.getAllowedApps(),
ctx.getGrantFlags(),
response,
)
);
ctx.onAllowedAppsChanged?.(apps, {
...DEFAULT_GRANT_FLAGS,
...ctx.getGrantFlags(),
})
return response
});
return response;
}
: undefined
: undefined;
return async (name, args) => {
// ─── Async lock gate ─────────────────────────────────────────────────
@@ -146,18 +146,18 @@ export function bindSessionContext(
// cross-process locks (O_EXCL file) await the real primitive here
// instead of pre-computing + feeding a fake sync result.
if (ctx.checkCuLock) {
const lock = await ctx.checkCuLock()
const lock = await ctx.checkCuLock();
if (lock.holder !== undefined && !lock.isSelf) {
const text =
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
return {
content: [{ type: 'text', text }],
content: [{ type: "text", text }],
isError: true,
telemetry: { error_kind: 'cu_lock_held' },
}
telemetry: { error_kind: "cu_lock_held" },
};
}
if (lock.holder === undefined && !defersLockAcquire(name)) {
await ctx.acquireCuLock?.()
await ctx.acquireCuLock?.();
// Re-check: the awaits above yield the microtask queue, so another
// session's check+acquire can interleave with ours. Hosts where
// acquire is a no-op when already held (Cowork's CuLockManager) give
@@ -165,21 +165,21 @@ export function bindSessionContext(
// proceeding. The CLI's O_EXCL file lock would surface this as a throw from
// acquire instead; this re-check is a belt-and-suspenders for that
// path too.
const recheck = await ctx.checkCuLock()
const recheck = await ctx.checkCuLock();
if (recheck.holder !== undefined && !recheck.isSelf) {
const text =
ctx.formatLockHeldMessage?.(recheck.holder) ??
DEFAULT_LOCK_HELD_MESSAGE
DEFAULT_LOCK_HELD_MESSAGE;
return {
content: [{ type: 'text', text }],
content: [{ type: "text", text }],
isError: true,
telemetry: { error_kind: 'cu_lock_held' },
}
telemetry: { error_kind: "cu_lock_held" },
};
}
// Fresh holder → any prior session's mouseButtonHeld is stale.
// Mirrors what Gate-3 does on the acquire branch. After the
// re-check so we only clear module state when we actually won.
resetMouseButtonHeld()
resetMouseButtonHeld();
}
}
@@ -189,12 +189,12 @@ export function bindSessionContext(
// isEmpty → skip.
const dimsFallback = lastScreenshot
? undefined
: ctx.getLastScreenshotDims?.()
: ctx.getLastScreenshotDims?.();
// Per-call AbortController for dialog dismissal. Aborted in `finally` —
// if handleToolCall finishes (MCP timeout, throw) before the user
// answers, the host's dialog handler sees the abort and tears down.
const dialogAbort = new AbortController()
const dialogAbort = new AbortController();
const overrides: ComputerUseOverrides = {
allowedApps: [...ctx.getAllowedApps()],
@@ -206,12 +206,12 @@ export function bindSessionContext(
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
lastScreenshot:
lastScreenshot ??
(dimsFallback ? { ...dimsFallback, base64: '' } : undefined),
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
onPermissionRequest: wrapPermission
? req => wrapPermission(req, dialogAbort.signal)
? (req) => wrapPermission(req, dialogAbort.signal)
: undefined,
onTeachPermissionRequest: wrapTeachPermission
? req => wrapTeachPermission(req, dialogAbort.signal)
? (req) => wrapTeachPermission(req, dialogAbort.signal)
: undefined,
onAppsHidden: ctx.onAppsHidden,
getClipboardStash: ctx.getClipboardStash,
@@ -228,28 +228,28 @@ export function bindSessionContext(
checkCuLock: undefined,
acquireCuLock: undefined,
isAborted: ctx.isAborted,
}
};
logger.debug(
`[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`,
)
);
// ─── Dispatch ────────────────────────────────────────────────────────
try {
const result = await handleToolCall(adapter, name, args, overrides)
const result = await handleToolCall(adapter, name, args, overrides);
if (result.screenshot) {
lastScreenshot = result.screenshot
const { base64: _blob, ...dims } = result.screenshot
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`)
ctx.onScreenshotCaptured?.(dims)
lastScreenshot = result.screenshot;
const { base64: _blob, ...dims } = result.screenshot;
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`);
ctx.onScreenshotCaptured?.(dims);
}
return result
return result;
} finally {
dialogAbort.abort()
dialogAbort.abort();
}
}
};
}
export function createComputerUseMcpServer(
@@ -257,36 +257,35 @@ export function createComputerUseMcpServer(
coordinateMode: CoordinateMode,
context?: ComputerUseSessionContext,
): Server {
const { serverName, logger } = adapter
const { serverName, logger } = adapter;
const server = new Server(
{ name: serverName, version: '0.1.3' },
{ name: serverName, version: "0.1.3" },
{ capabilities: { tools: {}, logging: {} } },
)
);
const tools = buildComputerUseTools(
adapter.executor.capabilities,
coordinateMode,
)
);
server.setRequestHandler(ListToolsRequestSchema, async () =>
adapter.isDisabled() ? { tools: [] } : { tools },
)
);
if (context) {
const dispatch = bindSessionContext(adapter, coordinateMode, context)
const dispatch = bindSessionContext(adapter, coordinateMode, context);
server.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
const {
screenshot: _s,
telemetry: _t,
...result
} = await dispatch(request.params.name, request.params.arguments ?? {})
return result
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
request.params.name,
request.params.arguments ?? {},
);
return result;
},
)
return server
);
return server;
}
// Legacy: no context → stub handler. Reached only if something calls the
@@ -297,18 +296,18 @@ export function createComputerUseMcpServer(
async (request): Promise<CallToolResult> => {
logger.warn(
`[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`,
)
);
return {
content: [
{
type: 'text',
text: 'This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.',
type: "text",
text: "This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.",
},
],
isError: true,
}
};
},
)
);
return server
return server;
}

View File

@@ -19,28 +19,28 @@
* this package never imports it — the crop is a function parameter.
*/
import type { ScreenshotResult } from './executor.js'
import type { Logger } from './types.js'
import type { ScreenshotResult } from "./executor.js";
import type { Logger } from "./types.js";
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
export type CropRawPatchFn = (
jpegBase64: string,
rect: { x: number; y: number; width: number; height: number },
) => Buffer | null
) => Buffer | null;
/** 9×9 is empirically the sweet spot — large enough to catch a tooltip
* appearing, small enough to not false-positive on surrounding animation.
**/
const DEFAULT_GRID_SIZE = 9
const DEFAULT_GRID_SIZE = 9;
export interface PixelCompareResult {
/** true → click may proceed. false → patch changed, abort the click. */
valid: boolean
valid: boolean;
/** true → validation did not run (cold start, sub-gate off, or internal
* error). The caller MUST treat this identically to `valid: true`. */
skipped: boolean
skipped: boolean;
/** Populated when valid === false. Returned to the model verbatim. */
warning?: string
warning?: string;
}
/**
@@ -57,22 +57,22 @@ function computeCropRect(
yPercent: number,
gridSize: number,
): { x: number; y: number; width: number; height: number } | null {
if (!imgW || !imgH) return null
if (!imgW || !imgH) return null;
const clampedX = Math.max(0, Math.min(100, xPercent))
const clampedY = Math.max(0, Math.min(100, yPercent))
const clampedX = Math.max(0, Math.min(100, xPercent));
const clampedY = Math.max(0, Math.min(100, yPercent));
const centerX = Math.round((clampedX / 100.0) * imgW)
const centerY = Math.round((clampedY / 100.0) * imgH)
const centerX = Math.round((clampedX / 100.0) * imgW);
const centerY = Math.round((clampedY / 100.0) * imgH);
const halfGrid = Math.floor(gridSize / 2)
const cropX = Math.max(0, centerX - halfGrid)
const cropY = Math.max(0, centerY - halfGrid)
const cropW = Math.min(gridSize, imgW - cropX)
const cropH = Math.min(gridSize, imgH - cropY)
if (cropW <= 0 || cropH <= 0) return null
const halfGrid = Math.floor(gridSize / 2);
const cropX = Math.max(0, centerX - halfGrid);
const cropY = Math.max(0, centerY - halfGrid);
const cropW = Math.min(gridSize, imgW - cropX);
const cropH = Math.min(gridSize, imgH - cropY);
if (cropW <= 0 || cropH <= 0) return null;
return { x: cropX, y: cropY, width: cropW, height: cropH }
return { x: cropX, y: cropY, width: cropW, height: cropH };
}
/**
@@ -98,17 +98,17 @@ export function comparePixelAtLocation(
xPercent,
yPercent,
gridSize,
)
if (!rect) return false
);
if (!rect) return false;
const patch1 = crop(lastScreenshot.base64, rect)
const patch2 = crop(freshScreenshot.base64, rect)
if (!patch1 || !patch2) return false
const patch1 = crop(lastScreenshot.base64, rect);
const patch2 = crop(freshScreenshot.base64, rect);
if (!patch1 || !patch2) return false;
// Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's
// .raw() gave RGB.
// Doesn't matter — we're comparing two same-format buffers for equality.
return patch1.equals(patch2)
return patch1.equals(patch2);
}
/**
@@ -135,13 +135,13 @@ export async function validateClickTarget(
gridSize: number = DEFAULT_GRID_SIZE,
): Promise<PixelCompareResult> {
if (!lastScreenshot) {
return { valid: true, skipped: true }
return { valid: true, skipped: true };
}
try {
const fresh = await takeFreshScreenshot()
const fresh = await takeFreshScreenshot();
if (!fresh) {
return { valid: true, skipped: true }
return { valid: true, skipped: true };
}
const pixelsMatch = comparePixelAtLocation(
@@ -151,21 +151,21 @@ export async function validateClickTarget(
xPercent,
yPercent,
gridSize,
)
);
if (pixelsMatch) {
return { valid: true, skipped: false }
return { valid: true, skipped: false };
}
return {
valid: false,
skipped: false,
warning:
'Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.',
}
"Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.",
};
} catch (err) {
// Skip validation on technical errors, execute action anyway.
// Battle-tested: validation failure must never block the click.
logger.debug('[pixelCompare] validation error, skipping', err)
return { valid: true, skipped: true }
logger.debug("[pixelCompare] validation error, skipping", err);
return { valid: true, skipped: true };
}
}

View File

@@ -11,33 +11,33 @@
/** These apps can execute arbitrary shell commands. */
const SHELL_ACCESS_BUNDLE_IDS = new Set([
'com.apple.Terminal',
'com.googlecode.iterm2',
'com.microsoft.VSCode',
'dev.warp.Warp-Stable',
'com.github.wez.wezterm',
'io.alacritty',
'net.kovidgoyal.kitty',
'com.jetbrains.intellij',
'com.jetbrains.pycharm',
])
"com.apple.Terminal",
"com.googlecode.iterm2",
"com.microsoft.VSCode",
"dev.warp.Warp-Stable",
"com.github.wez.wezterm",
"io.alacritty",
"net.kovidgoyal.kitty",
"com.jetbrains.intellij",
"com.jetbrains.pycharm",
]);
/** Finder in the allowlist ≈ browse + open-any-file. */
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(['com.apple.finder'])
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(["com.apple.finder"]);
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(['com.apple.systempreferences'])
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]);
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
...SHELL_ACCESS_BUNDLE_IDS,
...FILESYSTEM_ACCESS_BUNDLE_IDS,
...SYSTEM_SETTINGS_BUNDLE_IDS,
])
]);
export type SentinelCategory = 'shell' | 'filesystem' | 'system_settings'
export type SentinelCategory = "shell" | "filesystem" | "system_settings";
export function getSentinelCategory(bundleId: string): SentinelCategory | null {
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return 'shell'
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return 'filesystem'
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return 'system_settings'
return null
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return "shell";
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return "filesystem";
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return "system_settings";
return null;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@ import type {
ComputerExecutor,
InstalledApp,
ScreenshotResult,
} from './executor.js'
} from "./executor.js";
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
* cross-respawn `scaleCoord` survival. */
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
export type ScreenshotDims = Omit<ScreenshotResult, "base64">;
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
export interface Logger {
info: (message: string, ...args: unknown[]) => void
error: (message: string, ...args: unknown[]) => void
warn: (message: string, ...args: unknown[]) => void
debug: (message: string, ...args: unknown[]) => void
silly: (message: string, ...args: unknown[]) => void
info: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug: (message: string, ...args: unknown[]) => void;
silly: (message: string, ...args: unknown[]) => void;
}
/**
@@ -35,7 +35,7 @@ export interface Logger {
* Enforced in `runInputActionGates` via the frontmost-app check: keyboard
* actions require `"full"`, mouse actions require `"click"` or higher.
*/
export type CuAppPermTier = 'read' | 'click' | 'full'
export type CuAppPermTier = "read" | "click" | "full";
/**
* A single app the user has approved for the current session. Session-scoped
@@ -45,32 +45,32 @@ export type CuAppPermTier = 'read' | 'click' | 'full'
* scope.
*/
export interface AppGrant {
bundleId: string
displayName: string
bundleId: string;
displayName: string;
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
grantedAt: number
grantedAt: number;
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
* session state). */
tier?: CuAppPermTier
tier?: CuAppPermTier;
}
/** Orthogonal to the app allowlist. */
export interface CuGrantFlags {
clipboardRead: boolean
clipboardWrite: boolean
clipboardRead: boolean;
clipboardWrite: boolean;
/**
* When false, the `key` tool rejects combos in `keyBlocklist.ts`
* (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other
* key sequences work regardless.
*/
systemKeyCombos: boolean
systemKeyCombos: boolean;
}
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
clipboardRead: false,
clipboardWrite: false,
systemKeyCombos: false,
}
};
/**
* Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked
@@ -78,7 +78,7 @@ export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
* ONE convention and never learns the other exists. `normalized_0_100`
* sidesteps the Retina scaleFactor bug class entirely.
*/
export type CoordinateMode = 'pixels' | 'normalized_0_100'
export type CoordinateMode = "pixels" | "normalized_0_100";
/**
* Independent kill switches for subtle/risky ported behaviors. Read from
@@ -86,28 +86,28 @@ export type CoordinateMode = 'pixels' | 'normalized_0_100'
*/
export interface CuSubGates {
/** 9×9 exact-byte staleness guard before click. */
pixelValidation: boolean
pixelValidation: boolean;
/** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */
clipboardPasteMultiline: boolean
clipboardPasteMultiline: boolean;
/**
* Ease-out-cubic mouse glide at 60fps, distance-proportional duration
* (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency
* per click. When off, cursor teleports instantly.
*/
mouseAnimation: boolean
mouseAnimation: boolean;
/**
* Pre-action sequence: hide non-allowlisted apps, then defocus us (from the
* Vercept acquisition). When off, the
* frontmost gate fires in the normal case and the model gets stuck — this
* is the A/B-test-the-old-broken-behavior switch.
*/
hideBeforeAction: boolean
hideBeforeAction: boolean;
/**
* Auto-resolve the target display before each screenshot when the
* selected display has no allowed-app windows. When on, `handleScreenshot`
* uses the atomic Swift path; off → sticks with `selectedDisplayId`.
*/
autoTargetDisplay: boolean
autoTargetDisplay: boolean;
/**
* Stash+clear the clipboard while a tier-"click" app is frontmost.
* Closes the gap where a click-tier terminal/IDE has a UI Paste button
@@ -115,7 +115,7 @@ export interface CuSubGates {
* keyboard block can be routed around by clicking Paste. Restored when
* a non-"click" app becomes frontmost, or at turn end.
*/
clipboardGuard: boolean
clipboardGuard: boolean;
}
// ----------------------------------------------------------------------------
@@ -125,17 +125,17 @@ export interface CuSubGates {
/** One entry per app the model asked for, after name → bundle ID resolution. */
export interface ResolvedAppRequest {
/** What the model asked for (e.g. "Slack", "com.tinyspeck.slackmacgap"). */
requestedName: string
requestedName: string;
/** The resolved InstalledApp if found, else undefined (shown greyed in the UI). */
resolved?: InstalledApp
resolved?: InstalledApp;
/** Shell-access-equivalent bundle IDs get a UI warning. See sentinelApps.ts. */
isSentinel: boolean
isSentinel: boolean;
/** Already in the allowlist → skip the checkbox, return in `granted` immediately. */
alreadyGranted: boolean
alreadyGranted: boolean;
/** Hardcoded tier for this app (browser→"read", terminal→"click", else "full").
* The dialog displays this read-only; the renderer passes it through
* verbatim in the AppGrant. */
proposedTier: CuAppPermTier
proposedTier: CuAppPermTier;
}
/**
@@ -145,18 +145,18 @@ export interface ResolvedAppRequest {
* change needed.
*/
export interface CuPermissionRequest {
requestId: string
requestId: string;
/** Model-provided reason string. Shown prominently in the approval UI. */
reason: string
apps: ResolvedAppRequest[]
reason: string;
apps: ResolvedAppRequest[];
/** What the model asked for. User can toggle independently of apps. */
requestedFlags: Partial<CuGrantFlags>
requestedFlags: Partial<CuGrantFlags>;
/**
* For the "On Windows, Claude can see all apps..." footnote. Taken from
* `executor.capabilities.screenshotFiltering` so the renderer doesn't
* need to know about platforms.
*/
screenshotFiltering: 'native' | 'none'
screenshotFiltering: "native" | "none";
/**
* Present only when TCC permissions are NOT yet granted. When present,
* the renderer shows a TCC toggle panel (two rows: Accessibility, Screen
@@ -166,9 +166,9 @@ export interface CuPermissionRequest {
* restart after granting Screen Recording — we don't.
*/
tccState?: {
accessibility: boolean
screenRecording: boolean
}
accessibility: boolean;
screenRecording: boolean;
};
/**
* Apps with windows on the CU display that aren't in the requested
* allowlist. These will be hidden the first time Claude takes an action.
@@ -176,13 +176,13 @@ export interface CuPermissionRequest {
* user clicks Allow, but it's a preview, not a contract. Absent when
* empty so the renderer can skip the section cleanly.
*/
willHide?: Array<{ bundleId: string; displayName: string }>
willHide?: Array<{ bundleId: string; displayName: string }>;
/**
* `chicagoAutoUnhide` app preference at request time. The renderer picks
* between "...then restored when Claude is done" and "...will be hidden"
* copy. Absent when `willHide` is absent (same condition).
*/
autoUnhideEnabled?: boolean
autoUnhideEnabled?: boolean;
}
/**
@@ -191,10 +191,10 @@ export interface CuPermissionRequest {
* LocalAgentModeSessionManager.ts:2794).
*/
export interface CuPermissionResponse {
granted: AppGrant[]
granted: AppGrant[];
/** Bundle IDs the user unchecked, or apps that weren't installed. */
denied: Array<{ bundleId: string; reason: 'user_denied' | 'not_installed' }>
flags: CuGrantFlags
denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>;
flags: CuGrantFlags;
/**
* Whether the user clicked Allow in THIS dialog. Only set by the
* teach-mode handler — regular request_access doesn't need it (the
@@ -205,7 +205,7 @@ export interface CuPermissionResponse {
* them apart without this. Undefined → legacy/regular path, do not
* gate on it.
*/
userConsented?: boolean
userConsented?: boolean;
}
// ----------------------------------------------------------------------------
@@ -218,9 +218,9 @@ export interface CuPermissionResponse {
* No Electron imports in this package — the host injects everything.
*/
export interface ComputerUseHostAdapter {
serverName: string
logger: Logger
executor: ComputerExecutor
serverName: string;
logger: Logger;
executor: ComputerExecutor;
/**
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
@@ -231,23 +231,23 @@ export interface ComputerUseHostAdapter {
ensureOsPermissions(): Promise<
| { granted: true }
| { granted: false; accessibility: boolean; screenRecording: boolean }
>
>;
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
isDisabled(): boolean
isDisabled(): boolean;
/**
* The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest`
* to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's
* "will be hidden" copy can say "then restored" only when true.
*/
getAutoUnhideEnabled(): boolean
getAutoUnhideEnabled(): boolean;
/**
* Sub-gates re-read on every tool call so GrowthBook flips take effect
* mid-session without restart.
*/
getSubGates(): CuSubGates
getSubGates(): CuSubGates;
/**
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
@@ -261,7 +261,7 @@ export interface ComputerUseHostAdapter {
cropRawPatch(
jpegBase64: string,
rect: { x: number; y: number; width: number; height: number },
): Buffer | null
): Buffer | null;
}
// ----------------------------------------------------------------------------
@@ -286,18 +286,18 @@ export interface ComputerUseHostAdapter {
export interface ComputerUseSessionContext {
// ── Read state fresh per call ──────────────────────────────────────
getAllowedApps(): readonly AppGrant[]
getGrantFlags(): CuGrantFlags
getAllowedApps(): readonly AppGrant[];
getGrantFlags(): CuGrantFlags;
/** Per-user auto-deny list (Settings page). Empty array = none. */
getUserDeniedBundleIds(): readonly string[]
getSelectedDisplayId(): number | undefined
getDisplayPinnedByModel?(): boolean
getDisplayResolvedForApps?(): string | undefined
getTeachModeActive?(): boolean
getUserDeniedBundleIds(): readonly string[];
getSelectedDisplayId(): number | undefined;
getDisplayPinnedByModel?(): boolean;
getDisplayResolvedForApps?(): string | undefined;
getTeachModeActive?(): boolean;
/** Dims-only fallback when `lastScreenshot` is unset (cross-respawn).
* `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord
* works and pixelCompare correctly skips. */
getLastScreenshotDims?(): ScreenshotDims | undefined
getLastScreenshotDims?(): ScreenshotDims | undefined;
// ── Write-back callbacks ───────────────────────────────────────────
@@ -307,46 +307,46 @@ export interface ComputerUseSessionContext {
onPermissionRequest?(
req: CuPermissionRequest,
signal: AbortSignal,
): Promise<CuPermissionResponse>
): Promise<CuPermissionResponse>;
/** Teach-mode sibling of `onPermissionRequest`. */
onTeachPermissionRequest?(
req: CuTeachPermissionRequest,
signal: AbortSignal,
): Promise<CuPermissionResponse>
): Promise<CuPermissionResponse>;
/** Called by `bindSessionContext` after merging a permission response into
* the allowlist (dedupe on bundleId, truthy-only flag spread). Host
* persists for resume survival. */
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void
onAppsHidden?(bundleIds: string[]): void
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void;
onAppsHidden?(bundleIds: string[]): void;
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
getClipboardStash?(): string | undefined
getClipboardStash?(): string | undefined;
/** Writes the clipboardGuard stash. undefined clears it. */
onClipboardStashChanged?(stash: string | undefined): void
onResolvedDisplayUpdated?(displayId: number): void
onDisplayPinned?(displayId: number | undefined): void
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void
onClipboardStashChanged?(stash: string | undefined): void;
onResolvedDisplayUpdated?(displayId: number): void;
onDisplayPinned?(displayId: number | undefined): void;
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void;
/** Called after each screenshot. Host persists for respawn survival. */
onScreenshotCaptured?(dims: ScreenshotDims): void
onTeachModeActivated?(): void
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>
onTeachWorking?(): void
onScreenshotCaptured?(dims: ScreenshotDims): void;
onTeachModeActivated?(): void;
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>;
onTeachWorking?(): void;
// ── Lock (async) ───────────────────────────────────────────────────
/** At most one session uses CU at a time. Awaited by `bindSessionContext`
* before dispatch. Undefined → no lock gating (proceed). */
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>;
/** Take the lock. Called when `checkCuLock` returned `holder: undefined`
* on a non-deferring tool. Host emits enter-CU signals here. */
acquireCuLock?(): Promise<void>
acquireCuLock?(): Promise<void>;
/** Host-specific lock-held error text. Default is the package's generic
* message. The CLI host includes the holder session-ID prefix. */
formatLockHeldMessage?(holder: string): string
formatLockHeldMessage?(holder: string): string;
/** User-abort signal. Passed through to `ComputerUseOverrides.isAborted`
* for the mid-loop checks in handleComputerBatch / handleType. See that
* field for semantics. */
isAborted?(): boolean
isAborted?(): boolean;
}
// ----------------------------------------------------------------------------
@@ -360,9 +360,9 @@ export interface ComputerUseSessionContext {
* store, not the server.
*/
export interface ComputerUseOverrides {
allowedApps: AppGrant[]
grantFlags: CuGrantFlags
coordinateMode: CoordinateMode
allowedApps: AppGrant[];
grantFlags: CuGrantFlags;
coordinateMode: CoordinateMode;
/**
* User-configured auto-deny list (Settings → Desktop app → Computer Use).
@@ -376,14 +376,14 @@ export interface ComputerUseOverrides {
* not session state). Contrast with `allowedApps` which is per-session.
* Empty array = no user-configured denies (the default).
*/
userDeniedBundleIds: readonly string[]
userDeniedBundleIds: readonly string[];
/**
* Display CU operates on; read fresh per call. `scaleCoord` uses the
* `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches
* only affect the NEXT screenshot/prepare call.
*/
selectedDisplayId?: number
selectedDisplayId?: number;
/**
* The `request_access` tool handler calls this and awaits. The wrapper
@@ -395,16 +395,14 @@ export interface ComputerUseOverrides {
* Undefined when the session wasn't wired with a permission handler (e.g.
* a future headless mode). `request_access` returns a tool error in that case.
*/
onPermissionRequest?: (
req: CuPermissionRequest,
) => Promise<CuPermissionResponse>
onPermissionRequest?: (req: CuPermissionRequest) => Promise<CuPermissionResponse>;
/**
* For the pixel-validation staleness guard. The model's-last-screenshot,
* stashed by serverDef.ts after each `screenshot` tool call. Undefined on
* cold start → pixel validation skipped (click proceeds).
*/
lastScreenshot?: ScreenshotResult
lastScreenshot?: ScreenshotResult;
/**
* Fired after every `prepareForAction` with the bundle IDs it just hid.
@@ -418,7 +416,7 @@ export interface ComputerUseOverrides {
* Undefined when the session wasn't wired with a tracker — unhide just
* doesn't happen.
*/
onAppsHidden?: (bundleIds: string[]) => void
onAppsHidden?: (bundleIds: string[]) => void;
/**
* Reads the clipboardGuard stash from session state. `undefined` means no
@@ -426,7 +424,7 @@ export interface ComputerUseOverrides {
* and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern
* — state lives on the host's session, not module-level here.
*/
getClipboardStash?: () => string | undefined
getClipboardStash?: () => string | undefined;
/**
* Writes the clipboardGuard stash to session state. `undefined` clears.
@@ -435,7 +433,7 @@ export interface ComputerUseOverrides {
* directly and restores via Electron's `clipboard.writeText` (no nest-only
* import surface).
*/
onClipboardStashChanged?: (stash: string | undefined) => void
onClipboardStashChanged?: (stash: string | undefined) => void;
/**
* Write the resolver's picked display back to session so teach overlay
@@ -444,7 +442,7 @@ export interface ComputerUseOverrides {
* `resolvePrepareCapture`'s pick differs from `selectedDisplayId`.
* Fire-and-forget.
*/
onResolvedDisplayUpdated?: (displayId: number) => void
onResolvedDisplayUpdated?: (displayId: number) => void;
/**
* Set when the model explicitly picked a display via `switch_display`.
@@ -455,7 +453,7 @@ export interface ComputerUseOverrides {
* overrides any `selectedDisplayId` whenever an allowed app shares the
* host's monitor.
*/
displayPinnedByModel?: boolean
displayPinnedByModel?: boolean;
/**
* Write the model's explicit display pick to session. `displayId:
@@ -463,7 +461,7 @@ export interface ComputerUseOverrides {
* Sibling of `onResolvedDisplayUpdated` but also sets the pin flag —
* the two are semantically distinct (resolver-picked vs model-picked).
*/
onDisplayPinned?: (displayId: number | undefined) => void
onDisplayPinned?: (displayId: number | undefined) => void;
/**
* Sorted comma-joined bundle-ID set the display was last auto-resolved
@@ -472,14 +470,14 @@ export interface ComputerUseOverrides {
* doesn't yank the display on every screenshot, only when the app set
* has changed since the last resolve (or manual switch).
*/
displayResolvedForApps?: string
displayResolvedForApps?: string;
/**
* Records which app set the current display selection was made for. Fired
* alongside `onResolvedDisplayUpdated` when the resolver picks, so the next
* screenshot sees a matching set and skips auto-resolve.
*/
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void;
/**
* Global CU lock — at most one session actively uses CU at a time. Checked
@@ -496,7 +494,7 @@ export interface ComputerUseOverrides {
* The host manages release (on session idle/stop/archive) — this package
* never releases.
*/
checkCuLock?: () => { holder: string | undefined; isSelf: boolean }
checkCuLock?: () => { holder: string | undefined; isSelf: boolean };
/**
* Take the lock for this session. `handleToolCall` calls this exactly once
@@ -504,7 +502,7 @@ export interface ComputerUseOverrides {
* undefined. No-op if already held (defensive — the check should have
* short-circuited). Host emits an event the overlay listens to.
*/
acquireCuLock?: () => void
acquireCuLock?: () => void;
/**
* User-abort signal. Checked mid-iteration inside `handleComputerBatch`
@@ -515,7 +513,7 @@ export interface ComputerUseOverrides {
* Undefined → never aborts (e.g. unwired host). Live per-check read —
* same lazy-getter pattern as `checkCuLock`.
*/
isAborted?: () => boolean
isAborted?: () => boolean;
// ── Teach mode ───────────────────────────────────────────────────────
// Wired only when the host's teachModeEnabled gate is on. All five
@@ -531,7 +529,7 @@ export interface ComputerUseOverrides {
*/
onTeachPermissionRequest?: (
req: CuTeachPermissionRequest,
) => Promise<CuPermissionResponse>
) => Promise<CuPermissionResponse>;
/**
* Called by `handleRequestTeachAccess` after the user approves and at least
@@ -540,7 +538,7 @@ export interface ComputerUseOverrides {
* fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`)
* alongside the CU lock release.
*/
onTeachModeActivated?: () => void
onTeachModeActivated?: () => void;
/**
* Read by `handleRequestAccess` and `handleRequestTeachAccess` to
@@ -551,7 +549,7 @@ export interface ComputerUseOverrides {
* (not a boolean field) because teach mode state lives on the session,
* not on this per-call overrides object.
*/
getTeachModeActive?: () => boolean
getTeachModeActive?: () => boolean;
/**
* Called by `handleTeachStep` with the scaled anchor + text. Host stores
@@ -564,7 +562,7 @@ export interface ComputerUseOverrides {
* Same blocking-promise pattern as `onPermissionRequest`, but resolved by
* the teach overlay's own preload (not the main renderer's tool-approval UI).
*/
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>;
/**
* Called immediately after `onTeachStep` resolves with "next", before
@@ -573,7 +571,7 @@ export interface ComputerUseOverrides {
* notch). The next `onTeachStep` call replaces the spinner with the new
* tooltip content.
*/
onTeachWorking?: () => void
onTeachWorking?: () => void;
}
// ----------------------------------------------------------------------------
@@ -592,13 +590,13 @@ export interface ComputerUseOverrides {
* CSS coords match.
*/
export interface TeachStepRequest {
explanation: string
nextPreview: string
explanation: string;
nextPreview: string;
/** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */
anchorLogical?: { x: number; y: number }
anchorLogical?: { x: number; y: number };
}
export type TeachStepResult = { action: 'next' } | { action: 'exit' }
export type TeachStepResult = { action: "next" } | { action: "exit" };
/**
* Payload for the renderer's ComputerUseTeachApproval dialog. Rides through
@@ -608,17 +606,17 @@ export type TeachStepResult = { action: 'next' } | { action: 'exit' }
* fields it doesn't render (no grant-flag checkboxes in teach mode).
*/
export interface CuTeachPermissionRequest {
requestId: string
requestId: string;
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
reason: string
apps: ResolvedAppRequest[]
screenshotFiltering: 'native' | 'none'
reason: string;
apps: ResolvedAppRequest[];
screenshotFiltering: "native" | "none";
/** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */
tccState?: {
accessibility: boolean
screenRecording: boolean
}
willHide?: Array<{ bundleId: string; displayName: string }>
accessibility: boolean;
screenRecording: boolean;
};
willHide?: Array<{ bundleId: string; displayName: string }>;
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
autoUnhideEnabled?: boolean
autoUnhideEnabled?: boolean;
}

View File

@@ -1,8 +1,8 @@
{
"name": "@ant/computer-use-swift",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
"name": "@ant/computer-use-swift",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}

View File

@@ -9,17 +9,9 @@ import { readFileSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type {
AppInfo,
AppsAPI,
DisplayAPI,
DisplayGeometry,
InstalledApp,
PrepareDisplayResult,
RunningApp,
ScreenshotAPI,
ScreenshotResult,
SwiftBackend,
WindowDisplayInfo,
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
SwiftBackend, WindowDisplayInfo,
} from '../types.js'
export type {
@@ -40,8 +32,7 @@ export type {
function jxaSync(script: string): string {
const result = Bun.spawnSync({
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
stdout: 'pipe',
stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe',
})
return new TextDecoder().decode(result.stdout).trim()
}
@@ -49,16 +40,14 @@ function jxaSync(script: string): string {
function osascriptSync(script: string): string {
const result = Bun.spawnSync({
cmd: ['osascript', '-e', script],
stdout: 'pipe',
stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe',
})
return new TextDecoder().decode(result.stdout).trim()
}
async function osascript(script: string): Promise<string> {
const proc = Bun.spawn(['osascript', '-e', script], {
stdout: 'pipe',
stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe',
})
const text = await new Response(proc.stdout).text()
await proc.exited
@@ -67,8 +56,7 @@ async function osascript(script: string): Promise<string> {
async function jxa(script: string): Promise<string> {
const proc = Bun.spawn(['osascript', '-l', 'JavaScript', '-e', script], {
stdout: 'pipe',
stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe',
})
const text = await new Response(proc.stdout).text()
await proc.exited
@@ -113,10 +101,8 @@ export const display: DisplayAPI = {
JSON.stringify(result);
`)
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
width: Number(d.width),
height: Number(d.height),
scaleFactor: Number(d.scaleFactor),
displayId: Number(d.displayId),
width: Number(d.width), height: Number(d.height),
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
}))
} catch {
try {
@@ -140,10 +126,8 @@ export const display: DisplayAPI = {
JSON.stringify(result);
`)
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
width: Number(d.width),
height: Number(d.height),
scaleFactor: Number(d.scaleFactor),
displayId: Number(d.displayId),
width: Number(d.width), height: Number(d.height),
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
}))
} catch {
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
@@ -193,15 +177,9 @@ export const apps: AppsAPI = {
const dirs = ['/Applications', '~/Applications', '/System/Applications']
const allApps: InstalledApp[] = []
for (const dir of dirs) {
const expanded = dir.startsWith('~')
? join(process.env.HOME ?? '~', dir.slice(1))
: dir
const expanded = dir.startsWith('~') ? join(process.env.HOME ?? '~', dir.slice(1)) : dir
const proc = Bun.spawn(
[
'bash',
'-c',
`for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`,
],
['bash', '-c', `for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`],
{ stdout: 'pipe', stderr: 'pipe' },
)
const text = await new Response(proc.stdout).text()
@@ -267,13 +245,10 @@ export const apps: AppsAPI = {
// ScreenshotAPI
// ---------------------------------------------------------------------------
async function captureScreenToBase64(
args: string[],
): Promise<{ base64: string; width: number; height: number }> {
async function captureScreenToBase64(args: string[]): Promise<{ base64: string; width: number; height: number }> {
const tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`)
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
stdout: 'pipe',
stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe',
})
await proc.exited
try {
@@ -283,36 +258,18 @@ async function captureScreenToBase64(
const height = buf.readUInt32BE(20)
return { base64, width, height }
} finally {
try {
unlinkSync(tmpFile)
} catch {}
try { unlinkSync(tmpFile) } catch {}
}
}
export const screenshot: ScreenshotAPI = {
async captureExcluding(
_allowedBundleIds,
_quality,
_targetW,
_targetH,
displayId,
) {
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
const args = ['-x']
if (displayId !== undefined) args.push('-D', String(displayId))
return captureScreenToBase64(args)
},
async captureRegion(
_allowedBundleIds,
x,
y,
w,
h,
_outW,
_outH,
_quality,
displayId,
) {
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, displayId) {
const args = ['-x', '-R', `${x},${y},${w},${h}`]
if (displayId !== undefined) args.push('-D', String(displayId))
return captureScreenToBase64(args)

View File

@@ -8,17 +8,9 @@
*/
import type {
AppInfo,
AppsAPI,
DisplayAPI,
DisplayGeometry,
InstalledApp,
PrepareDisplayResult,
RunningApp,
ScreenshotAPI,
ScreenshotResult,
SwiftBackend,
WindowDisplayInfo,
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
SwiftBackend, WindowDisplayInfo,
} from '../types.js'
// ---------------------------------------------------------------------------
@@ -42,11 +34,7 @@ async function runAsync(cmd: string[]): Promise<string> {
}
function commandExists(name: string): boolean {
const result = Bun.spawnSync({
cmd: ['which', name],
stdout: 'pipe',
stderr: 'pipe',
})
const result = Bun.spawnSync({ cmd: ['which', name], stdout: 'pipe', stderr: 'pipe' })
return result.exitCode === 0
}
@@ -97,11 +85,7 @@ export const display: DisplayAPI = {
// ---------------------------------------------------------------------------
export const apps: AppsAPI = {
async prepareDisplay(
_allowlistBundleIds,
_surrogateHost,
_displayId,
): Promise<PrepareDisplayResult> {
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId): Promise<PrepareDisplayResult> {
return { activated: '', hidden: [] }
},
@@ -116,15 +100,7 @@ export const apps: AppsAPI = {
async appUnderPoint(x, y): Promise<AppInfo | null> {
try {
// Move mouse to point, get window under cursor
const out = run([
'xdotool',
'mousemove',
'--sync',
String(x),
String(y),
'getmouselocation',
'--shell',
])
const out = run(['xdotool', 'mousemove', '--sync', String(x), String(y), 'getmouselocation', '--shell'])
const windowMatch = out.match(/WINDOW=(\d+)/)
if (!windowMatch) return null
@@ -133,18 +109,10 @@ export const apps: AppsAPI = {
if (!pidStr) return null
let exePath = ''
try {
exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`])
} catch {
/* ignore */
}
try { exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`]) } catch { /* ignore */ }
let appName = ''
try {
appName = run(['cat', `/proc/${pidStr}/comm`])
} catch {
/* ignore */
}
try { appName = run(['cat', `/proc/${pidStr}/comm`]) } catch { /* ignore */ }
if (!exePath && !appName) return null
return { bundleId: exePath || pidStr!, displayName: appName || 'unknown' }
@@ -156,20 +124,14 @@ export const apps: AppsAPI = {
async listInstalled(): Promise<InstalledApp[]> {
try {
// Read .desktop files from standard locations
const dirs = [
'/usr/share/applications',
'/usr/local/share/applications',
`${process.env.HOME}/.local/share/applications`,
]
const dirs = ['/usr/share/applications', '/usr/local/share/applications', `${process.env.HOME}/.local/share/applications`]
const apps: InstalledApp[] = []
for (const dir of dirs) {
let files: string
try {
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
} catch {
continue
}
} catch { continue }
for (const filepath of files.split('\n').filter(Boolean)) {
try {
@@ -184,14 +146,11 @@ export const apps: AppsAPI = {
if (!name) continue
apps.push({
bundleId:
filepath.split('/').pop()?.replace('.desktop', '') ?? '',
bundleId: filepath.split('/').pop()?.replace('.desktop', '') ?? '',
displayName: name,
path: exec.split(/\s+/)[0] ?? '',
})
} catch {
/* skip unreadable files */
}
} catch { /* skip unreadable files */ }
}
}
@@ -218,17 +177,9 @@ export const apps: AppsAPI = {
if (!pid || pid === '0') continue
let exePath = ''
try {
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
} catch {
/* ignore */
}
try { exePath = run(['readlink', '-f', `/proc/${pid}/exe`]) } catch { /* ignore */ }
let appName = ''
try {
appName = run(['cat', `/proc/${pid}/comm`])
} catch {
/* ignore */
}
try { appName = run(['cat', `/proc/${pid}/comm`]) } catch { /* ignore */ }
if (appName) {
apps.push({ bundleId: exePath || pid, displayName: appName })
@@ -236,13 +187,11 @@ export const apps: AppsAPI = {
}
// Deduplicate by bundleId
const seen = new Set<string>()
return apps
.filter(a => {
if (seen.has(a.bundleId)) return false
seen.add(a.bundleId)
return true
})
.slice(0, 50)
return apps.filter(a => {
if (seen.has(a.bundleId)) return false
seen.add(a.bundleId)
return true
}).slice(0, 50)
}
// Fallback: ps with visible processes
@@ -268,9 +217,7 @@ export const apps: AppsAPI = {
await runAsync(['gtk-launch', desktopName])
return
}
} catch {
/* fall through */
}
} catch { /* fall through */ }
await runAsync(['xdg-open', name])
},
@@ -285,9 +232,7 @@ export const apps: AppsAPI = {
// Try xdotool windowactivate with search by name
await runAsync(['xdotool', 'search', '--name', id, 'windowactivate'])
}
} catch {
/* ignore failures for individual windows */
}
} catch { /* ignore failures for individual windows */ }
}
},
}
@@ -299,13 +244,7 @@ export const apps: AppsAPI = {
const SCREENSHOT_PATH = '/tmp/cu-screenshot.png'
export const screenshot: ScreenshotAPI = {
async captureExcluding(
_allowedBundleIds,
_quality,
_targetW,
_targetH,
_displayId,
): Promise<ScreenshotResult> {
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, _displayId): Promise<ScreenshotResult> {
try {
await runAsync(['scrot', '-o', SCREENSHOT_PATH])
@@ -322,26 +261,10 @@ export const screenshot: ScreenshotAPI = {
}
},
async captureRegion(
_allowedBundleIds,
x,
y,
w,
h,
_outW,
_outH,
_quality,
_displayId,
): Promise<ScreenshotResult> {
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId): Promise<ScreenshotResult> {
try {
// scrot -a x,y,w,h captures a specific region
await runAsync([
'scrot',
'-a',
`${x},${y},${w},${h}`,
'-o',
SCREENSHOT_PATH,
])
await runAsync(['scrot', '-a', `${x},${y},${w},${h}`, '-o', SCREENSHOT_PATH])
const file = Bun.file(SCREENSHOT_PATH)
const buffer = await file.arrayBuffer()

View File

@@ -6,24 +6,13 @@
*/
import type {
AppInfo,
AppsAPI,
DisplayAPI,
DisplayGeometry,
InstalledApp,
PrepareDisplayResult,
RunningApp,
ScreenshotAPI,
ScreenshotResult,
SwiftBackend,
WindowDisplayInfo,
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
SwiftBackend, WindowDisplayInfo,
} from '../types.js'
import { listWindows } from 'src/utils/computerUse/win32/windowEnum.js'
import {
captureWindow,
captureWindowByHwnd,
} from 'src/utils/computerUse/win32/windowCapture.js'
import { captureWindow, captureWindowByHwnd } from 'src/utils/computerUse/win32/windowCapture.js'
// ---------------------------------------------------------------------------
// PowerShell helper
@@ -74,18 +63,15 @@ foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
}
$result -join "|"
`)
return raw
.split('|')
.filter(Boolean)
.map(entry => {
const [w, h, id, primary] = entry.split(',')
return {
width: Number(w),
height: Number(h),
scaleFactor: 1, // Windows DPI scaling handled at system level
displayId: Number(id),
}
})
return raw.split('|').filter(Boolean).map(entry => {
const [w, h, id, primary] = entry.split(',')
return {
width: Number(w),
height: Number(h),
scaleFactor: 1, // Windows DPI scaling handled at system level
displayId: Number(id),
}
})
} catch {
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
}
@@ -153,17 +139,14 @@ foreach ($p in $paths) {
}
$apps | Select-Object -Unique | Select-Object -First 200
`)
return raw
.split('\n')
.filter(Boolean)
.map(line => {
const [name, path, id] = line.split('|', 3)
return {
bundleId: id ?? name ?? '',
displayName: name ?? '',
path: path ?? '',
}
})
return raw.split('\n').filter(Boolean).map(line => {
const [name, path, id] = line.split('|', 3)
return {
bundleId: id ?? name ?? '',
displayName: name ?? '',
path: path ?? '',
}
})
} catch {
return []
}
@@ -221,13 +204,7 @@ if ($proc) { [WinShow]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null; [WinSh
// ---------------------------------------------------------------------------
export const screenshot: ScreenshotAPI = {
async captureExcluding(
_allowedBundleIds,
_quality,
_targetW,
_targetH,
displayId,
) {
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
const raw = await psAsync(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
@@ -252,17 +229,7 @@ $ms.Dispose()
return { base64, width, height }
},
async captureRegion(
_allowedBundleIds,
x,
y,
w,
h,
_outW,
_outH,
_quality,
_displayId,
) {
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId) {
const raw = await psAsync(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

View File

@@ -37,52 +37,25 @@ const backend = loadBackend()
export class ComputerUseAPI {
apps = backend?.apps ?? {
async prepareDisplay() {
return { activated: '', hidden: [] }
},
async previewHideSet() {
return []
},
async findWindowDisplays(ids: string[]) {
return ids.map((b: string) => ({
bundleId: b,
displayIds: [] as number[],
}))
},
async appUnderPoint() {
return null
},
async listInstalled() {
return []
},
iconDataUrl() {
return null
},
listRunning() {
return []
},
async open() {
throw new Error('@ant/computer-use-swift: macOS only')
},
async prepareDisplay() { return { activated: '', hidden: [] } },
async previewHideSet() { return [] },
async findWindowDisplays(ids: string[]) { return ids.map((b: string) => ({ bundleId: b, displayIds: [] as number[] })) },
async appUnderPoint() { return null },
async listInstalled() { return [] },
iconDataUrl() { return null },
listRunning() { return [] },
async open() { throw new Error('@ant/computer-use-swift: macOS only') },
async unhide() {},
}
display = backend?.display ?? {
getSize() {
throw new Error('@ant/computer-use-swift: macOS only')
},
listAll() {
throw new Error('@ant/computer-use-swift: macOS only')
},
getSize() { throw new Error('@ant/computer-use-swift: macOS only') },
listAll() { throw new Error('@ant/computer-use-swift: macOS only') },
}
screenshot = backend?.screenshot ?? {
async captureExcluding() {
throw new Error('@ant/computer-use-swift: macOS only')
},
async captureRegion() {
throw new Error('@ant/computer-use-swift: macOS only')
},
async captureExcluding() { throw new Error('@ant/computer-use-swift: macOS only') },
async captureRegion() { throw new Error('@ant/computer-use-swift: macOS only') },
}
async resolvePrepareCapture(
@@ -93,12 +66,6 @@ export class ComputerUseAPI {
targetH: number,
displayId?: number,
): Promise<ResolvePrepareCaptureResult> {
return this.screenshot.captureExcluding(
allowedBundleIds,
quality,
targetW,
targetH,
displayId,
)
return this.screenshot.captureExcluding(allowedBundleIds, quality, targetW, targetH, displayId)
}
}

View File

@@ -55,11 +55,7 @@ export interface DisplayAPI {
}
export interface AppsAPI {
prepareDisplay(
allowlistBundleIds: string[],
surrogateHost: string,
displayId?: number,
): Promise<PrepareDisplayResult>
prepareDisplay(allowlistBundleIds: string[], surrogateHost: string, displayId?: number): Promise<PrepareDisplayResult>
previewHideSet(bundleIds: string[], displayId?: number): Promise<AppInfo[]>
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
@@ -72,22 +68,13 @@ export interface AppsAPI {
export interface ScreenshotAPI {
captureExcluding(
allowedBundleIds: string[],
quality: number,
targetW: number,
targetH: number,
displayId?: number,
allowedBundleIds: string[], quality: number,
targetW: number, targetH: number, displayId?: number,
): Promise<ScreenshotResult>
captureRegion(
allowedBundleIds: string[],
x: number,
y: number,
w: number,
h: number,
outW: number,
outH: number,
quality: number,
displayId?: number,
x: number, y: number, w: number, h: number,
outW: number, outH: number, quality: number, displayId?: number,
): Promise<ScreenshotResult>
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
}

View File

@@ -1,19 +1,23 @@
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react';
import instances from '../core/instances.js';
import React, {
type PropsWithChildren,
useContext,
useInsertionEffect,
} from 'react'
import instances from '../core/instances.js'
import {
DISABLE_MOUSE_TRACKING,
ENABLE_MOUSE_TRACKING,
ENTER_ALT_SCREEN,
EXIT_ALT_SCREEN,
} from '../core/termio/dec.js';
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js';
import Box from './Box.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
} from '../core/termio/dec.js'
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js'
import Box from './Box.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
type Props = PropsWithChildren<{
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
mouseTracking?: boolean;
}>;
mouseTracking?: boolean
}>
/**
* Run children in the terminal's alternate screen buffer, constrained to
@@ -35,9 +39,12 @@ type Props = PropsWithChildren<{
* from scrolling content) and so signal-exit cleanup can exit the alt
* screen if the component's own unmount doesn't run.
*/
export function AlternateScreen({ children, mouseTracking = true }: Props): React.ReactNode {
const size = useContext(TerminalSizeContext);
const writeRaw = useContext(TerminalWriteContext);
export function AlternateScreen({
children,
mouseTracking = true,
}: Props): React.ReactNode {
const size = useContext(TerminalSizeContext)
const writeRaw = useContext(TerminalWriteContext)
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
// resetAfterCommit between the mutation and layout commit phases, and
@@ -50,22 +57,31 @@ export function AlternateScreen({ children, mouseTracking = true }: Props): Reac
// Cleanup timing is unchanged: both insertion and layout effect cleanup
// run in the mutation phase on unmount, before resetAfterCommit.
useInsertionEffect(() => {
const ink = instances.get(process.stdout);
if (!writeRaw) return;
const ink = instances.get(process.stdout)
if (!writeRaw) return
writeRaw(ENTER_ALT_SCREEN + '\x1b[2J\x1b[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''));
ink?.setAltScreenActive(true, mouseTracking);
writeRaw(
ENTER_ALT_SCREEN +
'\x1b[2J\x1b[H' +
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
)
ink?.setAltScreenActive(true, mouseTracking)
return () => {
ink?.setAltScreenActive(false);
ink?.clearTextSelection();
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN);
};
}, [writeRaw, mouseTracking]);
ink?.setAltScreenActive(false)
ink?.clearTextSelection()
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
}
}, [writeRaw, mouseTracking])
return (
<Box flexDirection="column" height={size?.rows ?? 24} width="100%" flexShrink={0}>
<Box
flexDirection="column"
height={size?.rows ?? 24}
width="100%"
flexShrink={0}
>
{children}
</Box>
);
)
}

View File

@@ -1,14 +1,14 @@
import React, { PureComponent, type ReactNode } from 'react';
import React, { PureComponent, type ReactNode } from 'react'
// Business-layer callbacks — replaced with inline defaults so this package
// has zero dependencies on business code. The business layer can inject
// implementations via AppCallbacks when needed.
type AppCallbacks = {
updateLastInteractionTime?: () => void;
stopCapturingEarlyInput?: () => void;
isMouseClicksDisabled?: () => boolean;
logError?: (error: unknown) => void;
logForDebugging?: (message: string, opts?: { level?: string }) => void;
};
updateLastInteractionTime?: () => void
stopCapturingEarlyInput?: () => void
isMouseClicksDisabled?: () => boolean
logError?: (error: unknown) => void
logForDebugging?: (message: string, opts?: { level?: string }) => void
}
/** Default no-op / safe-default implementations */
const defaultCallbacks: Required<AppCallbacks> = {
@@ -17,34 +17,46 @@ const defaultCallbacks: Required<AppCallbacks> = {
isMouseClicksDisabled: () => false,
logError: (error: unknown) => console.error(error),
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
};
}
/**
* Override the default no-op callbacks. Call this from the business layer
* (e.g. src/ink.tsx) before mounting <App>.
*/
export function setAppCallbacks(cb: AppCallbacks): void {
Object.assign(defaultCallbacks, cb);
Object.assign(defaultCallbacks, cb)
}
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true';
return value === '1' || value === 'true'
}
import { EventEmitter } from '../core/events/emitter.js';
import { InputEvent } from '../core/events/input-event.js';
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js';
import { EventEmitter } from '../core/events/emitter.js'
import { InputEvent } from '../core/events/input-event.js'
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js'
import {
INITIAL_STATE,
type ParsedInput,
type ParsedKey,
type ParsedMouse,
parseMultipleKeypresses,
} from '../core/parse-keypress.js';
import reconciler from '../core/reconciler.js';
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
import { getTerminalFocused, setTerminalFocused } from '../core/terminal-focus-state.js';
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js';
} from '../core/parse-keypress.js'
import reconciler from '../core/reconciler.js'
import {
finishSelection,
hasSelection,
type SelectionState,
startSelection,
} from '../core/selection.js'
import {
isXtermJs,
setXtversionName,
supportsExtendedKeys,
} from '../core/terminal.js'
import {
getTerminalFocused,
setTerminalFocused,
} from '../core/terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
@@ -52,145 +64,155 @@ import {
ENABLE_MODIFY_OTHER_KEYS,
FOCUS_IN,
FOCUS_OUT,
} from '../core/termio/csi.js';
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../core/termio/dec.js';
import AppContext from './AppContext.js';
import { ClockProvider } from './ClockContext.js';
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
import ErrorOverview from './ErrorOverview.js';
import StdinContext from './StdinContext.js';
import { TerminalFocusProvider } from './TerminalFocusContext.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
} from '../core/termio/csi.js'
import {
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EBP,
EFE,
HIDE_CURSOR,
SHOW_CURSOR,
} from '../core/termio/dec.js'
import AppContext from './AppContext.js'
import { ClockProvider } from './ClockContext.js'
import CursorDeclarationContext, {
type CursorDeclarationSetter,
} from './CursorDeclarationContext.js'
import ErrorOverview from './ErrorOverview.js'
import StdinContext from './StdinContext.js'
import { TerminalFocusProvider } from './TerminalFocusContext.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
// Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT)
const SUPPORTS_SUSPEND = process.platform !== 'win32';
const SUPPORTS_SUSPEND = process.platform !== 'win32'
// After this many milliseconds of stdin silence, the next chunk triggers
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
// ssh reconnect, and laptop wake — the terminal resets DEC private modes
// but no signal reaches us. 5s is well above normal inter-keystroke gaps
// but short enough that the first scroll after reattach works.
const STDIN_RESUME_GAP_MS = 5000;
const STDIN_RESUME_GAP_MS = 5000
type Props = {
readonly children: ReactNode;
readonly stdin: NodeJS.ReadStream;
readonly stdout: NodeJS.WriteStream;
readonly stderr: NodeJS.WriteStream;
readonly exitOnCtrlC: boolean;
readonly onExit: (error?: Error) => void;
readonly terminalColumns: number;
readonly terminalRows: number;
readonly children: ReactNode
readonly stdin: NodeJS.ReadStream
readonly stdout: NodeJS.WriteStream
readonly stderr: NodeJS.WriteStream
readonly exitOnCtrlC: boolean
readonly onExit: (error?: Error) => void
readonly terminalColumns: number
readonly terminalRows: number
// Text selection state. App mutates this directly from mouse events
// and calls onSelectionChange to trigger a repaint. Mouse events only
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
// so the handler is always wired but dormant until tracking is on.
readonly selection: SelectionState;
readonly onSelectionChange: () => void;
readonly selection: SelectionState
readonly onSelectionChange: () => void
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
// onClick handlers. Returns true if a DOM handler consumed the click.
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
// gates on altScreenActive).
readonly onClickAt: (col: number, row: number) => boolean;
readonly onClickAt: (col: number, row: number) => boolean
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void;
readonly onHoverAt: (col: number, row: number) => void
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
readonly getHyperlinkAt: (col: number, row: number) => string | undefined;
readonly getHyperlinkAt: (col: number, row: number) => string | undefined
// Open a hyperlink URL in the browser. Called after the timer fires.
readonly onOpenHyperlink: (url: string) => void;
readonly onOpenHyperlink: (url: string) => void
// Called on double/triple-click PRESS at (col, row). count=2 selects
// the word under the cursor; count=3 selects the line. Ink reads the
// screen buffer to find word/line boundaries and mutates selection,
// setting isDragging=true so a subsequent drag extends by word/line.
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void;
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void
// Called on drag-motion. Mode-aware: char mode updates focus to the
// exact cell; word/line mode snaps to word/line boundaries. Needs
// screen-buffer access (word boundaries) so lives on Ink, not here.
readonly onSelectionDrag: (col: number, row: number) => void;
readonly onSelectionDrag: (col: number, row: number) => void
// Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
// Ink re-asserts terminal modes: extended key reporting, and (when in
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
// terminal side. Optional so testing.tsx doesn't need to stub it.
readonly onStdinResume?: () => void;
readonly onStdinResume?: () => void
// Receives the declared native-cursor position from useDeclaredCursor
// so ink.tsx can park the terminal cursor there after each frame.
// Enables IME composition at the input caret and lets screen readers /
// magnifiers track the input. Optional so testing.tsx doesn't stub it.
readonly onCursorDeclaration?: CursorDeclarationSetter;
readonly onCursorDeclaration?: CursorDeclarationSetter
// Dispatch a keyboard event through the DOM tree. Called for each
// parsed key alongside the legacy EventEmitter path.
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void;
};
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void
}
// Multi-click detection thresholds. 500ms is the macOS default; a small
// position tolerance allows for trackpad jitter between clicks.
const MULTI_CLICK_TIMEOUT_MS = 500;
const MULTI_CLICK_DISTANCE = 1;
const MULTI_CLICK_TIMEOUT_MS = 500
const MULTI_CLICK_DISTANCE = 1
type State = {
readonly error?: Error;
};
readonly error?: Error
}
// Root component for all Ink apps
// It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp';
static displayName = 'InternalApp'
static getDerivedStateFromError(error: Error) {
return { error };
return { error }
}
override state = {
error: undefined,
};
}
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0;
rawModeEnabledCount = 0
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE;
internal_eventEmitter = new EventEmitter()
keyParseState = INITIAL_STATE
// Timer for flushing incomplete escape sequences
incompleteEscapeTimer: NodeJS.Timeout | null = null;
incompleteEscapeTimer: NodeJS.Timeout | null = null
// Timeout durations for incomplete sequences (ms)
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
// Terminal query/response dispatch. Responses arrive on stdin (parsed
// out by parse-keypress) and are routed to pending promise resolvers.
querier = new TerminalQuerier(this.props.stdout);
querier = new TerminalQuerier(this.props.stdout)
// Multi-click tracking for double/triple-click text selection. A click
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
// click increments clickCount; otherwise it resets to 1.
lastClickTime = 0;
lastClickCol = -1;
lastClickRow = -1;
clickCount = 0;
lastClickTime = 0
lastClickCol = -1
lastClickRow = -1
clickCount = 0
// Deferred hyperlink-open timer — cancelled if a second click arrives
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
// the word without also opening the browser). DOM onClick dispatch is
// NOT deferred — it returns true from onClickAt and skips this timer.
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null;
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null
// Last mode-1003 motion position. Terminals already dedupe to cell
// granularity but this also lets us skip dispatchHover entirely on
// repeat events (drag-then-release at same cell, etc.).
lastHoverCol = -1;
lastHoverRow = -1;
lastHoverCol = -1
lastHoverRow = -1
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
// Initialized to now so startup doesn't false-trigger.
lastStdinTime = Date.now();
lastStdinTime = Date.now()
// Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean {
return this.props.stdin.isTTY;
return this.props.stdin.isTTY
}
override render() {
@@ -220,47 +242,56 @@ export default class App extends PureComponent<Props, State> {
>
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
<CursorDeclarationContext.Provider
value={this.props.onCursorDeclaration ?? (() => {})}
>
{this.state.error ? (
<ErrorOverview error={this.state.error as Error} />
) : (
this.props.children
)}
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>
</StdinContext.Provider>
</AppContext.Provider>
</TerminalSizeContext.Provider>
);
)
}
override componentDidMount() {
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
if (
this.props.stdout.isTTY &&
!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
) {
this.props.stdout.write(HIDE_CURSOR)
}
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR);
this.props.stdout.write(SHOW_CURSOR)
}
// Clear any pending timers
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
this.incompleteEscapeTimer = null;
clearTimeout(this.incompleteEscapeTimer)
this.incompleteEscapeTimer = null
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer);
this.pendingHyperlinkTimer = null;
clearTimeout(this.pendingHyperlinkTimer)
this.pendingHyperlinkTimer = null
}
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
this.handleSetRawMode(false)
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref();
this.props.stdin.unref()
} catch {
// stdin may already be destroyed
}
@@ -268,25 +299,25 @@ export default class App extends PureComponent<Props, State> {
}
override componentDidCatch(error: Error) {
this.handleExit(error);
this.handleExit(error)
}
handleSetRawMode = (isEnabled: boolean): void => {
const { stdin } = this.props;
const { stdin } = this.props
if (!this.isRawModeSupported()) {
if (stdin === process.stdin) {
throw new Error(
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
);
)
} else {
throw new Error(
'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
);
)
}
}
stdin.setEncoding('utf8');
stdin.setEncoding('utf8')
if (isEnabled) {
// Ensure raw mode is enabled only once
@@ -295,34 +326,34 @@ export default class App extends PureComponent<Props, State> {
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- the early capture handler would drain stdin before ours
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
defaultCallbacks.stopCapturingEarlyInput();
defaultCallbacks.stopCapturingEarlyInput()
// Safety net: remove any pre-existing readable listeners that aren't
// ours. In builds where setAppCallbacks() was never called, the early
// input capture's readableHandler remains attached and would consume
// all stdin data before our handleReadable sees it.
const existingListeners = stdin.listeners('readable');
const existingListeners = stdin.listeners('readable')
for (const listener of existingListeners) {
if (listener !== this.handleReadable) {
stdin.removeListener('readable', listener as any);
stdin.removeListener('readable', listener as any)
}
}
stdin.ref();
stdin.setRawMode(true);
stdin.addListener('readable', this.handleReadable);
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
// Enable bracketed paste mode
this.props.stdout.write(EBP);
this.props.stdout.write(EBP)
// Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE);
this.props.stdout.write(EFE)
// Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the
// latter).
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
@@ -333,19 +364,22 @@ export default class App extends PureComponent<Props, State> {
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
void Promise.all([
this.querier.send(xtversion()),
this.querier.flush(),
]).then(([r]) => {
if (r) {
setXtversionName(r.name);
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
setXtversionName(r.name)
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
});
});
})
})
}
this.rawModeEnabledCount++;
return;
this.rawModeEnabledCount++
return
}
// Disable raw mode only when no components left that are using it
@@ -355,31 +389,31 @@ export default class App extends PureComponent<Props, State> {
// If the old tree had more useInput hooks than the new tree, the old
// cleanup over-decrements the count to 0 even though the new tree has
// active listeners. Detect this and fix the count instead of disabling.
const activeListeners = this.internal_eventEmitter.listenerCount('input');
const activeListeners = this.internal_eventEmitter.listenerCount('input')
if (activeListeners > 0) {
this.rawModeEnabledCount = activeListeners;
return;
this.rawModeEnabledCount = activeListeners
return
}
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE);
this.props.stdout.write(DFE)
// Disable bracketed paste mode
this.props.stdout.write(DBP);
stdin.setRawMode(false);
stdin.removeListener('readable', this.handleReadable);
stdin.unref();
this.props.stdout.write(DBP)
stdin.setRawMode(false)
stdin.removeListener('readable', this.handleReadable)
stdin.unref()
}
};
}
// Helper to flush incomplete escape sequences
flushIncomplete = (): void => {
// Clear the timer reference
this.incompleteEscapeTimer = null;
this.incompleteEscapeTimer = null
// Only proceed if we have incomplete sequences
if (!this.keyParseState.incomplete) return;
if (!this.keyParseState.incomplete) return
// Fullscreen: if stdin has data waiting, it's almost certainly the
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
@@ -390,20 +424,23 @@ export default class App extends PureComponent<Props, State> {
// drain stdin next and clear this timer. Prevents both the spurious
// Escape key and the lost scroll event.
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT);
return;
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.NORMAL_TIMEOUT,
)
return
}
// Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic
this.processInput(null);
};
this.processInput(null)
}
// Process input through the parser and handle the results
processInput = (input: string | Buffer | null): void => {
// Parse input using our state machine
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
this.keyParseState = newState;
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
this.keyParseState = newState
// Process ALL keys in a SINGLE discreteUpdates call to prevent
// "Maximum update depth exceeded" error when many keys arrive at once
@@ -411,94 +448,106 @@ export default class App extends PureComponent<Props, State> {
// This batches all state updates from handleInput and all useInput
// listeners together within one high-priority update context.
if (keys.length > 0) {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
reconciler.discreteUpdates(
processKeysInBatch,
this,
keys,
undefined,
undefined,
)
}
// If we have incomplete escape sequences, set a timer to flush them
if (this.keyParseState.incomplete) {
// Cancel any existing timer first
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
clearTimeout(this.incompleteEscapeTimer)
}
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT,
);
this.keyParseState.mode === 'IN_PASTE'
? this.PASTE_TIMEOUT
: this.NORMAL_TIMEOUT,
)
}
};
}
handleReadable = (): void => {
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
// The terminal may have reset DEC private modes; re-assert mouse
// tracking. Checked before the read loop so one Date.now() covers
// all chunks in this readable event.
const now = Date.now();
const now = Date.now()
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.();
this.props.onStdinResume?.()
}
this.lastStdinTime = now;
this.lastStdinTime = now
try {
let chunk;
let chunk
while ((chunk = this.props.stdin.read() as string | null) !== null) {
// Process the input chunk
this.processInput(chunk);
this.processInput(chunk)
}
} catch (error) {
// In Bun, an uncaught throw inside a stream 'readable' handler can
// permanently wedge the stream: data stays buffered and 'readable'
// never re-emits. Catching here ensures the stream stays healthy so
// subsequent keystrokes are still delivered.
defaultCallbacks.logError(error);
defaultCallbacks.logError(error)
// Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this,
// the session freezes permanently (stdin reader dead, event loop alive).
const { stdin } = this.props;
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
defaultCallbacks.logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
level: 'warn',
});
stdin.addListener('readable', this.handleReadable);
const { stdin } = this.props
if (
this.rawModeEnabledCount > 0 &&
!stdin.listeners('readable').includes(this.handleReadable)
) {
defaultCallbacks.logForDebugging(
'handleReadable: re-attaching stdin readable listener after error recovery',
{ level: 'warn' },
)
stdin.addListener('readable', this.handleReadable)
}
}
};
}
handleInput = (input: string | undefined): void => {
// Exit on Ctrl+C
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit();
this.handleExit()
}
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
// parsed key to support both raw (\x1a) and CSI u format from Kitty
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
};
}
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
this.handleSetRawMode(false)
}
this.props.onExit(error);
};
this.props.onExit(error)
}
handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
// and Clock (interval speed) — no App setState needed.
setTerminalFocused(isFocused);
};
setTerminalFocused(isFocused)
}
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return;
return
}
// Store the exact raw mode count to restore it properly
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
// Completely disable raw mode before suspending
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false);
this.handleSetRawMode(false)
}
// Show cursor, disable focus reporting, and disable mouse tracking
@@ -507,44 +556,49 @@ export default class App extends PureComponent<Props, State> {
// it, SGR mouse sequences would appear as garbled text at the
// shell prompt while suspended.
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING);
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING)
}
// Emit suspend event for Claude Code to handle. Mostly just has a notification
this.internal_eventEmitter.emit('suspend');
this.internal_eventEmitter.emit('suspend')
// Set up resume handler
const resumeHandler = () => {
// Restore raw mode to exact previous state
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) {
this.handleSetRawMode(true);
this.handleSetRawMode(true)
}
}
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
if (this.props.stdout.isTTY) {
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
this.props.stdout.write(HIDE_CURSOR)
}
// Re-enable focus reporting to restore terminal state
this.props.stdout.write(EFE);
this.props.stdout.write(EFE)
}
// Emit resume event for Claude Code to handle
this.internal_eventEmitter.emit('resume');
this.internal_eventEmitter.emit('resume')
process.removeListener('SIGCONT', resumeHandler);
};
process.removeListener('SIGCONT', resumeHandler)
}
process.on('SIGCONT', resumeHandler);
process.kill(process.pid, 'SIGSTOP');
};
process.on('SIGCONT', resumeHandler)
process.kill(process.pid, 'SIGSTOP')
}
}
// Helper to process all keys within a single discrete update context.
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
function processKeysInBatch(
app: App,
items: ParsedInput[],
_unused1: undefined,
_unused2: undefined,
): void {
// Update interaction time for notification timeout tracking.
// This is called from the central input handler to avoid having multiple
// stdin listeners that can cause race conditions and dropped input.
@@ -552,70 +606,75 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined,
// Mode-1003 no-button motion is also excluded — passive cursor drift is
// not engagement (would suppress idle notifications + defer housekeeping).
if (
items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)))
items.some(
i =>
i.kind === 'key' ||
(i.kind === 'mouse' &&
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
)
) {
defaultCallbacks.updateLastInteractionTime();
defaultCallbacks.updateLastInteractionTime()
}
for (const item of items) {
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
// input — route them to the querier to resolve pending promises.
if (item.kind === 'response') {
app.querier.onResponse(item.response);
continue;
app.querier.onResponse(item.response)
continue
}
// Mouse click/drag events update selection state (fullscreen only).
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
// screen buffer. Button bit 0x20 = drag (motion while button held).
if (item.kind === 'mouse') {
handleMouseEvent(app, item);
continue;
handleMouseEvent(app, item)
continue
}
const sequence = item.sequence;
const sequence = item.sequence
// Handle terminal focus events (DECSET 1004)
if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true);
const event = new TerminalFocusEvent('terminalfocus');
app.internal_eventEmitter.emit('terminalfocus', event);
continue;
app.handleTerminalFocus(true)
const event = new TerminalFocusEvent('terminalfocus')
app.internal_eventEmitter.emit('terminalfocus', event)
continue
}
if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false);
app.handleTerminalFocus(false)
// Defensive: if we lost the release event (mouse released outside
// terminal window — some emulators drop it rather than capturing the
// pointer), focus-out is the next observable signal that the drag is
// over. Without this, drag-to-scroll's timer runs until the scroll
// boundary is hit.
if (app.props.selection.isDragging) {
finishSelection(app.props.selection);
app.props.onSelectionChange();
finishSelection(app.props.selection)
app.props.onSelectionChange()
}
const event = new TerminalFocusEvent('terminalblur');
app.internal_eventEmitter.emit('terminalblur', event);
continue;
const event = new TerminalFocusEvent('terminalblur')
app.internal_eventEmitter.emit('terminalblur', event)
continue
}
// Failsafe: if we receive input, the terminal must be focused
if (!getTerminalFocused()) {
setTerminalFocused(true);
setTerminalFocused(true)
}
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend();
continue;
app.handleSuspend()
continue
}
app.handleInput(sequence);
const event = new InputEvent(item);
app.internal_eventEmitter.emit('input', event);
app.handleInput(sequence)
const event = new InputEvent(item)
app.internal_eventEmitter.emit('input', event)
// Also dispatch through the DOM tree so onKeyDown handlers fire.
app.props.dispatchKeyboardEvent(item);
app.props.dispatchKeyboardEvent(item)
}
}
@@ -623,13 +682,13 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined,
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here).
if (defaultCallbacks.isMouseClicksDisabled()) return;
if (defaultCallbacks.isMouseClicksDisabled()) return
const sel = app.props.selection;
const sel = app.props.selection
// Terminal coords are 1-indexed; screen buffer is 0-indexed
const col = m.col - 1;
const row = m.row - 1;
const baseButton = m.button & 0x03;
const col = m.col - 1
const row = m.row - 1
const baseButton = m.button & 0x03
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
@@ -643,25 +702,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// past the edge, came back" — and tmux drops focus events unless
// `focus-events on` is set, so this is the more reliable signal.
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
finishSelection(sel)
app.props.onSelectionChange()
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
app.lastHoverCol = col;
app.lastHoverRow = row;
app.props.onHoverAt(col, row);
return;
if (col === app.lastHoverCol && row === app.lastHoverRow) return
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row)
return
}
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0;
return;
app.clickCount = 0
return
}
if ((m.button & 0x20) !== 0) {
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
// calls notifySelectionChange internally — no extra onSelectionChange.
app.props.onSelectionDrag(col, row);
return;
app.props.onSelectionDrag(col, row)
return
}
// Lost-release fallback for mode-1002-only terminals: a fresh press
// while isDragging=true means the previous release was dropped (cursor
@@ -669,43 +728,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
// hit the no-button-motion recovery above instead, so this is rare.
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
finishSelection(sel)
app.props.onSelectionChange()
}
// Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can
// extend by word/line like native macOS. Previously detected on
// release, which meant (a) visible latency before the word highlights
// and (b) double-click+drag fell through to char-mode selection.
const now = Date.now();
const now = Date.now()
const nearLast =
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
app.clickCount = nearLast ? app.clickCount + 1 : 1;
app.lastClickTime = now;
app.lastClickCol = col;
app.lastClickRow = row;
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
app.clickCount = nearLast ? app.clickCount + 1 : 1
app.lastClickTime = now
app.lastClickCol = col
app.lastClickRow = row
if (app.clickCount >= 2) {
// Cancel any pending hyperlink-open from the first click — this is
// a double-click, not a single-click on a link.
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
app.pendingHyperlinkTimer = null;
clearTimeout(app.pendingHyperlinkTimer)
app.pendingHyperlinkTimer = null
}
// Cap at 3 (line select) for quadruple+ clicks.
const count = app.clickCount === 2 ? 2 : 3;
app.props.onMultiClick(col, row, count);
return;
const count = app.clickCount === 2 ? 2 : 3
app.props.onMultiClick(col, row, count)
return
}
startSelection(sel, col, row);
startSelection(sel, col, row)
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
// comment at the hyperlink-open guard below). On macOS xterm.js,
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
// xterm.js would have consumed the event for native selection).
sel.lastPressHadAlt = (m.button & 0x08) !== 0;
app.props.onSelectionChange();
return;
sel.lastPressHadAlt = (m.button & 0x08) !== 0
app.props.onSelectionChange()
return
}
// Release: end the drag even for non-zero button codes. Some terminals
@@ -715,12 +774,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// scroll boundary. Only act on non-left releases when we ARE dragging
// (so an unrelated middle/right click-release doesn't touch selection).
if (baseButton !== 0) {
if (!sel.isDragging) return;
finishSelection(sel);
app.props.onSelectionChange();
return;
if (!sel.isDragging) return
finishSelection(sel)
app.props.onSelectionChange()
return
}
finishSelection(sel);
finishSelection(sel)
// NOTE: unlike the old release-based detection we do NOT reset clickCount
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
// an intervening drag doesn't break the click chain. Practical upside:
@@ -741,7 +800,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Resolve the hyperlink URL synchronously while the screen buffer
// still reflects what the user clicked — deferring only the
// browser-open so double-click can cancel it.
const url = app.props.getHyperlinkAt(col, row);
const url = app.props.getHyperlinkAt(col, row)
// xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
// handler that fires on Cmd+click *without consuming the mouse event*
// (Linkifier._handleMouseUp calls link.activate() but never
@@ -757,19 +816,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Clear any prior pending timer — clicking a second link
// supersedes the first (only the latest click opens).
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
clearTimeout(app.pendingHyperlinkTimer)
}
app.pendingHyperlinkTimer = setTimeout(
(app, url) => {
app.pendingHyperlinkTimer = null;
app.props.onOpenHyperlink(url);
app.pendingHyperlinkTimer = null
app.props.onOpenHyperlink(url)
},
MULTI_CLICK_TIMEOUT_MS,
app,
url,
);
)
}
}
}
app.props.onSelectionChange();
app.props.onSelectionChange()
}

View File

@@ -1,48 +1,48 @@
import React, { type PropsWithChildren, type Ref } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../core/dom.js';
import type { ClickEvent } from '../core/events/click-event.js';
import type { FocusEvent } from '../core/events/focus-event.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import type { Styles } from '../core/styles.js';
import * as warn from '../core/warn.js';
import React, { type PropsWithChildren, type Ref } from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import * as warn from '../core/warn.js'
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
ref?: Ref<DOMElement>
/**
* Tab order index. Nodes with `tabIndex >= 0` participate in
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
*/
tabIndex?: number;
tabIndex?: number
/**
* Focus this element when it mounts. Like the HTML `autofocus`
* attribute — the FocusManager calls `focus(node)` during the
* reconciler's `commitMount` phase.
*/
autoFocus?: boolean;
autoFocus?: boolean
/**
* Fired on left-button click (press + release without drag). Only works
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
* otherwise. The event bubbles from the deepest hit Box up through
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
*/
onClick?: (event: ClickEvent) => void;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void
/**
* Fired when the mouse moves into this Box's rendered rect. Like DOM
* `mouseenter`, does NOT bubble — moving between children does not
* re-fire on the parent. Only works inside `<AlternateScreen>` where
* mode-1003 mouse tracking is enabled.
*/
onMouseEnter?: () => void;
onMouseEnter?: () => void
/** Fired when the mouse moves out of this Box's rendered rect. */
onMouseLeave?: () => void;
};
onMouseLeave?: () => void
}
/**
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
@@ -68,23 +68,23 @@ function Box({
...style
}: PropsWithChildren<Props>): React.ReactNode {
// Warn if spacing values are not integers to prevent fractional layout dimensions
warn.ifNotInteger(style.margin, 'margin');
warn.ifNotInteger(style.marginX, 'marginX');
warn.ifNotInteger(style.marginY, 'marginY');
warn.ifNotInteger(style.marginTop, 'marginTop');
warn.ifNotInteger(style.marginBottom, 'marginBottom');
warn.ifNotInteger(style.marginLeft, 'marginLeft');
warn.ifNotInteger(style.marginRight, 'marginRight');
warn.ifNotInteger(style.padding, 'padding');
warn.ifNotInteger(style.paddingX, 'paddingX');
warn.ifNotInteger(style.paddingY, 'paddingY');
warn.ifNotInteger(style.paddingTop, 'paddingTop');
warn.ifNotInteger(style.paddingBottom, 'paddingBottom');
warn.ifNotInteger(style.paddingLeft, 'paddingLeft');
warn.ifNotInteger(style.paddingRight, 'paddingRight');
warn.ifNotInteger(style.gap, 'gap');
warn.ifNotInteger(style.columnGap, 'columnGap');
warn.ifNotInteger(style.rowGap, 'rowGap');
warn.ifNotInteger(style.margin, 'margin')
warn.ifNotInteger(style.marginX, 'marginX')
warn.ifNotInteger(style.marginY, 'marginY')
warn.ifNotInteger(style.marginTop, 'marginTop')
warn.ifNotInteger(style.marginBottom, 'marginBottom')
warn.ifNotInteger(style.marginLeft, 'marginLeft')
warn.ifNotInteger(style.marginRight, 'marginRight')
warn.ifNotInteger(style.padding, 'padding')
warn.ifNotInteger(style.paddingX, 'paddingX')
warn.ifNotInteger(style.paddingY, 'paddingY')
warn.ifNotInteger(style.paddingTop, 'paddingTop')
warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
warn.ifNotInteger(style.paddingRight, 'paddingRight')
warn.ifNotInteger(style.gap, 'gap')
warn.ifNotInteger(style.columnGap, 'columnGap')
warn.ifNotInteger(style.rowGap, 'rowGap')
return (
<ink-box
@@ -112,7 +112,7 @@ function Box({
>
{children}
</ink-box>
);
)
}
export default Box;
export default Box

View File

@@ -1,33 +1,39 @@
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../core/dom.js';
import type { ClickEvent } from '../core/events/click-event.js';
import type { FocusEvent } from '../core/events/focus-event.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import type { Styles } from '../core/styles.js';
import Box from './Box.js';
import React, {
type Ref,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
type ButtonState = {
focused: boolean;
hovered: boolean;
active: boolean;
};
focused: boolean
hovered: boolean
active: boolean
}
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
ref?: Ref<DOMElement>
/**
* Called when the button is activated via Enter, Space, or click.
*/
onAction: () => void;
onAction: () => void
/**
* Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only.
*/
tabIndex?: number;
tabIndex?: number
/**
* Focus this button when it mounts.
*/
autoFocus?: boolean;
autoFocus?: boolean
/**
* Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself
@@ -35,53 +41,64 @@ export type Props = Except<Styles, 'textWrap'> & {
*
* If not provided, children render as-is (no state-dependent styling).
*/
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
};
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
function Button({ onAction, tabIndex = 0, autoFocus, children, ref, ...style }: Props): React.ReactNode {
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isActive, setIsActive] = useState(false);
function Button({
onAction,
tabIndex = 0,
autoFocus,
children,
ref,
...style
}: Props): React.ReactNode {
const [isFocused, setIsFocused] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (activeTimer.current) clearTimeout(activeTimer.current);
};
}, []);
if (activeTimer.current) clearTimeout(activeTimer.current)
}
}, [])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'return' || e.key === ' ') {
e.preventDefault();
setIsActive(true);
onAction();
if (activeTimer.current) clearTimeout(activeTimer.current);
activeTimer.current = setTimeout(setter => setter(false), 100, setIsActive);
e.preventDefault()
setIsActive(true)
onAction()
if (activeTimer.current) clearTimeout(activeTimer.current)
activeTimer.current = setTimeout(
setter => setter(false),
100,
setIsActive,
)
}
},
[onAction],
);
)
const handleClick = useCallback(
(_e: ClickEvent) => {
onAction();
onAction()
},
[onAction],
);
)
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []);
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []);
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
const state: ButtonState = {
focused: isFocused,
hovered: isHovered,
active: isActive,
};
const content = typeof children === 'function' ? children(state) : children;
}
const content = typeof children === 'function' ? children(state) : children
return (
<Box
@@ -98,8 +115,8 @@ function Button({ onAction, tabIndex = 0, autoFocus, children, ref, ...style }:
>
{content}
</Box>
);
)
}
export default Button;
export type { ButtonState };
export default Button
export type { ButtonState }

View File

@@ -1,93 +1,99 @@
import React, { createContext, useEffect, useState } from 'react';
import { FRAME_INTERVAL_MS } from '../core/constants.js';
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
import React, { createContext, useEffect, useState } from 'react'
import { FRAME_INTERVAL_MS } from '../core/constants.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
export type Clock = {
subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
now: () => number;
setTickInterval: (ms: number) => void;
};
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
now: () => number
setTickInterval: (ms: number) => void
}
export function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>();
let interval: ReturnType<typeof setInterval> | null = null;
let currentTickIntervalMs = tickIntervalMs;
let startTime = 0;
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null
let currentTickIntervalMs = tickIntervalMs
let startTime = 0
// Snapshot of the current tick's time, ensuring all subscribers in the same
// tick see the same value (keeps animations synchronized)
let tickTime = 0;
let tickTime = 0
function tick(): void {
tickTime = Date.now() - startTime;
tickTime = Date.now() - startTime
for (const onChange of subscribers.keys()) {
onChange();
onChange()
}
}
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean);
const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) {
if (interval) {
clearInterval(interval);
interval = null;
clearInterval(interval)
interval = null
}
if (startTime === 0) {
startTime = Date.now();
startTime = Date.now()
}
interval = setInterval(tick, currentTickIntervalMs);
interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
clearInterval(interval);
interval = null;
clearInterval(interval)
interval = null
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive);
updateInterval();
subscribers.set(onChange, keepAlive)
updateInterval()
return () => {
subscribers.delete(onChange);
updateInterval();
};
subscribers.delete(onChange)
updateInterval()
}
},
now() {
if (startTime === 0) {
startTime = Date.now();
startTime = Date.now()
}
// When the clock interval is running, return the synchronized tickTime
// so all subscribers in the same tick see the same value.
// When paused (no keepAlive subscribers), return real-time to avoid
// returning a stale tickTime from the last tick before the pause.
if (interval && tickTime) {
return tickTime;
return tickTime
}
return Date.now() - startTime;
return Date.now() - startTime
},
setTickInterval(ms) {
if (ms === currentTickIntervalMs) return;
currentTickIntervalMs = ms;
updateInterval();
if (ms === currentTickIntervalMs) return
currentTickIntervalMs = ms
updateInterval()
},
};
}
}
export const ClockContext = createContext<Clock | null>(null);
export const ClockContext = createContext<Clock | null>(null)
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
// Own component so App.tsx doesn't re-render when the clock is created.
// The clock value is stable (created once via useState), so the provider
// never causes consumer re-renders on its own.
export function ClockProvider({ children }: { children: React.ReactNode }): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS));
const focused = useTerminalFocus();
export function ClockProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
const focused = useTerminalFocus()
useEffect(() => {
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
}, [clock, focused]);
clock.setTickInterval(
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
)
}, [clock, focused])
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
}

View File

@@ -1,48 +1,48 @@
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
import { readFileSync } from 'fs';
import React from 'react';
import StackUtils from 'stack-utils';
import Box from './Box.js';
import Text from './Text.js';
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
import { readFileSync } from 'fs'
import React from 'react'
import StackUtils from 'stack-utils'
import Box from './Box.js'
import Text from './Text.js'
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
// Error's source file is reported as file:///home/user/file.js
// This function removes the file://[cwd] part
const cleanupPath = (path: string | undefined): string | undefined => {
return path?.replace(`file://${process.cwd()}/`, '');
};
return path?.replace(`file://${process.cwd()}/`, '')
}
let stackUtils: StackUtils | undefined;
let stackUtils: StackUtils | undefined
function getStackUtils(): StackUtils {
return (stackUtils ??= new StackUtils({
cwd: process.cwd(),
internals: StackUtils.nodeInternals(),
}));
}))
}
/* eslint-enable custom-rules/no-process-cwd */
type Props = {
readonly error: Error;
};
readonly error: Error
}
export default function ErrorOverview({ error }: Props) {
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
const filePath = cleanupPath(origin?.file);
let excerpt: CodeExcerpt[] | undefined;
let lineWidth = 0;
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
const filePath = cleanupPath(origin?.file)
let excerpt: CodeExcerpt[] | undefined
let lineWidth = 0
if (filePath && origin?.line) {
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
const sourceCode = readFileSync(filePath, 'utf8');
excerpt = codeExcerpt(sourceCode, origin.line);
const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line)
if (excerpt) {
for (const { line } of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length);
lineWidth = Math.max(lineWidth, String(line).length)
}
}
} catch {
@@ -76,7 +76,9 @@ export default function ErrorOverview({ error }: Props) {
<Box width={lineWidth + 1}>
<Text
dim={line !== origin.line}
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
backgroundColor={
line === origin.line ? 'ansi:red' : undefined
}
color={line === origin.line ? 'ansi:white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
@@ -101,7 +103,7 @@ export default function ErrorOverview({ error }: Props) {
.split('\n')
.slice(1)
.map(line => {
const parsedLine = getStackUtils().parseLine(line);
const parsedLine = getStackUtils().parseLine(line)
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
@@ -110,7 +112,7 @@ export default function ErrorOverview({ error }: Props) {
<Text dim>- </Text>
<Text bold>{line}</Text>
</Box>
);
)
}
return (
@@ -119,13 +121,14 @@ export default function ErrorOverview({ error }: Props) {
<Text bold>{parsedLine.function}</Text>
<Text dim>
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column})
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
</Text>
</Box>
);
)
})}
</Box>
)}
</Box>
);
)
}

View File

@@ -1,17 +1,21 @@
import type { ReactNode } from 'react';
import React from 'react';
import { supportsHyperlinks } from '../core/supports-hyperlinks.js';
import Text from './Text.js';
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
import Text from './Text.js'
export type Props = {
readonly children?: ReactNode;
readonly url: string;
readonly fallback?: ReactNode;
};
readonly children?: ReactNode
readonly url: string
readonly fallback?: ReactNode
}
export default function Link({ children, url, fallback }: Props): React.ReactNode {
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
// Use children if provided, otherwise display the URL
const content = children ?? url;
const content = children ?? url
if (supportsHyperlinks()) {
// Wrap in Text to ensure we're in a text context
@@ -20,8 +24,8 @@ export default function Link({ children, url, fallback }: Props): React.ReactNod
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
);
)
}
return <Text>{fallback ?? content}</Text>;
return <Text>{fallback ?? content}</Text>
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react'
export type Props = {
/**
@@ -6,12 +6,12 @@ export type Props = {
*
* @default 1
*/
readonly count?: number;
};
readonly count?: number
}
/**
* Adds one or more newline (\n) characters. Must be used within <Text> components.
*/
export default function Newline({ count = 1 }: Props) {
return <ink-text>{'\n'.repeat(count)}</ink-text>;
return <ink-text>{'\n'.repeat(count)}</ink-text>
}

View File

@@ -1,5 +1,5 @@
import React, { type PropsWithChildren } from 'react';
import Box, { type Props as BoxProps } from './Box.js';
import React, { type PropsWithChildren } from 'react'
import Box, { type Props as BoxProps } from './Box.js'
type Props = Omit<BoxProps, 'noSelect'> & {
/**
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
*
* @default false
*/
fromLeftEdge?: boolean;
};
fromLeftEdge?: boolean
}
/**
* Marks its contents as non-selectable in fullscreen text selection.
@@ -32,10 +32,14 @@ type Props = Omit<BoxProps, 'noSelect'> & {
* tracking). No-op in the main-screen scrollback render where the
* terminal's native selection is used instead.
*/
export function NoSelect({ children, fromLeftEdge, ...boxProps }: PropsWithChildren<Props>): React.ReactNode {
export function NoSelect({
children,
fromLeftEdge,
...boxProps
}: PropsWithChildren<Props>): React.ReactNode {
return (
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
{children}
</Box>
);
)
}

View File

@@ -1,14 +1,14 @@
import React from 'react';
import React from 'react'
type Props = {
/**
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
*/
lines: string[];
lines: string[]
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
width: number;
};
width: number
}
/**
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
@@ -27,7 +27,13 @@ type Props = {
*/
export function RawAnsi({ lines, width }: Props): React.ReactNode {
if (lines.length === 0) {
return null;
return null
}
return <ink-raw-ansi rawText={lines.join('\n')} rawWidth={width} rawHeight={lines.length} />;
return (
<ink-raw-ansi
rawText={lines.join('\n')}
rawWidth={width}
rawHeight={lines.length}
/>
)
}

View File

@@ -1,14 +1,20 @@
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../core/dom.js';
import { markDirty, scheduleRenderFrom } from '../core/dom.js';
import { markCommitStart } from '../core/reconciler.js';
import type { Styles } from '../core/styles.js';
import Box from './Box.js';
import React, {
type PropsWithChildren,
type Ref,
useImperativeHandle,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
import { markCommitStart } from '../core/reconciler.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
export type ScrollBoxHandle = {
scrollTo: (y: number) => void;
scrollBy: (dy: number) => void;
scrollTo: (y: number) => void
scrollBy: (dy: number) => void
/**
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
* scrollTo which bakes a number that's stale by the time the throttled
@@ -16,24 +22,24 @@ export type ScrollBoxHandle = {
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
*/
scrollToElement: (el: DOMElement, offset?: number) => void;
scrollToBottom: () => void;
getScrollTop: () => number;
getPendingDelta: () => number;
getScrollHeight: () => number;
scrollToElement: (el: DOMElement, offset?: number) => void
scrollToBottom: () => void
getScrollTop: () => number
getPendingDelta: () => number
getScrollHeight: () => number
/**
* Like getScrollHeight, but reads Yoga directly instead of the cached
* value written by render-node-to-output (throttled, up to 16ms stale).
* Use when you need a fresh value in useLayoutEffect after a React commit
* that grew content. Slightly more expensive (native Yoga call).
*/
getFreshScrollHeight: () => number;
getViewportHeight: () => number;
getFreshScrollHeight: () => number
getViewportHeight: () => number
/**
* Absolute screen-buffer row of the first visible content line (inside
* padding). Used for drag-to-scroll edge detection.
*/
getViewportTop: () => number;
getViewportTop: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
@@ -41,14 +47,14 @@ export type ScrollBoxHandle = {
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight).
*/
isSticky: () => boolean;
isSticky: () => boolean
/**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
* happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback.
*/
subscribe: (listener: () => void) => () => void;
subscribe: (listener: () => void) => () => void
/**
* Set the render-time scrollTop clamp to the currently-mounted children's
* coverage span. Called by useVirtualScroll after computing its range;
@@ -57,17 +63,20 @@ export type ScrollBoxHandle = {
* content instead of blank spacer. Pass undefined to disable (sticky,
* cold start).
*/
setClampBounds: (min: number | undefined, max: number | undefined) => void;
};
setClampBounds: (min: number | undefined, max: number | undefined) => void
}
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
ref?: Ref<ScrollBoxHandle>;
export type ScrollBoxProps = Except<
Styles,
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
> & {
ref?: Ref<ScrollBoxHandle>
/**
* When true, automatically pins scroll position to the bottom when content
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
*/
stickyScroll?: boolean;
};
stickyScroll?: boolean
}
/**
* A Box with `overflow: scroll` and an imperative scroll API.
@@ -79,8 +88,13 @@ export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX
*
* Works best inside a fullscreen (constrained-height root) Ink tree.
*/
function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null);
function ScrollBox({
children,
ref,
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null)
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
// mark it dirty, and call the root's throttled scheduleRender directly.
// The Ink renderer reads scrollTop from the node — no React state needed,
@@ -89,109 +103,113 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// render — otherwise scheduleRender's leading edge fires on the FIRST
// event before subsequent events mutate scrollTop. scrollToBottom still
// forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0);
const listenersRef = useRef(new Set<() => void>());
const renderQueuedRef = useRef(false);
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const renderQueuedRef = useRef(false)
const notify = () => {
for (const l of listenersRef.current) l();
};
for (const l of listenersRef.current) l()
}
function scrollMutated(el: DOMElement): void {
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain.
// noop — injected by business layer via onScrollActivity callback
markDirty(el);
markCommitStart();
notify();
if (renderQueuedRef.current) return;
renderQueuedRef.current = true;
markDirty(el)
markCommitStart()
notify()
if (renderQueuedRef.current) return
renderQueuedRef.current = true
queueMicrotask(() => {
renderQueuedRef.current = false;
scheduleRenderFrom(el);
});
renderQueuedRef.current = false
scheduleRenderFrom(el)
})
}
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
scrollTo(y: number) {
const el = domRef.current;
if (!el) return;
const el = domRef.current
if (!el) return
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false;
el.pendingScrollDelta = undefined;
el.scrollAnchor = undefined;
el.scrollTop = Math.max(0, Math.floor(y));
scrollMutated(el);
el.stickyScroll = false
el.pendingScrollDelta = undefined
el.scrollAnchor = undefined
el.scrollTop = Math.max(0, Math.floor(y))
scrollMutated(el)
},
scrollToElement(el: DOMElement, offset = 0) {
const box = domRef.current;
if (!box) return;
box.stickyScroll = false;
box.pendingScrollDelta = undefined;
box.scrollAnchor = { el, offset };
scrollMutated(box);
const box = domRef.current
if (!box) return
box.stickyScroll = false
box.pendingScrollDelta = undefined
box.scrollAnchor = { el, offset }
scrollMutated(box)
},
scrollBy(dy: number) {
const el = domRef.current;
if (!el) return;
el.stickyScroll = false;
const el = domRef.current
if (!el) return
el.stickyScroll = false
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined;
el.scrollAnchor = undefined
// Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
scrollMutated(el);
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
},
scrollToBottom() {
const el = domRef.current;
if (!el) return;
el.pendingScrollDelta = undefined;
el.stickyScroll = true;
markDirty(el);
notify();
forceRender(n => n + 1);
const el = domRef.current
if (!el) return
el.pendingScrollDelta = undefined
el.stickyScroll = true
markDirty(el)
notify()
forceRender(n => n + 1)
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0;
return domRef.current?.scrollTop ?? 0
},
getPendingDelta() {
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
// this to mount the union [committed, committed+pending] range —
// otherwise intermediate drain frames find no children (blank).
return domRef.current?.pendingScrollDelta ?? 0;
return domRef.current?.pendingScrollDelta ?? 0
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0;
return domRef.current?.scrollHeight ?? 0
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
const content = domRef.current?.childNodes[0] as DOMElement | undefined
return (
content?.yogaNode?.getComputedHeight() ??
domRef.current?.scrollHeight ??
0
)
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0;
return domRef.current?.scrollViewportHeight ?? 0
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0;
return domRef.current?.scrollViewportTop ?? 0
},
isSticky() {
const el = domRef.current;
if (!el) return false;
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
const el = domRef.current
if (!el) return false
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
},
subscribe(listener: () => void) {
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
listenersRef.current.add(listener)
return () => listenersRef.current.delete(listener)
},
setClampBounds(min, max) {
const el = domRef.current;
if (!el) return;
el.scrollClampMin = min;
el.scrollClampMax = max;
const el = domRef.current
if (!el) return
el.scrollClampMin = min
el.scrollClampMax = max
},
}),
// notify/scrollMutated are inline (no useCallback) but only close over
@@ -199,7 +217,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// every render (which re-registers the ref = churn).
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
)
// Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
@@ -215,8 +233,8 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
return (
<ink-box
ref={el => {
domRef.current = el;
if (el) el.scrollTop ??= 0;
domRef.current = el
if (el) el.scrollTop ??= 0
}}
style={{
flexWrap: 'nowrap',
@@ -233,7 +251,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
{children}
</Box>
</ink-box>
);
)
}
export default ScrollBox;
export default ScrollBox

View File

@@ -1,10 +1,10 @@
import React from 'react';
import Box from './Box.js';
import React from 'react'
import Box from './Box.js'
/**
* A flexible space that expands along the major axis of its containing layout.
* It's useful as a shortcut for filling all the available spaces between elements.
*/
export default function Spacer() {
return <Box flexGrow={1} />;
return <Box flexGrow={1} />
}

View File

@@ -1,36 +1,53 @@
import React, { createContext, useMemo, useSyncExternalStore } from 'react';
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
import {
getTerminalFocused,
getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../core/terminal-focus-state.js';
} from '../core/terminal-focus-state.js'
export type { TerminalFocusState };
export type { TerminalFocusState }
export type TerminalFocusContextProps = {
readonly isTerminalFocused: boolean;
readonly terminalFocusState: TerminalFocusState;
};
readonly isTerminalFocused: boolean
readonly terminalFocusState: TerminalFocusState
}
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
isTerminalFocused: true,
terminalFocusState: 'unknown',
});
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
TerminalFocusContext.displayName = 'TerminalFocusContext';
TerminalFocusContext.displayName = 'TerminalFocusContext'
// Separate component so App.tsx doesn't re-render on focus changes.
// Children are a stable prop reference, so they don't re-render either —
// only components that consume the context will re-render.
export function TerminalFocusProvider({ children }: { children: React.ReactNode }): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
export function TerminalFocusProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocused,
)
const terminalFocusState = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocusState,
)
const value = useMemo(() => ({ isTerminalFocused, terminalFocusState }), [isTerminalFocused, terminalFocusState]);
const value = useMemo(
() => ({ isTerminalFocused, terminalFocusState }),
[isTerminalFocused, terminalFocusState],
)
return <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>;
return (
<TerminalFocusContext.Provider value={value}>
{children}
</TerminalFocusContext.Provider>
)
}
export default TerminalFocusContext;
export default TerminalFocusContext

View File

@@ -1,8 +1,8 @@
import { createContext } from 'react';
import { createContext } from 'react'
export type TerminalSize = {
columns: number;
rows: number;
};
columns: number
rows: number
}
export const TerminalSizeContext = createContext<TerminalSize | null>(null);
export const TerminalSizeContext = createContext<TerminalSize | null>(null)

View File

@@ -1,55 +1,58 @@
import type { ReactNode } from 'react';
import React from 'react';
import type { Color, Styles, TextStyles } from '../core/styles.js';
import type { ReactNode } from 'react'
import React from 'react'
import type { Color, Styles, TextStyles } from '../core/styles.js'
type BaseProps = {
/**
* Change text color. Accepts a raw color value (rgb, hex, ansi).
*/
readonly color?: Color;
readonly color?: Color
/**
* Same as `color`, but for background.
*/
readonly backgroundColor?: Color;
readonly backgroundColor?: Color
/**
* Make the text italic.
*/
readonly italic?: boolean;
readonly italic?: boolean
/**
* Make the text underlined.
*/
readonly underline?: boolean;
readonly underline?: boolean
/**
* Make the text crossed with a line.
*/
readonly strikethrough?: boolean;
readonly strikethrough?: boolean
/**
* Inverse background and foreground colors.
*/
readonly inverse?: boolean;
readonly inverse?: boolean
/**
* This property tells Ink to wrap or truncate text if its width is larger than container.
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
*/
readonly wrap?: Styles['textWrap'];
readonly wrap?: Styles['textWrap']
readonly children?: ReactNode;
};
readonly children?: ReactNode
}
/**
* Bold and dim are mutually exclusive in terminals.
* This type ensures you can use one or the other, but not both.
*/
type WeightProps = { bold?: never; dim?: never } | { bold: boolean; dim?: never } | { dim: boolean; bold?: never };
type WeightProps =
| { bold?: never; dim?: never }
| { bold: boolean; dim?: never }
| { dim: boolean; bold?: never }
export type Props = BaseProps & WeightProps;
export type Props = BaseProps & WeightProps
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: {
@@ -100,7 +103,7 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
flexDirection: 'row',
textWrap: 'truncate-start',
},
} as const;
} as const
/**
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
@@ -118,7 +121,7 @@ export default function Text({
children,
}: Props): React.ReactNode {
if (children === undefined || children === null) {
return null;
return null
}
// Build textStyles object with only the properties that are set
@@ -131,11 +134,11 @@ export default function Text({
...(underline && { underline }),
...(strikethrough && { strikethrough }),
...(inverse && { inverse }),
};
}
return (
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
{children}
</ink-text>
);
)
}

View File

@@ -1,26 +1,31 @@
import React from 'react';
import Link from '../components/Link.js';
import Text from '../components/Text.js';
import type { Color } from './styles.js';
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js';
import React from 'react'
import Link from '../components/Link.js'
import Text from '../components/Text.js'
import type { Color } from './styles.js'
import {
type NamedColor,
Parser,
type Color as TermioColor,
type TextStyle,
} from './termio.js'
type Props = {
children: string;
children: string
/** When true, force all text to be rendered with dim styling */
dimColor?: boolean;
};
dimColor?: boolean
}
type SpanProps = {
color?: Color;
backgroundColor?: Color;
dim?: boolean;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
hyperlink?: string;
};
color?: Color
backgroundColor?: Color
dim?: boolean
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
inverse?: boolean
hyperlink?: string
}
/**
* Component that parses ANSI escape codes and renders them using Text components.
@@ -30,32 +35,43 @@ type SpanProps = {
*
* Memoized to prevent re-renders when parent changes but children string is the same.
*/
export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): React.ReactNode {
export const Ansi = React.memo(function Ansi({
children,
dimColor,
}: Props): React.ReactNode {
if (typeof children !== 'string') {
return dimColor ? <Text dim>{String(children)}</Text> : <Text>{String(children)}</Text>;
return dimColor ? (
<Text dim>{String(children)}</Text>
) : (
<Text>{String(children)}</Text>
)
}
if (children === '') {
return null;
return null
}
const spans = parseToSpans(children);
const spans = parseToSpans(children)
if (spans.length === 0) {
return null;
return null
}
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
return dimColor ? <Text dim>{spans[0]!.text}</Text> : <Text>{spans[0]!.text}</Text>;
return dimColor ? (
<Text dim>{spans[0]!.text}</Text>
) : (
<Text>{spans[0]!.text}</Text>
)
}
const content = spans.map((span, i) => {
const hyperlink = span.props.hyperlink;
const hyperlink = span.props.hyperlink
// When dimColor is forced, override the span's dim prop
if (dimColor) {
span.props.dim = true;
span.props.dim = true
}
const hasTextProps = hasAnyTextProps(span.props);
const hasTextProps = hasAnyTextProps(span.props)
if (hyperlink) {
return hasTextProps ? (
@@ -77,7 +93,7 @@ export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): Rea
<Link key={i} url={hyperlink}>
{span.text}
</Link>
);
)
}
return hasTextProps ? (
@@ -96,79 +112,79 @@ export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): Rea
</StyledText>
) : (
span.text
);
});
)
})
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>;
});
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
})
type Span = {
text: string;
props: SpanProps;
};
text: string
props: SpanProps
}
/**
* Parse an ANSI string into spans using the termio parser.
*/
function parseToSpans(input: string): Span[] {
const parser = new Parser();
const actions = parser.feed(input);
const spans: Span[] = [];
const parser = new Parser()
const actions = parser.feed(input)
const spans: Span[] = []
let currentHyperlink: string | undefined;
let currentHyperlink: string | undefined
for (const action of actions) {
if (action.type === 'link') {
if (action.action.type === 'start') {
currentHyperlink = action.action.url;
currentHyperlink = action.action.url
} else {
currentHyperlink = undefined;
currentHyperlink = undefined
}
continue;
continue
}
if (action.type === 'text') {
const text = action.graphemes.map(g => g.value).join('');
if (!text) continue;
const text = action.graphemes.map(g => g.value).join('')
if (!text) continue
const props = textStyleToSpanProps(action.style);
const props = textStyleToSpanProps(action.style)
if (currentHyperlink) {
props.hyperlink = currentHyperlink;
props.hyperlink = currentHyperlink
}
// Try to merge with previous span if props match
const lastSpan = spans[spans.length - 1];
const lastSpan = spans[spans.length - 1]
if (lastSpan && propsEqual(lastSpan.props, props)) {
lastSpan.text += text;
lastSpan.text += text
} else {
spans.push({ text, props });
spans.push({ text, props })
}
}
}
return spans;
return spans
}
/**
* Convert termio's TextStyle to SpanProps.
*/
function textStyleToSpanProps(style: TextStyle): SpanProps {
const props: SpanProps = {};
const props: SpanProps = {}
if (style.bold) props.bold = true;
if (style.dim) props.dim = true;
if (style.italic) props.italic = true;
if (style.underline !== 'none') props.underline = true;
if (style.strikethrough) props.strikethrough = true;
if (style.inverse) props.inverse = true;
if (style.bold) props.bold = true
if (style.dim) props.dim = true
if (style.italic) props.italic = true
if (style.underline !== 'none') props.underline = true
if (style.strikethrough) props.strikethrough = true
if (style.inverse) props.inverse = true
const fgColor = colorToString(style.fg);
if (fgColor) props.color = fgColor;
const fgColor = colorToString(style.fg)
if (fgColor) props.color = fgColor
const bgColor = colorToString(style.bg);
if (bgColor) props.backgroundColor = bgColor;
const bgColor = colorToString(style.bg)
if (bgColor) props.backgroundColor = bgColor
return props;
return props
}
// Map termio named colors to the ansi: format
@@ -189,7 +205,7 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright',
};
}
/**
* Convert termio's Color to the string format used by Ink.
@@ -197,13 +213,13 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
function colorToString(color: TermioColor): Color | undefined {
switch (color.type) {
case 'named':
return NAMED_COLOR_MAP[color.name] as Color;
return NAMED_COLOR_MAP[color.name] as Color
case 'indexed':
return `ansi256(${color.index})` as Color;
return `ansi256(${color.index})` as Color
case 'rgb':
return `rgb(${color.r},${color.g},${color.b})` as Color;
return `rgb(${color.r},${color.g},${color.b})` as Color
case 'default':
return undefined;
return undefined
}
}
@@ -221,7 +237,7 @@ function propsEqual(a: SpanProps, b: SpanProps): boolean {
a.strikethrough === b.strikethrough &&
a.inverse === b.inverse &&
a.hyperlink === b.hyperlink
);
)
}
function hasAnyProps(props: SpanProps): boolean {
@@ -235,7 +251,7 @@ function hasAnyProps(props: SpanProps): boolean {
props.strikethrough === true ||
props.inverse === true ||
props.hyperlink !== undefined
);
)
}
function hasAnyTextProps(props: SpanProps): boolean {
@@ -248,18 +264,18 @@ function hasAnyTextProps(props: SpanProps): boolean {
props.underline === true ||
props.strikethrough === true ||
props.inverse === true
);
)
}
// Text style props without weight (bold/dim) - these are handled separately
type BaseTextStyleProps = {
color?: Color;
backgroundColor?: Color;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
};
color?: Color
backgroundColor?: Color
italic?: boolean
underline?: boolean
strikethrough?: boolean
inverse?: boolean
}
// Wrapper component that handles bold/dim mutual exclusivity for Text
function StyledText({
@@ -268,9 +284,9 @@ function StyledText({
children,
...rest
}: BaseTextStyleProps & {
bold?: boolean;
dim?: boolean;
children: string;
bold?: boolean
dim?: boolean
children: string
}): React.ReactNode {
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
if (dim) {
@@ -278,14 +294,14 @@ function StyledText({
<Text {...rest} dim>
{children}
</Text>
);
)
}
if (bold) {
return (
<Text {...rest} bold>
{children}
</Text>
);
)
}
return <Text {...rest}>{children}</Text>;
return <Text {...rest}>{children}</Text>
}

View File

@@ -17,16 +17,8 @@
import bidiFactory from 'bidi-js'
type BidiInstance = {
getEmbeddingLevels: (
text: string,
defaultDirection?: string,
) => { paragraphLevel: number; levels: Uint8Array }
getReorderSegments: (
text: string,
embeddingLevels: { paragraphLevel: number; levels: Uint8Array },
start?: number,
end?: number,
) => [number, number][]
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
getVisualOrder: (reorderSegments: [number, number][]) => number[]
}

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type Cursor = any
export type Cursor = any;

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export {}
export {};

View File

@@ -37,9 +37,7 @@ export class MouseActionEvent extends Event {
/** Recompute local coords relative to the target Box. */
prepareForTarget(target: EventTarget): void {
const dom = target as unknown as {
yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number }
}
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
}

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type PasteEvent = any
export type PasteEvent = any;

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type ResizeEvent = any
export type ResizeEvent = any;

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@ export class LogUpdate {
const { screen } = frame
const lines: string[] = []
let currentStyles: AnsiCode[] = []
let currentHyperlink: Hyperlink
let currentHyperlink: Hyperlink = undefined
for (let y = 0; y < screen.height; y++) {
let line = ''
for (let x = 0; x < screen.width; x++) {
@@ -301,7 +301,7 @@ export class LogUpdate {
cursorRestoreScroll
let currentStyleId = stylePool.none
let currentHyperlink: Hyperlink
let currentHyperlink: Hyperlink = undefined
// First pass: render changes to existing rows (rows < prev.screen.height)
let needsFullReset = false
@@ -533,7 +533,7 @@ function renderFrameSlice(
stylePool: StylePool,
): VirtualScreen {
let currentStyleId = stylePool.none
let currentHyperlink: Hyperlink
let currentHyperlink: Hyperlink = undefined
// Track the styleId of the last rendered cell on this line (-1 if none).
// Passed to visibleCellAtIndex to enable fg-only space optimization.
let lastRenderedStyleId = -1

View File

@@ -8,17 +8,18 @@ import { Buffer } from 'buffer'
import { PASTE_END, PASTE_START } from './termio/csi.js'
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
// eslint-disable-next-line no-control-regex
const FN_KEY_RE =
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
// Modifier is optional - when absent, defaults to 1 (no modifiers)
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
// xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
@@ -26,41 +27,41 @@ const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
// modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
// TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
// Note param order is reversed vs CSI u (modifier first, keycode second).
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
// -- Terminal response patterns (inbound sequences from the terminal itself) --
// DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode)
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
// DA1: CSI ? Ps ; ... c — primary device attributes response
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
// DA2: CSI > Ps ; ... c — secondary device attributes response
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const DA2_RE = /^\x1b\[>([\d;]*)c$/
// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
// (private ? marker distinguishes from CSI u key events)
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
// DECXCPR cursor position: CSI ? row ; col R
// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
// OSC response: OSC code ; data (BEL|ST)
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
// XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q).
// xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
// their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
// goes through the pty, not the environment.
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
// SGR mouse event: CSI < button ; col ; row M (press) or m (release)
// Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
// eslint-disable-next-line no-control-regex
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
function createPasteKey(content: string): ParsedKey {

View File

@@ -34,10 +34,8 @@ if (process.env.NODE_ENV === 'development') {
void import('./devtools.js')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: unknown) {
if (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND'
) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') {
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
`
The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
@@ -199,6 +197,7 @@ let _prepareAt = 0
/** Debug log helper — replaces fs.appendFileSync with console.warn. */
function debugLog(message: string): void {
// biome-ignore lint/suspicious/noConsole: debug instrumentation
console.warn(`[ink-commit] ${message}`)
}
// --- END ---
@@ -305,7 +304,9 @@ const reconciler = createReconciler<
if (COMMIT_LOG) {
const renderMs = performance.now() - _tr
if (renderMs > 10) {
debugLog(`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`)
debugLog(
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`,
)
}
}
},

View File

@@ -114,6 +114,7 @@ const wrappedRender = async (
await Promise.resolve()
const instance = renderSync(node, options)
if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') {
// biome-ignore lint/suspicious/noConsole: debug instrumentation
console.warn(
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
)

View File

@@ -119,44 +119,6 @@ export class StylePool {
this.none = this.intern([])
}
private static readonly CACHE_MAX = 1000
/**
* Evict oldest entries from derivative caches when they exceed the limit.
* ids/styles are never evicted (id is an array index).
*/
private evictCacheIfNeeded(): void {
if (this.transitionCache.size > StylePool.CACHE_MAX) {
const keys = this.transitionCache.keys()
for (
let i = 0;
i < this.transitionCache.size - StylePool.CACHE_MAX;
i++
) {
const k = keys.next().value
if (k !== undefined) this.transitionCache.delete(k)
}
}
if (this.inverseCache.size > StylePool.CACHE_MAX) {
const keys = this.inverseCache.keys()
for (let i = 0; i < this.inverseCache.size - StylePool.CACHE_MAX; i++) {
const k = keys.next().value
if (k !== undefined) this.inverseCache.delete(k)
}
}
if (this.currentMatchCache.size > StylePool.CACHE_MAX) {
const keys = this.currentMatchCache.keys()
for (
let i = 0;
i < this.currentMatchCache.size - StylePool.CACHE_MAX;
i++
) {
const k = keys.next().value
if (k !== undefined) this.currentMatchCache.delete(k)
}
}
}
/**
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
* style has a visible effect on space characters (background, inverse,
@@ -174,7 +136,6 @@ export class StylePool {
(rawId << 1) |
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
this.ids.set(key, id)
this.evictCacheIfNeeded()
}
return id
}
@@ -325,7 +286,7 @@ function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
*/
// const enum is inlined at compile time - no runtime object, no property access
export enum CellWidth {
export const enum CellWidth {
// Not a wide character, cell width 1
Narrow = 0,
// Wide character, cell width 2. This cell contains the actual character.
@@ -1183,7 +1144,7 @@ type DiffCallback = (
y: number,
removed: Cell | undefined,
added: Cell | undefined,
) => boolean | undefined
) => boolean | void
/**
* Like diff(), but calls a callback for each change instead of building an array.

View File

@@ -14,18 +14,9 @@ function execFileNoThrow(
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise(resolve => {
const { input, timeout } = options
const proc = nodeExecFile(
command,
args,
{ timeout },
(error, stdout, stderr) => {
resolve({
code: error ? 1 : 0,
stdout: stdout ?? '',
stderr: stderr ?? '',
})
},
)
const proc = nodeExecFile(command, args, { timeout }, (error, stdout, stderr) => {
resolve({ code: error ? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
})
if (input && proc.stdin) {
proc.stdin.write(input)
proc.stdin.end()

View File

@@ -49,13 +49,7 @@ export default function sliceAnsi(
// pass start/end in display cells (via stringWidth), so position must
// track the same units.
const width =
token.type === 'ansi'
? 0
: token.type === 'char'
? token.fullWidth
? 2
: stringWidth(token.value)
: 0
token.type === 'ansi' ? 0 : token.type === 'char' ? (token.fullWidth ? 2 : stringWidth(token.value)) : 0
// Break AFTER trailing zero-width marks — a combining mark attaches to
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at

View File

@@ -111,7 +111,6 @@ function isDefined(n: number): boolean {
// NaN-safe equality for layout-cache input comparison
function sameFloat(a: number, b: number): boolean {
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check (x !== x ↔ isNaN)
return a === b || (a !== a && b !== b)
}
@@ -2373,14 +2372,12 @@ function boundAxis(
if (v > maxV.value) v = maxV.value
} else if (maxU === 2) {
const m = (maxV.value * owner) / 100
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
if (m === m && v > m) v = m
}
if (minU === 1) {
if (v < minV.value) v = minV.value
} else if (minU === 2) {
const m = (minV.value * owner) / 100
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
if (m === m && v < m) v = m
}
return v

View File

@@ -110,9 +110,7 @@ export function useSearchInput({
if (e.key === 'delete') {
e.preventDefault()
if (cursorOffset < query.length) {
setQueryState(
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
)
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
}
return
}
@@ -161,9 +159,7 @@ export function useSearchInput({
return
}
if (cursorOffset < query.length) {
setQueryState(
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
)
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
}
return
}
@@ -211,9 +207,7 @@ export function useSearchInput({
// Regular character input
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
e.preventDefault()
setQueryState(
query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset),
)
setQueryState(query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset))
setCursorOffset(cursorOffset + 1)
}
}

View File

@@ -1,16 +1,7 @@
import { createContext, useCallback, useContext, useMemo } from 'react'
import {
isProgressReportingAvailable,
type Progress,
} from '../core/terminal.js'
import { isProgressReportingAvailable, type Progress } from '../core/terminal.js'
import { BEL } from '../core/termio/ansi.js'
import {
ITERM2,
OSC,
osc,
PROGRESS,
wrapForMultiplexer,
} from '../core/termio/osc.js'
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from '../core/termio/osc.js'
type WriteRaw = (data: string) => void

View File

@@ -10,16 +10,13 @@
// ============================================================
// Core API (render/createRoot)
// ============================================================
export {
default as wrappedRender,
renderSync,
createRoot,
} from './core/root.js'
export { default as wrappedRender, renderSync, createRoot } from './core/root.js'
export type { RenderOptions, Instance, Root } from './core/root.js'
export * from './theme/theme-types.js'
// InkCore class
export { default as Ink } from './core/ink.js'
// ============================================================
// Keybindings
// ============================================================
@@ -71,21 +68,8 @@ export type {
// ============================================================
// Core types
// ============================================================
export type {
DOMElement,
TextNode,
ElementNames,
DOMNodeAttribute,
} from './core/dom.js'
export type {
Styles,
TextStyles,
Color,
RGBColor,
HexColor,
Ansi256Color,
AnsiColor,
} from './core/styles.js'
export type { DOMElement, TextNode, ElementNames, DOMNodeAttribute } from './core/dom.js'
export type { Styles, TextStyles, Color, RGBColor, HexColor, Ansi256Color, AnsiColor } from './core/styles.js'
export type { Key } from './core/events/input-event.js'
export type { FlickerReason, FrameEvent } from './core/frame.js'
export type { MatchPosition } from './core/render-to-screen.js'
@@ -99,10 +83,7 @@ export { ClickEvent } from './core/events/click-event.js'
export { EventEmitter } from './core/events/emitter.js'
export { Event } from './core/events/event.js'
export { InputEvent } from './core/events/input-event.js'
export {
TerminalFocusEvent,
type TerminalFocusEventType,
} from './core/events/terminal-focus-event.js'
export { TerminalFocusEvent, type TerminalFocusEventType } from './core/events/terminal-focus-event.js'
export { KeyboardEvent } from './core/events/keyboard-event.js'
export { FocusEvent } from './core/events/focus-event.js'
export { FocusManager } from './core/focus.js'
@@ -111,53 +92,17 @@ export { stringWidth } from './core/stringWidth.js'
export { default as wrapText } from './core/wrap-text.js'
export { default as measureElement } from './core/measure-element.js'
export { supportsTabStatus } from './core/termio/osc.js'
export {
setClipboard,
getClipboardPath,
CLEAR_ITERM2_PROGRESS,
CLEAR_TAB_STATUS,
CLEAR_TERMINAL_TITLE,
wrapForMultiplexer,
} from './core/termio/osc.js'
export {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
} from './core/termio/csi.js'
export {
SHOW_CURSOR,
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EXIT_ALT_SCREEN,
HIDE_CURSOR,
ENTER_ALT_SCREEN,
ENABLE_MOUSE_TRACKING,
} from './core/termio/dec.js'
export { setClipboard, getClipboardPath, CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, CLEAR_TERMINAL_TITLE, wrapForMultiplexer } from './core/termio/osc.js'
export { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS } from './core/termio/csi.js'
export { SHOW_CURSOR, DBP, DFE, DISABLE_MOUSE_TRACKING, EXIT_ALT_SCREEN, HIDE_CURSOR, ENTER_ALT_SCREEN, ENABLE_MOUSE_TRACKING } from './core/termio/dec.js'
export { default as instances } from './core/instances.js'
export {
default as renderBorder,
type BorderTextOptions,
} from './core/render-border.js'
export {
isSynchronizedOutputSupported,
isXtermJs,
hasCursorUpViewportYankBug,
writeDiffToTerminal,
} from './core/terminal.js'
export {
colorize,
applyColor,
applyTextStyles,
type ColorType,
} from './core/colorize.js'
export { default as renderBorder, type BorderTextOptions } from './core/render-border.js'
export { isSynchronizedOutputSupported, isXtermJs, hasCursorUpViewportYankBug, writeDiffToTerminal } from './core/terminal.js'
export { colorize, applyColor, applyTextStyles, type ColorType } from './core/colorize.js'
export { wrapAnsi } from './core/wrapAnsi.js'
export { default as styles } from './core/styles.js'
export { clamp } from './core/layout/geometry.js'
export {
getTerminalFocusState,
getTerminalFocused,
subscribeTerminalFocus,
} from './core/terminal-focus-state.js'
export { getTerminalFocusState, getTerminalFocused, subscribeTerminalFocus } from './core/terminal-focus-state.js'
export { supportsHyperlinks } from './core/supports-hyperlinks.js'
// ============================================================
@@ -167,11 +112,7 @@ export { default as BaseBox } from './components/Box.js'
export type { Props as BaseBoxProps } from './components/Box.js'
export { default as BaseText } from './components/Text.js'
export type { Props as BaseTextProps } from './components/Text.js'
export {
default as Button,
type ButtonState,
type Props as ButtonProps,
} from './components/Button.js'
export { default as Button, type ButtonState, type Props as ButtonProps } from './components/Button.js'
export { default as Link } from './components/Link.js'
export type { Props as LinkProps } from './components/Link.js'
export { default as Newline } from './components/Newline.js'
@@ -179,19 +120,13 @@ export type { Props as NewlineProps } from './components/Newline.js'
export { default as Spacer } from './components/Spacer.js'
export { NoSelect } from './components/NoSelect.js'
export { RawAnsi } from './components/RawAnsi.js'
export {
default as ScrollBox,
type ScrollBoxHandle,
} from './components/ScrollBox.js'
export { default as ScrollBox, type ScrollBoxHandle } from './components/ScrollBox.js'
export { AlternateScreen } from './components/AlternateScreen.js'
// App types
export type { Props as AppProps } from './components/AppContext.js'
export type { Props as StdinProps } from './components/StdinContext.js'
export {
TerminalSizeContext,
type TerminalSize,
} from './components/TerminalSizeContext.js'
export { TerminalSizeContext, type TerminalSize } from './components/TerminalSizeContext.js'
// ============================================================
// Hooks
@@ -205,28 +140,20 @@ export { default as useStdin } from './hooks/use-stdin.js'
export { useTerminalSize } from './hooks/useTerminalSize.js'
export { useTimeout } from './hooks/useTimeout.js'
export { useMinDisplayTime } from './hooks/useMinDisplayTime.js'
export {
useDoublePress,
DOUBLE_PRESS_TIMEOUT_MS,
} from './hooks/useDoublePress.js'
export { useDoublePress, DOUBLE_PRESS_TIMEOUT_MS } from './hooks/useDoublePress.js'
export { useTabStatus, type TabStatusKind } from './hooks/use-tab-status.js'
export { useTerminalFocus } from './hooks/use-terminal-focus.js'
export { useTerminalTitle } from './hooks/use-terminal-title.js'
export { useTerminalViewport } from './hooks/use-terminal-viewport.js'
export { useSearchHighlight } from './hooks/use-search-highlight.js'
export { useDeclaredCursor } from './hooks/use-declared-cursor.js'
export {
TerminalWriteProvider,
useTerminalNotification,
type TerminalNotification,
} from './hooks/useTerminalNotification.js'
export { TerminalWriteProvider, useTerminalNotification, type TerminalNotification } from './hooks/useTerminalNotification.js'
// ============================================================
// Theme (Layer 3)
// ============================================================
export {
ThemeProvider,
setThemeConfigCallbacks,
usePreviewTheme,
useTheme,
useThemeSetting,

View File

@@ -1,63 +1,84 @@
import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react';
import type { Key } from '../core/events/input-event.js';
import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js';
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js';
import React, {
createContext,
type RefObject,
useContext,
useLayoutEffect,
useMemo,
} from 'react'
import type { Key } from '../core/events/input-event.js'
import {
type ChordResolveResult,
getBindingDisplayText,
resolveKeyWithChordState,
} from './resolver.js'
import type {
KeybindingContextName,
ParsedBinding,
ParsedKeystroke,
} from './types.js'
/** Handler registration for action callbacks */
type HandlerRegistration = {
action: string;
context: KeybindingContextName;
handler: () => void;
};
action: string
context: KeybindingContextName
handler: () => void
}
type KeybindingContextValue = {
/** Resolve a key input to an action name (with chord support) */
resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult;
resolve: (
input: string,
key: Key,
activeContexts: KeybindingContextName[],
) => ChordResolveResult
/** Update the pending chord state */
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
setPendingChord: (pending: ParsedKeystroke[] | null) => void
/** Get display text for an action (e.g., "ctrl+t") */
getDisplayText: (action: string, context: KeybindingContextName) => string | undefined;
getDisplayText: (
action: string,
context: KeybindingContextName,
) => string | undefined
/** All parsed bindings (for help display) */
bindings: ParsedBinding[];
bindings: ParsedBinding[]
/** Current pending chord keystrokes (null if not in a chord) */
pendingChord: ParsedKeystroke[] | null;
pendingChord: ParsedKeystroke[] | null
/** Currently active keybinding contexts (for priority resolution) */
activeContexts: Set<KeybindingContextName>;
activeContexts: Set<KeybindingContextName>
/** Register a context as active (call on mount) */
registerActiveContext: (context: KeybindingContextName) => void;
registerActiveContext: (context: KeybindingContextName) => void
/** Unregister a context (call on unmount) */
unregisterActiveContext: (context: KeybindingContextName) => void;
unregisterActiveContext: (context: KeybindingContextName) => void
/** Register a handler for an action (used by useKeybinding) */
registerHandler: (registration: HandlerRegistration) => () => void;
registerHandler: (registration: HandlerRegistration) => () => void
/** Invoke all handlers for an action (used by ChordInterceptor) */
invokeAction: (action: string) => boolean;
};
invokeAction: (action: string) => boolean
}
const KeybindingContext = createContext<KeybindingContextValue | null>(null);
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
type ProviderProps = {
bindings: ParsedBinding[];
bindings: ParsedBinding[]
/** Ref for immediate access to pending chord (avoids React state delay) */
pendingChordRef: RefObject<ParsedKeystroke[] | null>;
pendingChordRef: RefObject<ParsedKeystroke[] | null>
/** State value for re-renders (UI updates) */
pendingChord: ParsedKeystroke[] | null;
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
activeContexts: Set<KeybindingContextName>;
registerActiveContext: (context: KeybindingContextName) => void;
unregisterActiveContext: (context: KeybindingContextName) => void;
pendingChord: ParsedKeystroke[] | null
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
registerActiveContext: (context: KeybindingContextName) => void
unregisterActiveContext: (context: KeybindingContextName) => void
/** Ref to handler registry (used by ChordInterceptor) */
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>;
children: React.ReactNode;
};
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
children: React.ReactNode
}
export function KeybindingProvider({
bindings,
@@ -72,54 +93,60 @@ export function KeybindingProvider({
}: ProviderProps): React.ReactNode {
const value = useMemo<KeybindingContextValue>(() => {
const getDisplay = (action: string, context: KeybindingContextName) =>
getBindingDisplayText(action, context, bindings);
getBindingDisplayText(action, context, bindings)
// Register a handler for an action
const registerHandler = (registration: HandlerRegistration) => {
const registry = handlerRegistryRef.current;
if (!registry) return () => {};
const registry = handlerRegistryRef.current
if (!registry) return () => {}
if (!registry.has(registration.action)) {
registry.set(registration.action, new Set());
registry.set(registration.action, new Set())
}
registry.get(registration.action)!.add(registration);
registry.get(registration.action)!.add(registration)
// Return unregister function
return () => {
const handlers = registry.get(registration.action);
const handlers = registry.get(registration.action)
if (handlers) {
handlers.delete(registration);
handlers.delete(registration)
if (handlers.size === 0) {
registry.delete(registration.action);
registry.delete(registration.action)
}
}
};
};
}
}
// Invoke all handlers for an action
const invokeAction = (action: string): boolean => {
const registry = handlerRegistryRef.current;
if (!registry) return false;
const registry = handlerRegistryRef.current
if (!registry) return false
const handlers = registry.get(action);
if (!handlers || handlers.size === 0) return false;
const handlers = registry.get(action)
if (!handlers || handlers.size === 0) return false
// Find handlers whose context is active
for (const registration of handlers) {
if (activeContexts.has(registration.context)) {
registration.handler();
return true;
registration.handler()
return true
}
}
return false;
};
return false
}
return {
// Use ref for immediate access to pending chord, avoiding React state delay
// This is critical for chord sequences where the second key might be pressed
// before React re-renders with the updated pendingChord state
resolve: (input, key, contexts) =>
resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current),
resolveKeyWithChordState(
input,
key,
contexts,
bindings,
pendingChordRef.current,
),
setPendingChord,
getDisplayText: getDisplay,
bindings,
@@ -129,7 +156,7 @@ export function KeybindingProvider({
unregisterActiveContext,
registerHandler,
invokeAction,
};
}
}, [
bindings,
pendingChordRef,
@@ -139,17 +166,23 @@ export function KeybindingProvider({
registerActiveContext,
unregisterActiveContext,
handlerRegistryRef,
]);
])
return <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>;
return (
<KeybindingContext.Provider value={value}>
{children}
</KeybindingContext.Provider>
)
}
export function useKeybindingContext(): KeybindingContextValue {
const ctx = useContext(KeybindingContext);
const ctx = useContext(KeybindingContext)
if (!ctx) {
throw new Error('useKeybindingContext must be used within KeybindingProvider');
throw new Error(
'useKeybindingContext must be used within KeybindingProvider',
)
}
return ctx;
return ctx
}
/**
@@ -157,7 +190,7 @@ export function useKeybindingContext(): KeybindingContextValue {
* Useful for components that may render before provider is available.
*/
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
return useContext(KeybindingContext);
return useContext(KeybindingContext)
}
/**
@@ -175,15 +208,18 @@ export function useOptionalKeybindingContext(): KeybindingContextValue | null {
* }
* ```
*/
export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void {
const keybindingContext = useOptionalKeybindingContext();
export function useRegisterKeybindingContext(
context: KeybindingContextName,
isActive: boolean = true,
): void {
const keybindingContext = useOptionalKeybindingContext()
useLayoutEffect(() => {
if (!keybindingContext || !isActive) return;
if (!keybindingContext || !isActive) return
keybindingContext.registerActiveContext(context);
keybindingContext.registerActiveContext(context)
return () => {
keybindingContext.unregisterActiveContext(context);
};
}, [context, keybindingContext, isActive]);
keybindingContext.unregisterActiveContext(context)
}
}, [context, keybindingContext, isActive])
}

View File

@@ -5,47 +5,49 @@
* wrapper. App-specific dependencies (binding loading, change subscription,
* warning display, debug logging) are injected via props.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { InputEvent } from '../core/events/input-event.js';
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { InputEvent } from '../core/events/input-event.js'
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
// other handlers process them - this is required for chord sequence support
// eslint-disable-next-line custom-rules/prefer-use-keybindings
import useInput from '../hooks/use-input.js';
import type { Key } from '../core/events/input-event.js';
import { KeybindingProvider } from './KeybindingContext.js';
import { resolveKeyWithChordState } from './resolver.js';
import useInput from '../hooks/use-input.js'
import type { Key } from '../core/events/input-event.js'
import { KeybindingProvider } from './KeybindingContext.js'
import { resolveKeyWithChordState } from './resolver.js'
import type {
KeybindingContextName,
KeybindingsLoadResult,
ParsedBinding,
ParsedKeystroke,
KeybindingWarning,
} from './types.js';
} from './types.js'
/**
* Timeout for chord sequences in milliseconds.
* If the user doesn't complete the chord within this time, it's cancelled.
*/
const CHORD_TIMEOUT_MS = 1000;
const CHORD_TIMEOUT_MS = 1000
export type KeybindingSetupProps = {
children: React.ReactNode;
children: React.ReactNode
/** Load bindings synchronously for initial render */
loadBindings: () => KeybindingsLoadResult;
loadBindings: () => KeybindingsLoadResult
/** Subscribe to binding changes; return an unsubscribe function */
subscribeToChanges: (callback: (result: KeybindingsLoadResult) => void) => () => void;
subscribeToChanges: (
callback: (result: KeybindingsLoadResult) => void,
) => () => void
/** Initialize any file watcher (idempotent). Called once on mount. */
initWatcher?: () => void | Promise<void>;
initWatcher?: () => void | Promise<void>
/** Optional callback when warnings are emitted (initial load or reload) */
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void;
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void
/** Optional debug logger */
onDebugLog?: (message: string) => void;
};
onDebugLog?: (message: string) => void
}
export function KeybindingSetup({
children,
@@ -57,105 +59,115 @@ export function KeybindingSetup({
}: KeybindingSetupProps): React.ReactNode {
// Load bindings synchronously for initial render
const [loadResult, setLoadResult] = useState<KeybindingsLoadResult>(() => {
const result = loadBindings();
const result = loadBindings()
onDebugLog?.(
`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
);
return result;
});
)
return result
})
const { bindings, warnings } = loadResult;
const { bindings, warnings } = loadResult
// Track if this is a reload (not initial load)
const [isReload, setIsReload] = useState(false);
const [isReload, setIsReload] = useState(false)
// Notify about warnings
useEffect(() => {
onWarnings?.(warnings, isReload);
}, [warnings, isReload, onWarnings]);
onWarnings?.(warnings, isReload)
}, [warnings, isReload, onWarnings])
// Chord state management - use ref for immediate access, state for re-renders
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null);
const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null);
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = useState<
ParsedKeystroke[] | null
>(null)
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
const handlerRegistryRef = useRef(
new Map<
string,
Set<{
action: string;
context: KeybindingContextName;
handler: () => void;
action: string
context: KeybindingContextName
handler: () => void
}>
>(),
);
)
// Active context tracking for keybinding priority resolution
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set());
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
const registerActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.add(context);
}, []);
const registerActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.add(context)
},
[],
)
const unregisterActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.delete(context);
}, []);
const unregisterActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.delete(context)
},
[],
)
// Clear chord timeout when component unmounts or chord changes
const clearChordTimeout = useCallback(() => {
if (chordTimeoutRef.current) {
clearTimeout(chordTimeoutRef.current);
chordTimeoutRef.current = null;
clearTimeout(chordTimeoutRef.current)
chordTimeoutRef.current = null
}
}, []);
}, [])
// Wrapper for setPendingChord that manages timeout and syncs ref+state
const setPendingChord = useCallback(
(pending: ParsedKeystroke[] | null) => {
clearChordTimeout();
clearChordTimeout()
if (pending !== null) {
// Set timeout to cancel chord if not completed
chordTimeoutRef.current = setTimeout(
(pendingChordRef, setPendingChordState) => {
onDebugLog?.('[keybindings] Chord timeout - cancelling');
pendingChordRef.current = null;
setPendingChordState(null);
onDebugLog?.('[keybindings] Chord timeout - cancelling')
pendingChordRef.current = null
setPendingChordState(null)
},
CHORD_TIMEOUT_MS,
pendingChordRef,
setPendingChordState,
);
)
}
// Update ref immediately for synchronous access in resolve()
pendingChordRef.current = pending;
pendingChordRef.current = pending
// Update state to trigger re-renders for UI updates
setPendingChordState(pending);
setPendingChordState(pending)
},
[clearChordTimeout, onDebugLog],
);
)
useEffect(() => {
// Initialize file watcher (idempotent - only runs once)
void initWatcher?.();
void initWatcher?.()
// Subscribe to changes
const unsubscribe = subscribeToChanges(result => {
// Any callback invocation is a reload since initial load happens
// synchronously in useState, not via this subscription
setIsReload(true);
setIsReload(true)
setLoadResult(result);
onDebugLog?.(`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`);
});
setLoadResult(result)
onDebugLog?.(
`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
)
})
return () => {
unsubscribe();
clearChordTimeout();
};
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog]);
unsubscribe()
clearChordTimeout()
}
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog])
return (
<KeybindingProvider
@@ -177,7 +189,7 @@ export function KeybindingSetup({
/>
{children}
</KeybindingProvider>
);
)
}
/**
@@ -191,10 +203,10 @@ export function KeybindingSetup({
* system could recognize it as completing a chord.
*/
type HandlerRegistration = {
action: string;
context: KeybindingContextName;
handler: () => void;
};
action: string
context: KeybindingContextName
handler: () => void
}
function ChordInterceptor({
bindings,
@@ -203,11 +215,11 @@ function ChordInterceptor({
activeContexts,
handlerRegistryRef,
}: {
bindings: ParsedBinding[];
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>;
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
activeContexts: Set<KeybindingContextName>;
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>;
bindings: ParsedBinding[]
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
}): null {
const handleInput = useCallback(
(input: string, key: Key, event: InputEvent) => {
@@ -216,78 +228,94 @@ function ChordInterceptor({
// here. Skip the registry scan. Mid-chord wheel still falls through so
// scrolling cancels the pending chord like any other non-matching key.
if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) {
return;
return
}
// Build context list from registered handlers + activeContexts + Global
const registry = handlerRegistryRef.current;
const handlerContexts = new Set<KeybindingContextName>();
const registry = handlerRegistryRef.current
const handlerContexts = new Set<KeybindingContextName>()
if (registry) {
for (const handlers of registry.values()) {
for (const registration of handlers) {
handlerContexts.add(registration.context);
handlerContexts.add(registration.context)
}
}
}
const contexts: KeybindingContextName[] = [...handlerContexts, ...activeContexts, 'Global'];
const contexts: KeybindingContextName[] = [
...handlerContexts,
...activeContexts,
'Global',
]
// Track whether we're completing a chord (pending was non-null)
const wasInChord = pendingChordRef.current !== null;
const wasInChord = pendingChordRef.current !== null
// Check if this keystroke is part of a chord sequence
const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current);
const result = resolveKeyWithChordState(
input,
key,
contexts,
bindings,
pendingChordRef.current,
)
switch (result.type) {
case 'chord_started':
// This key starts a chord - store pending state and stop propagation
setPendingChord(result.pending);
event.stopImmediatePropagation();
break;
setPendingChord(result.pending)
event.stopImmediatePropagation()
break
case 'match': {
// Clear pending state
setPendingChord(null);
setPendingChord(null)
// Only invoke handlers and stop propagation for chord completions
// (multi-keystroke sequences). Single-keystroke matches should propagate
// to per-hook handlers to avoid interfering with other input handling.
if (wasInChord) {
const contextsSet = new Set(contexts);
const contextsSet = new Set(contexts)
if (registry) {
const handlers = registry.get(result.action);
const handlers = registry.get(result.action)
if (handlers && handlers.size > 0) {
for (const registration of handlers) {
if (contextsSet.has(registration.context)) {
registration.handler();
event.stopImmediatePropagation();
break;
registration.handler()
event.stopImmediatePropagation()
break
}
}
}
}
}
break;
break
}
case 'chord_cancelled':
setPendingChord(null);
event.stopImmediatePropagation();
break;
setPendingChord(null)
event.stopImmediatePropagation()
break
case 'unbound':
setPendingChord(null);
event.stopImmediatePropagation();
break;
setPendingChord(null)
event.stopImmediatePropagation()
break
case 'none':
// No chord involvement - let other handlers process
break;
break
}
},
[bindings, pendingChordRef, setPendingChord, activeContexts, handlerRegistryRef],
);
[
bindings,
pendingChordRef,
setPendingChord,
activeContexts,
handlerRegistryRef,
],
)
useInput(handleInput);
useInput(handleInput)
return null;
return null
}

View File

@@ -1,10 +1,10 @@
import React, { Children, isValidElement } from 'react';
import { Text } from '../index.js';
import React, { Children, isValidElement } from 'react'
import { Text } from '../index.js'
type Props = {
/** The items to join with a middot separator */
children: React.ReactNode;
};
children: React.ReactNode
}
/**
* Joins children with a middot separator (" · ") for inline metadata display.
@@ -36,20 +36,22 @@ type Props = {
*/
export function Byline({ children }: Props): React.ReactNode {
// Children.toArray already filters out null, undefined, and booleans
const validChildren = Children.toArray(children);
const validChildren = Children.toArray(children)
if (validChildren.length === 0) {
return null;
return null
}
return (
<>
{validChildren.map((child, index) => (
<React.Fragment key={isValidElement(child) ? (child.key ?? index) : index}>
<React.Fragment
key={isValidElement(child) ? (child.key ?? index) : index}
>
{index > 0 && <Text dimColor> · </Text>}
{child}
</React.Fragment>
))}
</>
);
)
}

View File

@@ -6,18 +6,30 @@
* internal theme components.
*/
import React from 'react';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import React from 'react'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
type Props = {
action: string;
context: string;
fallback: string;
description: string;
parens?: boolean;
bold?: boolean;
};
export function ConfigurableShortcutHint({ fallback, description, parens, bold }: Props): React.ReactNode {
return <KeyboardShortcutHint shortcut={fallback} action={description} parens={parens} bold={bold} />;
action: string
context: string
fallback: string
description: string
parens?: boolean
bold?: boolean
}
export function ConfigurableShortcutHint({
fallback,
description,
parens,
bold,
}: Props): React.ReactNode {
return (
<KeyboardShortcutHint
shortcut={fallback}
action={description}
parens={parens}
bold={bold}
/>
)
}

View File

@@ -1,23 +1,26 @@
import React from 'react';
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCD.js';
import { Box, Text } from '../index.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Theme } from './theme-types.js';
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { Pane } from './Pane.js';
import React from 'react'
import {
type ExitState,
useExitOnCtrlCDWithKeybindings,
} from '../hooks/useExitOnCtrlCD.js'
import { Box, Text } from '../index.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { Theme } from './theme-types.js'
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { Pane } from './Pane.js'
type DialogProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
onCancel: () => void;
color?: keyof Theme;
hideInputGuide?: boolean;
hideBorder?: boolean;
title: React.ReactNode
subtitle?: React.ReactNode
children: React.ReactNode
onCancel: () => void
color?: keyof Theme
hideInputGuide?: boolean
hideBorder?: boolean
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
inputGuide?: (exitState: ExitState) => React.ReactNode;
inputGuide?: (exitState: ExitState) => React.ReactNode
/**
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
@@ -25,8 +28,8 @@ type DialogProps = {
* consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on
* press, delete-forward on ctrl+d with text). Defaults to `true`.
*/
isCancelActive?: boolean;
};
isCancelActive?: boolean
}
export function Dialog({
title,
@@ -39,7 +42,11 @@ export function Dialog({
inputGuide,
isCancelActive = true,
}: DialogProps): React.ReactNode {
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
const exitState = useExitOnCtrlCDWithKeybindings(
undefined,
undefined,
isCancelActive,
)
// Use configurable keybinding for ESC to cancel.
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
@@ -48,16 +55,21 @@ export function Dialog({
useKeybinding('confirm:no', onCancel, {
context: 'Confirmation',
isActive: isCancelActive,
});
})
const defaultInputGuide = exitState.pending ? (
<Text>Press {exitState.keyName} again to exit</Text>
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
</Byline>
);
)
const content = (
<>
@@ -78,11 +90,11 @@ export function Dialog({
</Box>
)}
</>
);
)
if (hideBorder) {
return content;
return content
}
return <Pane color={color}>{content}</Pane>;
return <Pane color={color}>{content}</Pane>
}

View File

@@ -1,33 +1,33 @@
import React from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { stringWidth } from '../core/stringWidth.js';
import { Ansi, Text } from '../index.js';
import type { Theme } from './theme-types.js';
import React from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { stringWidth } from '../core/stringWidth.js'
import { Ansi, Text } from '../index.js'
import type { Theme } from './theme-types.js'
type DividerProps = {
/**
* Width of the divider in characters.
* Defaults to terminal width.
*/
width?: number;
width?: number
/**
* Theme color for the divider.
* If not provided, dimColor is used.
*/
color?: keyof Theme;
color?: keyof Theme
/**
* Character to use for the divider line.
* @default '─'
*/
char?: string;
char?: string
/**
* Padding to subtract from the width (e.g., for indentation).
* @default 0
*/
padding?: number;
padding?: number
/**
* Title shown in the middle of the divider.
@@ -37,8 +37,8 @@ type DividerProps = {
* // ─────────── Title ───────────
* <Divider title="Title" />
*/
title?: string;
};
title?: string
}
/**
* A horizontal divider line.
@@ -63,15 +63,21 @@ type DividerProps = {
* // With centered title
* <Divider title="3 new messages" />
*/
export function Divider({ width, color, char = '─', padding = 0, title }: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize();
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding);
export function Divider({
width,
color,
char = '─',
padding = 0,
title,
}: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize()
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
if (title) {
const titleWidth = stringWidth(title) + 2; // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
const leftWidth = Math.floor(sideWidth / 2);
const rightWidth = sideWidth - leftWidth;
const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
const leftWidth = Math.floor(sideWidth / 2)
const rightWidth = sideWidth - leftWidth
return (
<Text color={color} dimColor={!color}>
{char.repeat(leftWidth)}{' '}
@@ -80,12 +86,12 @@ export function Divider({ width, color, char = '─', padding = 0, title }: Divi
</Text>{' '}
{char.repeat(rightWidth)}
</Text>
);
)
}
return (
<Text color={color} dimColor={!color}>
{char.repeat(effectiveWidth)}
</Text>
);
)
}

View File

@@ -1,72 +1,72 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import { clamp } from '../core/layout/geometry.js';
import { Box, Text, useTerminalFocus } from '../index.js';
import { SearchBox } from './SearchBox.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { ListItem } from './ListItem.js';
import { Pane } from './Pane.js';
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import { clamp } from '../core/layout/geometry.js'
import { Box, Text, useTerminalFocus } from '../index.js'
import { SearchBox } from './SearchBox.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { ListItem } from './ListItem.js'
import { Pane } from './Pane.js'
type PickerAction<T> = {
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
action: string;
handler: (item: T) => void;
};
action: string
handler: (item: T) => void
}
type Props<T> = {
title: string;
placeholder?: string;
initialQuery?: string;
items: readonly T[];
getKey: (item: T) => string;
title: string
placeholder?: string
initialQuery?: string
items: readonly T[]
getKey: (item: T) => string
/** Keep to one line — preview handles overflow. */
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
renderPreview?: (item: T) => React.ReactNode;
renderItem: (item: T, isFocused: boolean) => React.ReactNode
renderPreview?: (item: T) => React.ReactNode
/** 'right' keeps hints stable (no bounce), but needs width. */
previewPosition?: 'bottom' | 'right';
visibleCount?: number;
previewPosition?: 'bottom' | 'right'
visibleCount?: number
/**
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
* always match screen direction — ↑ walks visually up regardless.
*/
direction?: 'down' | 'up';
direction?: 'down' | 'up'
/** Caller owns filtering: re-filter on each call and pass new items. */
onQueryChange: (query: string) => void;
onQueryChange: (query: string) => void
/** Enter key. Primary action. */
onSelect: (item: T) => void;
onSelect: (item: T) => void
/**
* Tab key. If provided, Tab no longer aliases Enter — it gets its own
* handler and hint. Shift+Tab falls through to this if onShiftTab is unset.
*/
onTab?: PickerAction<T>;
onTab?: PickerAction<T>
/** Shift+Tab key. Gets its own hint. */
onShiftTab?: PickerAction<T>;
onShiftTab?: PickerAction<T>
/**
* Fires when the focused item changes (via arrows or when items reset).
* Useful for async preview loading — keeps I/O out of renderPreview.
*/
onFocus?: (item: T | undefined) => void;
onCancel: () => void;
onFocus?: (item: T | undefined) => void
onCancel: () => void
/** Shown when items is empty. Caller bakes loading/searching state into this. */
emptyMessage?: string | ((query: string) => string);
emptyMessage?: string | ((query: string) => string)
/**
* Status line below the list, e.g. "500+ matches" or "42 matches…".
* Caller decides when to show it — pass undefined to hide.
*/
matchLabel?: string;
selectAction?: string;
extraHints?: React.ReactNode;
};
matchLabel?: string
selectAction?: string
extraHints?: React.ReactNode
}
const DEFAULT_VISIBLE = 8;
const DEFAULT_VISIBLE = 8
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
const CHROME_ROWS = 10;
const MIN_VISIBLE = 2;
const CHROME_ROWS = 10
const MIN_VISIBLE = 2
export function FuzzyPicker<T>({
title,
@@ -90,22 +90,25 @@ export function FuzzyPicker<T>({
selectAction = 'select',
extraHints,
}: Props<T>): React.ReactNode {
const isTerminalFocused = useTerminalFocus();
const { rows, columns } = useTerminalSize();
const [focusedIndex, setFocusedIndex] = useState(0);
const isTerminalFocused = useTerminalFocus()
const { rows, columns } = useTerminalSize()
const [focusedIndex, setFocusedIndex] = useState(0)
// Cap visibleCount so the picker never exceeds the terminal height. When it
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
// by the overflow amount and a previously-drawn line flashes blank.
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
const visibleCount = Math.max(
MIN_VISIBLE,
Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)),
)
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
// below that. Compact mode drops shift+tab and shortens labels.
const compact = columns < 120;
const compact = columns < 120
const step = (delta: 1 | -1) => {
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1));
};
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1))
}
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
@@ -117,62 +120,67 @@ export function FuzzyPicker<T>({
onCancel,
initialQuery,
backspaceExitsOnEmpty: false,
});
})
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? 1 : -1);
return;
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? 1 : -1)
return
}
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? -1 : 1);
return;
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? -1 : 1)
return
}
if (e.key === 'return') {
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (selected) onSelect(selected);
return;
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (selected) onSelect(selected)
return
}
if (e.key === 'tab') {
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (!selected) return;
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab;
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (!selected) return
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab
if (tabAction) {
tabAction.handler(selected);
tabAction.handler(selected)
} else {
onSelect(selected);
onSelect(selected)
}
}
};
}
useEffect(() => {
onQueryChange(query);
setFocusedIndex(0);
onQueryChange(query)
setFocusedIndex(0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
}, [query])
useEffect(() => {
setFocusedIndex(i => clamp(i, 0, items.length - 1));
}, [items.length]);
setFocusedIndex(i => clamp(i, 0, items.length - 1))
}, [items.length])
const focused = items[focusedIndex];
const focused = items[focusedIndex]
useEffect(() => {
onFocus?.(focused);
onFocus?.(focused)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused]);
}, [focused])
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
const visible = items.slice(windowStart, windowStart + visibleCount);
const windowStart = clamp(
focusedIndex - visibleCount + 1,
0,
items.length - visibleCount,
)
const visible = items.slice(windowStart, windowStart + visibleCount)
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
const emptyText =
typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage
const searchBox = (
<SearchBox
@@ -182,7 +190,7 @@ export function FuzzyPicker<T>({
isFocused
isTerminalFocused={isTerminalFocused}
/>
);
)
const listBlock = (
<List
@@ -196,21 +204,25 @@ export function FuzzyPicker<T>({
renderItem={renderItem}
emptyText={emptyText}
/>
);
)
const preview =
renderPreview && focused ? (
<Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box>
) : null;
) : null
// Structure must not depend on preview truthiness — when focused goes
// undefined (e.g. delete clears matches), switching row→fragment would
// change both layout AND gap count, bouncing the searchBox below.
const listGroup =
renderPreview && previewPosition === 'right' ? (
<Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
<Box
flexDirection="row"
gap={2}
height={visibleCount + (matchLabel ? 1 : 0)}
>
<Box flexDirection="column" flexShrink={0}>
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
@@ -226,12 +238,18 @@ export function FuzzyPicker<T>({
{matchLabel && <Text dimColor>{matchLabel}</Text>}
{preview}
</Box>
);
)
const inputAbove = direction !== 'up';
const inputAbove = direction !== 'up'
return (
<Pane color="permission">
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Text bold color="permission">
{title}
</Text>
@@ -240,26 +258,42 @@ export function FuzzyPicker<T>({
{!inputAbove && searchBox}
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
<KeyboardShortcutHint
shortcut="↑/↓"
action={compact ? 'nav' : 'navigate'}
/>
<KeyboardShortcutHint
shortcut="Enter"
action={compact ? firstWord(selectAction) : selectAction}
/>
{onTab && (
<KeyboardShortcutHint shortcut="Tab" action={onTab.action} />
)}
{onShiftTab && !compact && (
<KeyboardShortcutHint
shortcut="shift+tab"
action={onShiftTab.action}
/>
)}
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
{extraHints}
</Byline>
</Text>
</Box>
</Pane>
);
)
}
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
visible: readonly T[];
windowStart: number;
total: number;
focusedIndex: number;
emptyText: string;
};
type ListProps<T> = Pick<
Props<T>,
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
> & {
visible: readonly T[]
windowStart: number
total: number
focusedIndex: number
emptyText: string
}
function List<T>({
visible,
@@ -277,14 +311,15 @@ function List<T>({
<Box height={visibleCount} flexShrink={0}>
<Text dimColor>{emptyText}</Text>
</Box>
);
)
}
const rows = visible.map((item, i) => {
const actualIndex = windowStart + i;
const isFocused = actualIndex === focusedIndex;
const atLowEdge = i === 0 && windowStart > 0;
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total;
const actualIndex = windowStart + i
const isFocused = actualIndex === focusedIndex
const atLowEdge = i === 0 && windowStart > 0
const atHighEdge =
i === visible.length - 1 && windowStart + visibleCount! < total
return (
<ListItem
key={getKey(item)}
@@ -295,17 +330,21 @@ function List<T>({
>
{renderItem(item, isFocused)}
</ListItem>
);
});
)
})
return (
<Box height={visibleCount} flexShrink={0} flexDirection={direction === 'up' ? 'column-reverse' : 'column'}>
<Box
height={visibleCount}
flexShrink={0}
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
>
{rows}
</Box>
);
)
}
function firstWord(s: string): string {
const i = s.indexOf(' ');
return i === -1 ? s : s.slice(0, i);
const i = s.indexOf(' ')
return i === -1 ? s : s.slice(0, i)
}

View File

@@ -1,16 +1,16 @@
import React from 'react';
import Text from '../components/Text.js';
import React from 'react'
import Text from '../components/Text.js'
type Props = {
/** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */
shortcut: string;
shortcut: string
/** The action the key performs (e.g., "expand", "select", "navigate") */
action: string;
action: string
/** Whether to wrap the hint in parentheses. Default: false */
parens?: boolean;
parens?: boolean
/** Whether to render the shortcut in bold. Default: false */
bold?: boolean;
};
bold?: boolean
}
/**
* Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)"
@@ -35,19 +35,24 @@ type Props = {
* </Byline>
* </Text>
*/
export function KeyboardShortcutHint({ shortcut, action, parens = false, bold = false }: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut;
export function KeyboardShortcutHint({
shortcut,
action,
parens = false,
bold = false,
}: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
if (parens) {
return (
<Text>
({shortcutText} to {action})
</Text>
);
)
}
return (
<Text>
{shortcutText} to {action}
</Text>
);
)
}

View File

@@ -1,44 +1,44 @@
import figures from 'figures';
import type { ReactNode } from 'react';
import React from 'react';
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js';
import { Box, Text } from '../index.js';
import figures from 'figures'
import type { ReactNode } from 'react'
import React from 'react'
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js'
import { Box, Text } from '../index.js'
type ListItemProps = {
/**
* Whether this item is currently focused (keyboard selection).
* Shows the pointer indicator () when true.
*/
isFocused: boolean;
isFocused: boolean
/**
* Whether this item is selected (chosen/checked).
* Shows the checkmark indicator (✓) when true.
* @default false
*/
isSelected?: boolean;
isSelected?: boolean
/**
* The content to display for this item.
*/
children: ReactNode;
children: ReactNode
/**
* Optional description text displayed below the main content.
*/
description?: string;
description?: string
/**
* Show a down arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollDown?: boolean;
showScrollDown?: boolean
/**
* Show an up arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollUp?: boolean;
showScrollUp?: boolean
/**
* Whether to apply automatic styling to the children based on focus/selection state.
@@ -46,21 +46,21 @@ type ListItemProps = {
* - When false: children are rendered as-is, allowing custom styling
* @default true
*/
styled?: boolean;
styled?: boolean
/**
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
* @default false
*/
disabled?: boolean;
disabled?: boolean
/**
* Whether this ListItem should declare the terminal cursor position.
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
* @default true
*/
declareCursor?: boolean;
};
declareCursor?: boolean
}
/**
* A list item component for selection UIs (dropdowns, multi-selects, menus).
@@ -115,46 +115,46 @@ export function ListItem({
// Determine which indicator to show
function renderIndicator(): ReactNode {
if (disabled) {
return <Text> </Text>;
return <Text> </Text>
}
if (isFocused) {
return <Text color="suggestion">{figures.pointer}</Text>;
return <Text color="suggestion">{figures.pointer}</Text>
}
if (showScrollDown) {
return <Text dimColor>{figures.arrowDown}</Text>;
return <Text dimColor>{figures.arrowDown}</Text>
}
if (showScrollUp) {
return <Text dimColor>{figures.arrowUp}</Text>;
return <Text dimColor>{figures.arrowUp}</Text>
}
return <Text> </Text>;
return <Text> </Text>
}
// Determine text color based on state
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
if (disabled) {
return 'inactive';
return 'inactive'
}
if (!styled) {
return undefined;
return undefined
}
if (isSelected) {
return 'success';
return 'success'
}
if (isFocused) {
return 'suggestion';
return 'suggestion'
}
return undefined;
return undefined
}
const textColor = getTextColor();
const textColor = getTextColor()
// Park the native terminal cursor on the pointer indicator so screen
// readers / magnifiers track the focused item. (0,0) is the top-left of
@@ -163,7 +163,7 @@ export function ListItem({
line: 0,
column: 0,
active: isFocused && !disabled && declareCursor !== false,
});
})
return (
<Box ref={cursorRef} flexDirection="column">
@@ -184,5 +184,5 @@ export function ListItem({
</Box>
)}
</Box>
);
)
}

View File

@@ -1,30 +1,30 @@
import React from 'react';
import { Box, Text } from '../index.js';
import { Spinner } from './Spinner.js';
import React from 'react'
import { Box, Text } from '../index.js'
import { Spinner } from './Spinner.js'
type LoadingStateProps = {
/**
* The loading message to display next to the spinner.
*/
message: string;
message: string
/**
* Display the message in bold.
* @default false
*/
bold?: boolean;
bold?: boolean
/**
* Display the message in dimmed color.
* @default false
*/
dimColor?: boolean;
dimColor?: boolean
/**
* Optional subtitle displayed below the main message.
*/
subtitle?: string;
};
subtitle?: string
}
/**
* A spinner with loading message for async operations.
@@ -62,5 +62,5 @@ export function LoadingState({
</Box>
{subtitle && <Text dimColor>{subtitle}</Text>}
</Box>
);
)
}

View File

@@ -1,16 +1,16 @@
import React from 'react';
import { useIsInsideModal } from './modalContext.js';
import { Box } from '../index.js';
import type { Theme } from './theme-types.js';
import { Divider } from './Divider.js';
import React from 'react'
import { useIsInsideModal } from './modalContext.js'
import { Box } from '../index.js'
import type { Theme } from './theme-types.js'
import { Divider } from './Divider.js'
type PaneProps = {
children: React.ReactNode;
children: React.ReactNode
/**
* Theme color for the top border line.
*/
color?: keyof Theme;
};
color?: keyof Theme
}
/**
* A pane — a region of the terminal that appears below the REPL prompt,
@@ -44,7 +44,7 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
<Box flexDirection="column" paddingX={1} flexShrink={0}>
{children}
</Box>
);
)
}
return (
<Box flexDirection="column" paddingTop={1}>
@@ -53,5 +53,5 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
{children}
</Box>
</Box>
);
)
}

Some files were not shown because too many files have changed in this diff Show More