refactor(server): canonicalize service API (#31049)

This commit is contained in:
Dax 2026-06-06 23:27:28 -04:00 committed by GitHub
parent 53ff1b57c9
commit fe0c4f8c74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
388 changed files with 7103 additions and 4092 deletions

View File

@ -94,8 +94,12 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"effect": "catalog:", "effect": "catalog:",
"solid-js": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
@ -531,6 +535,7 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
@ -787,6 +792,31 @@
"vite": "catalog:", "vite": "catalog:",
}, },
}, },
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.16.2", "version": "1.16.2",
@ -1776,6 +1806,8 @@
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
@ -3336,7 +3368,7 @@
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@ -5722,6 +5754,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@ -5730,8 +5764,6 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@ -5920,6 +5952,8 @@
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], "dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@ -5978,10 +6012,12 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
@ -6388,6 +6424,8 @@
"@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="], "@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="],
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],

1
packages/cli/bunfig.toml Normal file
View File

@ -0,0 +1 @@
preload = ["@opentui/solid/preload"]

View File

@ -20,8 +20,12 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"effect": "catalog:" "effect": "catalog:",
"solid-js": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",

View File

@ -1,8 +1,12 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises" import { rm } from "fs/promises"
import path from "path" import path from "path"
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import pkg from "../package.json"
import { modelsData } from "./generate" import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
@ -13,7 +17,9 @@ await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single") const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline") const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps") const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const allTargets: { const allTargets: {
os: string os: string
@ -43,6 +49,12 @@ const targets = singleFlag
}) })
: allTargets : allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) { for (const item of targets) {
const target = [ const target = [
binary, binary,
@ -56,8 +68,9 @@ for (const item of targets) {
const name = target.replace(binary, "cli") const name = target.replace(binary, "cli")
console.log(`building ${name}`) console.log(`building ${name}`)
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/index.ts"], entrypoints: ["./src/index.ts", parserWorker],
tsconfig: "./tsconfig.json", tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"], external: ["node-gyp"],
format: "esm", format: "esm",
minify: true, minify: true,
@ -79,6 +92,11 @@ for (const item of targets) {
OPENCODE_MODELS_DEV: modelsData, OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`, OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
}, },
}) })

View File

@ -0,0 +1,13 @@
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(Commands, () =>
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* Effect.promise(() => runTui(transport))
}),
)

View File

@ -1,4 +1,5 @@
import { NodeHttpServer } from "@effect/platform-node" import { NodeHttpServer } from "@effect/platform-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect" import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
@ -34,6 +35,7 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build( return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })), Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(PermissionSaved.defaultLayer),
), ),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address)) ).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
} }

View File

@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
} }
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand { function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const spec: Command.Command.Any = Object.keys(node.commands).length const handler = handlers.find((handler) => handler.spec === node.spec)
? (node.spec as Command.Command<string, unknown>).pipe( const spec = handler
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))), ? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
) )
: node.spec : node.spec
const handler = handlers.find((handler) => handler.spec === node.spec) if (!Object.keys(node.commands).length) return spec as ProvidedCommand
if (!handler) return spec as ProvidedCommand
return spec.pipe( return spec.pipe(
Command.withHandler((input) => Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
) as ProvidedCommand ) as ProvidedCommand
} }

View File

@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon" import { Daemon } from "./services/daemon"
const Handlers = Runtime.handlers(Commands, { const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
debug: { debug: {
agents: () => import("./commands/handlers/debug/agents"), agents: () => import("./commands/handlers/debug/agents"),
}, },

View File

@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http" import { HttpServer } from "effect/unstable/http"
import { randomBytes, randomUUID } from "crypto" import { randomBytes, randomUUID } from "crypto"
import { spawn } from "node:child_process"
import path from "path" import path from "path"
export interface Interface { export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown> readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
readonly start: () => Effect.Effect<string, Error> readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined> readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown> readonly stop: () => Effect.Effect<void, unknown>
@ -108,16 +110,20 @@ export const layer = Layer.effect(
const start = Effect.fn("cli.daemon.start")(function* () { const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option) const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing) const found = Option.getOrUndefined(existing)
if (found?.version === InstallationVersion) return found.url const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore) if (found) yield* stopProcess(found).pipe(Effect.ignore)
yield* Effect.sync(() => { const entrypoint = compiled ? undefined : process.argv[1]
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], { yield* Effect.try({
stdin: "ignore", try: () => {
stdout: "ignore", spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
stderr: "ignore", detached: true,
}).unref() stdio: "ignore",
}).unref()
},
catch: (cause) => new Error("Failed to start server", { cause }),
}) })
return yield* compatible().pipe( return yield* compatible().pipe(
@ -127,8 +133,13 @@ export const layer = Layer.effect(
) )
}) })
const transport = Effect.fn("cli.daemon.transport")(function* () {
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
})
const client = Effect.fn("cli.daemon.client")(function* () { const client = Effect.fn("cli.daemon.client")(function* () {
return yield* createClient(yield* start()) const connection = yield* transport()
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
}) })
const status = Effect.fn("cli.daemon.status")(function* () { const status = Effect.fn("cli.daemon.status")(function* () {
@ -173,7 +184,7 @@ export const layer = Layer.effect(
) )
}) })
return Service.of({ client, start, status, stop, password, register }) return Service.of({ client, transport, start, status, stop, password, register })
}), }),
) )

115
packages/cli/src/tui.ts Normal file
View File

@ -0,0 +1,115 @@
import { createTuiBuildInfo, createTuiEnvironment, createTuiRenderer, run, type TuiHost } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import type { TuiPlatform } from "@opencode-ai/tui/platform"
import os from "node:os"
import path from "node:path"
declare const OPENCODE_VERSION: string | undefined
declare const OPENCODE_CHANNEL: string | undefined
export async function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
const state = path.join(os.homedir(), ".local", "state", "opencode")
const environment = createTuiEnvironment({
cwd: process.cwd(),
platform: process.platform,
paths: {
home: os.homedir(),
state,
worktree: path.join(state, "worktree"),
},
capabilities: {
mouse: config.mouse,
copyOnSelect: true,
terminalTitle: true,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: {
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
},
editor: { zedTerminal: false },
skipInitialLoading: false,
})
const build = createTuiBuildInfo({
version: typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local",
channel: typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local",
})
const renderer = await createTuiRenderer(config, { environment, build })
const handle = run({
...transport,
args: {},
config,
environment,
build,
renderer,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
host: createHost(),
})
await handle.done
}
function createHost(): TuiHost {
return {
platform,
attention() {
return {
async notify() {
return { ok: false, notification: false, sound: false, skipped: "attention_disabled" }
},
soundboard: {
registerPack: () => () => {},
activate: () => false,
current: () => "",
list: () => [],
},
dispose() {},
}
},
logger: { error: (message, extra) => console.error(message, extra ?? "") },
lifecycle: {
onSighup(handler) {
process.on("SIGHUP", handler)
return () => process.off("SIGHUP", handler)
},
writeStdout: (text) => process.stdout.write(text),
writeStderr: (text) => process.stderr.write(text),
},
formatError: () => undefined,
formatUnknownError(error) {
if (error instanceof Error) return error.message
return String(error)
},
}
}
const platform: TuiPlatform = {
files: {
readText: (file) => Bun.file(file).text(),
readBytes: async (file) => new Uint8Array(await Bun.file(file).arrayBuffer()),
async mime(file) {
return Bun.file(file).type || "application/octet-stream"
},
},
}
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
}, { preconnect: fetch.preconnect })

View File

@ -2,6 +2,8 @@
"$schema": "https://json.schemastore.org/tsconfig", "$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json", "extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false "noUncheckedIndexedAccess": false
} }

View File

@ -63,7 +63,7 @@ export type Editor = {
export interface Interface { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"] readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope> readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined> readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined> readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@ -113,10 +113,7 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
update: Effect.fn("AgentV2.update")(function* (update) { update: state.update,
const transform = yield* state.transform()
yield* transform(update)
}),
get: Effect.fn("AgentV2.get")(function* (id) { get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id) return state.get().agents.get(id)
}), }),

View File

@ -203,7 +203,7 @@ export const layer = Layer.effect(
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID, event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
), ),
Stream.runForEach((event) => Stream.runForEach((event) =>
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
), ),
Effect.forkIn(scope, { startImmediately: true }), Effect.forkIn(scope, { startImmediately: true }),
) )

View File

@ -27,6 +27,7 @@ export type Editor = {
export interface Interface { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"] readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined> readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@ -54,6 +55,7 @@ export const layer = Layer.effect(
}) })
return Service.of({ return Service.of({
update: state.update,
transform: state.transform, transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) { get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name) return state.get().commands.get(name)

View File

@ -6,6 +6,7 @@ import { Git } from "../git"
import { Location } from "../location" import { Location } from "../location"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import { SessionV2 } from "../session" import { SessionV2 } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionEvent } from "../session/event" import { SessionEvent } from "../session/event"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { AbsolutePath, RelativePath } from "../schema" import { AbsolutePath, RelativePath } from "../schema"
@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer), Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionV2.defaultLayer), Layer.provide(SessionV2.defaultLayer),
) )

View File

@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
return files.filter((file): file is File => file !== undefined) return files.filter((file): file is File => file !== undefined)
}) })
yield* registry.contribute({ yield* registry.register({
key, key,
load: observe().pipe( load: observe().pipe(
Effect.map((files) => Effect.map((files) =>

View File

@ -425,20 +425,12 @@ export const layer = Layer.effect(
}), }),
) )
const DefaultDatabase = Database.defaultLayer
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide( Layer.provide(SessionExecution.noopLayer),
Layer.mergeAll( Layer.provide(SessionStore.defaultLayer),
DefaultDatabase, Layer.provide(SessionProjector.defaultLayer),
DefaultEvents, Layer.provide(EventV2.defaultLayer),
DefaultProjector, Layer.provide(Database.defaultLayer),
DefaultStore, Layer.provide(ProjectV2.defaultLayer),
SessionExecution.noopLayer,
ProjectV2.defaultLayer,
),
),
Layer.orDie, Layer.orDie,
) )

View File

@ -31,3 +31,5 @@ export const layer = Layer.effect(
}) })
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))

View File

@ -58,3 +58,5 @@ export const layer = Layer.effect(
}) })
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

View File

@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer" import type { Draft, Objectish } from "immer"
/** /**
* A replayable contribution applied to an editor during rebuild. * A replayable transform applied to an editor during rebuild.
* *
* Transforms are intentionally synchronous and mutation-shaped: domain editors * Transforms are intentionally synchronous and mutation-shaped: domain editors
* hide the draft representation while preserving concise plugin/config code. * hide the draft representation while preserving concise plugin/config code.
@ -39,15 +39,17 @@ export interface Interface<State extends Objectish, Editor> {
* registration order. Closing the owning Scope removes the slot and rebuilds. * registration order. Closing the owning Scope removes the slot and rebuilds.
*/ */
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope> readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
/** Registers and applies a replayable transform in the current Scope. */
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
/** /**
* Mutates the current materialized state directly. * Mutates the current materialized state directly, once.
* *
* This is not replayable contribution state: a later rebuild starts again * This is not replayable transform state: a later rebuild starts again
* from `initial()` plus active transforms, so direct edits must be reserved * from `initial()` plus active transforms, so direct edits must be reserved
* for current-state adjustments that are intentionally outside the transform * for current-state adjustments that are intentionally outside the transform
* fold. * fold.
*/ */
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void> readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
} }
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> { export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
@ -69,7 +71,7 @@ export function create<State extends Objectish, Editor>(options: Options<State,
yield* commit(next) yield* commit(next)
}) })
return { const result: Interface<State, Editor> = {
get: () => state, get: () => state,
transform: Effect.fn("State.transform")(function* () { transform: Effect.fn("State.transform")(function* () {
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
@ -96,10 +98,15 @@ export function create<State extends Objectish, Editor>(options: Options<State,
}), }),
) )
}), }),
update: Effect.fn("State.update")(function* (update, reason) { update: Effect.fn("State.update")(function* (update) {
const transform = yield* result.transform()
yield* transform(update)
}),
mutate: Effect.fn("State.mutate")(function* (update, reason) {
const api = options.editor(state as Draft<State>) const api = options.editor(state as Draft<State>)
yield* update(api) yield* update(api)
if (options.finalize) yield* options.finalize(api, reason) if (options.finalize) yield* options.finalize(api, reason)
}, semaphore.withPermit), }, semaphore.withPermit),
} }
return result
} }

View File

@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
}), }),
]) ])
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) }) yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
}), }),
) )

View File

@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
import { Context, Effect, Layer, Ref, Scope } from "effect" import { Context, Effect, Layer, Ref, Scope } from "effect"
import { SystemContext } from "./index" import { SystemContext } from "./index"
export interface Contribution { export interface Entry {
readonly key: SystemContext.Key readonly key: SystemContext.Key
readonly load: Effect.Effect<SystemContext.SystemContext> readonly load: Effect.Effect<SystemContext.SystemContext>
} }
export interface Interface { export interface Interface {
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope> readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
readonly load: () => Effect.Effect<SystemContext.SystemContext> readonly load: () => Effect.Effect<SystemContext.SystemContext>
} }
@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([]) const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
return Service.of({ return Service.of({
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) { register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Ref.modify(contributions, (current) => { Ref.modify(entries, (current) => {
if (current.some((item) => item.key === contribution.key)) return [false, current] if (current.some((item) => item.key === entry.key)) return [false, current]
return [true, [...current, contribution]] return [true, [...current, entry]]
}).pipe( }).pipe(
Effect.flatMap((added) => Effect.flatMap((added) =>
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`), added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
), ),
Effect.as(contribution), Effect.as(entry),
), ),
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)), (entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
) )
}), }),
load: Effect.fn("SystemContextRegistry.load")(function* () { load: Effect.fn("SystemContextRegistry.load")(function* () {
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return SystemContext.combine( return SystemContext.combine(
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }), yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
) )
}), }),
}) })

View File

@ -15,7 +15,7 @@ import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write" import { WriteTool } from "./write"
/** /**
* Composes only the shipped Location-scoped built-in tool contributions. * Composes only the shipped Location-scoped built-in tool transforms.
* Each tool retains its implementation and focused tests independently. Dynamic * Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped canonical registrations, while * MCP and plugin tools later use separate scoped canonical registrations, while
* provider/model filtering belongs to a future materialization phase rather * provider/model filtering belongs to a future materialization phase rather
@ -25,7 +25,7 @@ import { WriteTool } from "./write"
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy * TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
* parity, task, LSP, * parity, task, LSP,
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
* contributions separate from this static built-in list. * transforms separate from this static built-in list.
*/ */
export const locationLayer = Layer.mergeAll( export const locationLayer = Layer.mergeAll(
ApplyPatchTool.layer, ApplyPatchTool.layer,

View File

@ -59,7 +59,7 @@ describe("AgentV2", () => {
}), }),
) )
it.effect("removes a transform contribution when its scope closes", () => it.effect("removes a transform when its scope closes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped") const id = AgentV2.ID.make("scoped")

View File

@ -45,7 +45,7 @@ describe("PluginV2", () => {
}), }),
) )
it.effect("serializes same-ID additions and leaves one removable contribution", () => it.effect("serializes same-ID additions and leaves one removable attachment", () =>
Effect.gen(function* () { Effect.gen(function* () {
const values = state() const values = state()
const layerScope = yield* Scope.fork(yield* Scope.Scope) const layerScope = yield* Scope.fork(yield* Scope.Scope)

View File

@ -173,7 +173,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.effectDiscard( const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe( SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) => Effect.flatMap((registry) =>
registry.contribute({ registry.register({
key: systemContextKey, key: systemContextKey,
load: Effect.sync(() => load: Effect.sync(() =>
SystemContext.combine( SystemContext.combine(

View File

@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const contribution = (key: string, text: string, sourceKey = key) => ({ const entry = (key: string, text: string, sourceKey = key) => ({
key: SystemContext.Key.make(key), key: SystemContext.Key.make(key),
load: Effect.succeed( load: Effect.succeed(
SystemContext.make({ SystemContext.make({
@ -20,7 +20,7 @@ const contribution = (key: string, text: string, sourceKey = key) => ({
const it = testEffect(SystemContextRegistry.layer) const it = testEffect(SystemContextRegistry.layer)
describe("SystemContextRegistry", () => { describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no contributions", () => it.effect("loads empty system context when there are no entries", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
@ -28,21 +28,21 @@ describe("SystemContextRegistry", () => {
}), }),
) )
it.effect("loads scoped contributions in stable key order", () => it.effect("loads scoped entries in stable key order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/second", "second")) yield* registry.register(entry("test/second", "second"))
yield* registry.contribute(contribution("test/first", "first")) yield* registry.register(entry("test/first", "first"))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond") expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
}), }),
) )
it.effect("re-evaluates contribution producers on each load", () => it.effect("re-evaluates entry producers on each load", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
let loads = 0 let loads = 0
yield* registry.contribute({ yield* registry.register({
key: SystemContext.Key.make("test/dynamic"), key: SystemContext.Key.make("test/dynamic"),
load: Effect.sync(() => { load: Effect.sync(() => {
loads++ loads++
@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
}), }),
) )
it.effect("propagates contribution producer failures", () => it.effect("propagates entry producer failures", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
const failure = new Error("contribution failed") const failure = new Error("entry failed")
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) }) yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
const exit = yield* registry.load().pipe(Effect.exit) const exit = yield* registry.load().pipe(Effect.exit)
@ -70,11 +70,11 @@ describe("SystemContextRegistry", () => {
}), }),
) )
it.effect("rejects duplicate source keys from separate contributions", () => it.effect("rejects duplicate source keys from separate entries", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/first", "first", "test/duplicate")) yield* registry.register(entry("test/first", "first", "test/duplicate"))
yield* registry.contribute(contribution("test/second", "second", "test/duplicate")) yield* registry.register(entry("test/second", "second", "test/duplicate"))
const exit = yield* registry.load().pipe(Effect.exit) const exit = yield* registry.load().pipe(Effect.exit)
@ -86,23 +86,23 @@ describe("SystemContextRegistry", () => {
}), }),
) )
it.effect("rejects duplicate contribution keys", () => it.effect("rejects duplicate entry keys", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
yield* registry.contribute(contribution("test/duplicate", "first")) yield* registry.register(entry("test/duplicate", "first"))
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit) const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key") if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
}), }),
) )
it.effect("removes a contribution when its owning scope closes", () => it.effect("removes an entry when its owning scope closes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* SystemContextRegistry.Service const registry = yield* SystemContextRegistry.Service
const scope = yield* Scope.make() const scope = yield* Scope.make()
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope)) yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped") expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")

View File

@ -89,6 +89,7 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",

View File

@ -1,386 +1 @@
export default { export { default } from "@opencode-ai/tui/parsers-config"
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
parsers: [
{
filetype: "python",
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
// Unclear.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
],
},
},
{
filetype: "rust",
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
],
},
},
{
filetype: "go",
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
],
},
},
{
filetype: "cpp",
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
],
},
},
{
filetype: "csharp",
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
],
},
},
{
filetype: "bash",
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
],
},
},
{
filetype: "c",
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
],
},
},
{
filetype: "java",
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
],
},
},
{
filetype: "kotlin",
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
},
},
{
filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
],
},
},
{
filetype: "php",
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
],
},
},
{
filetype: "scala",
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
],
},
},
{
filetype: "html",
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
],
// TODO: Injections not working for some reason
// injections: [
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
// ],
},
// injectionMapping: {
// nodeTypes: {
// script_element: "javascript",
// style_element: "css",
// },
// infoStringMap: {
// javascript: "javascript",
// css: "css",
// },
// },
},
{
filetype: "vue",
wasm: "https://github.com/anomalyco/tree-sitter-vue/releases/download/v0.1.2/tree-sitter-vue.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/html_tags/highlights.scm",
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/vue/highlights.scm",
],
},
},
{
filetype: "hcl",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
],
},
},
{
filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
],
},
},
{
filetype: "yaml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
],
},
},
{
filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
],
},
},
{
filetype: "css",
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
],
},
},
{
filetype: "julia",
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
],
},
},
{
filetype: "lua",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
],
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
},
},
{
filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
],
},
},
{
filetype: "clojure",
// temporarily using fork to fix issues
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
],
},
},
{
filetype: "swift",
wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
queries: {
highlights: [
// NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
"https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
],
},
},
{
filetype: "toml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
],
},
},
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
],
},
},
{
filetype: "diff",
aliases: ["udiff", "patch"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm",
],
},
},
{
filetype: "elixir",
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
],
},
},
{
filetype: "fsharp",
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
],
},
},
{
filetype: "r",
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
],
},
},
{
filetype: "make",
aliases: ["makefile"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
],
},
},
{
filetype: "vim",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
],
},
},
{
filetype: "xml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
],
},
},
{
filetype: "agda",
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
],
},
},
],
}

View File

@ -158,7 +158,7 @@ for (const item of targets) {
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js") const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath) const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/cmd/tui/worker.ts" const workerPath = "./src/cli/tui/worker.ts"
// Use platform-specific bunfs root path based on target OS // Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"

View File

@ -2,8 +2,8 @@
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Schema } from "effect" import { Schema } from "effect"
import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema"
type JsonSchema = Record<string, unknown> type JsonSchema = Record<string, unknown>
const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
@ -73,5 +73,5 @@ await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null,
if (tuiFile) { if (tuiFile) {
console.log(tuiFile) console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2)) await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2))
} }

View File

@ -5,7 +5,7 @@ import * as ts from "typescript"
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode" const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
// Get entry file from command line arg or use default // Get entry file from command line arg or use default
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts" const ENTRY_FILE = process.argv[2] || "src/plugin/tui/runtime.ts"
const visited = new Set<string>() const visited = new Set<string>()

View File

@ -1,9 +1,10 @@
import { cmd } from "../cmd" import { cmd } from "./cmd"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
import { errorMessage } from "@/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "./validate-session" import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { resolveTuiRuntime } from "../tui/runtime"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@ -44,7 +45,7 @@ export const AttachCommand = cmd({
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}), }),
handler: async (args) => { handler: async (args) => {
const { TuiConfig } = await import("@/cli/cmd/tui/config/tui") const { TuiConfig } = await import("@/config/tui")
const unguard = win32InstallCtrlCGuard() const unguard = win32InstallCtrlCGuard()
try { try {
win32DisableProcessedInput() win32DisableProcessedInput()
@ -67,6 +68,7 @@ export const AttachCommand = cmd({
})() })()
const headers = ServerAuth.headers({ password: args.password, username: args.username }) const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get() const config = await TuiConfig.get()
const runtime = resolveTuiRuntime(config)
try { try {
await validateSession({ await validateSession({
@ -81,11 +83,16 @@ export const AttachCommand = cmd({
return return
} }
const { createTuiRenderer, tui } = await import("./app") const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
const renderer = await createTuiRenderer(config) const { createLegacyTuiHost } = await import("../tui/host")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
const renderer = await createTuiRenderer(config, runtime)
const handle = tui({ const handle = tui({
...runtime,
url: args.url, url: args.url,
config, config,
host: createLegacyTuiHost(renderer),
pluginHost: createLegacyTuiPluginHost(),
renderer, renderer,
args: { args: {
continue: args.continue, continue: args.continue,

View File

@ -1,48 +1 @@
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) export * from "@opencode-ai/tui/prompt/display"
export function promptOffsetWidth(value: string) {
let width = 0
for (const part of graphemes.segment(value)) {
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
}
return width
}
function displayOffsetIndex(value: string, offset: number) {
if (offset <= 0) return 0
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + promptOffsetWidth(part.segment)
if (next > offset) return part.index
width = next
}
return value.length
}
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
}
export function displayCharAt(value: string, offset: number) {
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + promptOffsetWidth(part.segment)
if (offset === width || offset < next) return part.segment
width = next
}
}
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
const text = displaySlice(value, 0, offset)
const index = text.lastIndexOf("@")
if (index === -1) return
const before = index === 0 ? undefined : text[index - 1]
const query = text.slice(index)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
return promptOffsetWidth(text.slice(0, index))
}
}

View File

@ -22,7 +22,7 @@ import {
movePromptHistory, movePromptHistory,
pushPromptHistory, pushPromptHistory,
} from "./prompt.shared" } from "./prompt.shared"
import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap" import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types" import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"

View File

@ -3,7 +3,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import "opentui-spinner/solid" import "opentui-spinner/solid"
import { Show, createMemo, indexArray } from "solid-js" import { Show, createMemo, indexArray } from "solid-js"
import { SPINNER_FRAMES } from "../tui/component/spinner" import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { RunEntryContent, separatorRows } from "./scrollback.writer" import { RunEntryContent, separatorRows } from "./scrollback.writer"
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types" import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
import type { RunFooterTheme, RunTheme } from "./theme" import type { RunFooterTheme, RunTheme } from "./theme"

View File

@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
import { render } from "@opentui/solid" import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap" import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
import { withRunSpan } from "./otel" import { withRunSpan } from "./otel"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent" import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"

View File

@ -13,7 +13,7 @@
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import "opentui-spinner/solid" import "opentui-spinner/solid"
import { createColors, createFrames } from "../tui/ui/spinner" import { createColors, createFrames } from "@opencode-ai/tui/ui/spinner"
import { import {
RUN_SUBAGENT_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS,
RunCommandMenuBody, RunCommandMenuBody,
@ -33,7 +33,7 @@ import {
useBindings, useBindings,
useKeymapSelector, useKeymapSelector,
type OpenTuiKeymap, type OpenTuiKeymap,
} from "@/cli/cmd/tui/keymap" } from "@opencode-ai/tui/keymap"
import type { import type {
FooterPromptRoute, FooterPromptRoute,
FooterQueuedPrompt, FooterQueuedPrompt,

View File

@ -6,17 +6,14 @@
// history ring. All are async because they read config or hit the SDK, but // history ring. All are async because they read config or hit the SDK, but
// none block each other. // none block each other.
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { createBindingLookup } from "@opentui/keymap/extras" import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/config/tui"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared" import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared" import { resolveSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared" import { pickVariant } from "./variant.shared"
const DEFAULT_LEADER_TIMEOUT = 2000
export type ModelInfo = { export type ModelInfo = {
providers: RunProvider[] providers: RunProvider[]
variants: string[] variants: string[]
@ -70,13 +67,8 @@ function emptySessionInfo(): SessionInfo {
} }
function defaultRunTuiConfig(): RunTuiConfig { function defaultRunTuiConfig(): RunTuiConfig {
const keybinds = TuiKeybind.parse({})
return { return {
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { ...resolve({}, { terminalSuspend: process.platform !== "win32" }),
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader_timeout: DEFAULT_LEADER_TIMEOUT,
diff_style: "auto", diff_style: "auto",
} }
} }

View File

@ -11,7 +11,7 @@
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Session as SessionApi } from "@/session/session" import { Session as SessionApi } from "@/session/session"
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap" import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import * as Locale from "@/util/locale" import * as Locale from "@/util/locale"
import { withRunSpan } from "./otel" import { withRunSpan } from "./otel"
import { resolveInteractiveStdin } from "./runtime.stdin" import { resolveInteractiveStdin } from "./runtime.stdin"

View File

@ -590,7 +590,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
: (renderer.themeMode ?? mode(RGBA.fromHex(bg))) : (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
const theme = resolveTheme(generateSystem(colors, pick), pick) const theme = resolveTheme(generateSystem(colors, pick), pick)
const indexed = indexedPalette(colors, 256) const indexed = indexedPalette(colors, 256)
const shared = await import("../tui/context/theme") const shared = await import("@opencode-ai/tui/context/theme")
const syntaxTheme: SharedSyntaxTheme = { const syntaxTheme: SharedSyntaxTheme = {
...theme, ...theme,
_hasSelectedListItemText: true, _hasSelectedListItemText: true,

View File

@ -12,7 +12,7 @@
// → footer.ts queues commits and patches the footer view // → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal // → OpenTUI split-footer renderer writes to terminal
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui" import type { TuiConfig } from "@opencode-ai/tui/config"
export type RunFilePart = { export type RunFilePart = {
type: "file" type: "file"

View File

@ -1,17 +1,17 @@
import { cmd } from "@/cli/cmd/cmd" import { cmd } from "@/cli/cmd/cmd"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker" import { type rpc } from "../tui/worker"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk" import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { import {
OPENCODE_PROCESS_ROLE, OPENCODE_PROCESS_ROLE,
@ -19,7 +19,8 @@ import {
ensureRunID, ensureRunID,
sanitizedProcessEnv, sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process" } from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session" import { validateSession } from "../tui/validate-session"
import { resolveTuiRuntime } from "../tui/runtime"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@ -57,9 +58,9 @@ function createEventSource(client: RpcClient): EventSource {
async function target() { async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url) const dist = new URL("./cli/tui/worker.js", import.meta.url)
if (await Filesystem.exists(fileURLToPath(dist))) return dist if (await Filesystem.exists(fileURLToPath(dist))) return dist
return new URL("./worker.ts", import.meta.url) return new URL("../tui/worker.ts", import.meta.url)
} }
async function input(value?: string) { async function input(value?: string) {
@ -112,7 +113,7 @@ export const TuiThreadCommand = cmd({
describe: "agent to use", describe: "agent to use",
}), }),
handler: async (args) => { handler: async (args) => {
const { TuiConfig } = await import("./config/tui") const { TuiConfig } = await import("@/config/tui")
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
// (Important when running under `bun run` wrappers on Windows.) // (Important when running under `bun run` wrappers on Windows.)
const unguard = win32InstallCtrlCGuard() const unguard = win32InstallCtrlCGuard()
@ -188,6 +189,7 @@ export const TuiThreadCommand = cmd({
const prompt = await input(args.prompt) const prompt = await input(args.prompt)
const config = await TuiConfig.get() const config = await TuiConfig.get()
const runtime = resolveTuiRuntime(config)
const network = resolveNetworkOptionsNoConfig(args) const network = resolveNetworkOptionsNoConfig(args)
const external = const external =
@ -228,9 +230,12 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.() }, 1000).unref?.()
try { try {
const { createTuiRenderer, tui } = await import("./app") const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
const renderer = await createTuiRenderer(config) const { createLegacyTuiHost } = await import("../tui/host")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
const renderer = await createTuiRenderer(config, runtime)
const handle = tui({ const handle = tui({
...runtime,
url: transport.url, url: transport.url,
renderer, renderer,
async onSnapshot() { async onSnapshot() {
@ -239,6 +244,8 @@ export const TuiThreadCommand = cmd({
return [tui, server] return [tui, server]
}, },
config, config,
host: createLegacyTuiHost(renderer),
pluginHost: createLegacyTuiPluginHost(),
directory: cwd, directory: cwd,
fetch: transport.fetch, fetch: transport.fetch,
events: transport.events, events: transport.events,

View File

@ -1,90 +0,0 @@
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
if (!entry) return 0
const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day
const weight = 1 / (1 + daysSince)
return entry.frequency * weight
}
const MAX_FRECENCY_ENTRIES = 1000
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
name: "Frecency",
init: () => {
const frecencyPath = path.join(Global.Path.state, "frecency.jsonl")
onMount(async () => {
const text = await Filesystem.readText(frecencyPath).catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as { path: string; frequency: number; lastOpen: number }
} catch {
return null
}
})
.filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null)
const latest = lines.reduce(
(acc, entry) => {
acc[entry.path] = entry
return acc
},
{} as Record<string, { path: string; frequency: number; lastOpen: number }>,
)
const sorted = Object.values(latest)
.sort((a, b) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
setStore(
"data",
Object.fromEntries(
sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
),
)
if (sorted.length > 0) {
const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
writeFile(frecencyPath, content).catch(() => {})
}
})
const [store, setStore] = createStore({
data: {} as Record<string, { frequency: number; lastOpen: number }>,
})
function updateFrecency(filePath: string) {
const absolutePath = path.resolve(process.cwd(), filePath)
const newEntry = {
frequency: (store.data[absolutePath]?.frequency || 0) + 1,
lastOpen: Date.now(),
}
setStore("data", absolutePath, newEntry)
appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
const sorted = Object.entries(store.data)
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
setStore("data", Object.fromEntries(sorted))
const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
writeFile(frecencyPath, content).catch(() => {})
}
}
return {
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]),
updateFrecency,
data: () => store.data,
}
},
})

View File

@ -1,31 +0,0 @@
import { PartID } from "@/session/schema"
import { displaySlice } from "@/cli/cmd/prompt-display"
import type { PromptInfo } from "./history"
type Item = PromptInfo["parts"][number]
export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
return rest
}
export function assign(part: Item): Item & { id: PartID } {
return {
...part,
id: PartID.ascending(),
}
}
export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
return parts.reduce((result, part) => {
if (part.type !== "text" || !part.source?.text) return result
return result.replace(part.source.text.value, part.text)
}, text)
}
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
return ranges
.slice()
.sort((a, b) => b.start - a.start)
.reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
}

View File

@ -1,101 +0,0 @@
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { PromptInfo } from "./history"
export type StashEntry = {
input: string
parts: PromptInfo["parts"]
timestamp: number
}
const MAX_STASH_ENTRIES = 50
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
name: "PromptStash",
init: () => {
const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
onMount(async () => {
const text = await Filesystem.readText(stashPath).catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((line): line is StashEntry => line !== null)
.slice(-MAX_STASH_ENTRIES)
setStore("entries", lines)
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashPath, content).catch(() => {})
}
})
const [store, setStore] = createStore({
entries: [] as StashEntry[],
})
return {
list() {
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
let trimmed = false
setStore(
produce((draft) => {
draft.entries.push(stash)
if (draft.entries.length > MAX_STASH_ENTRIES) {
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
trimmed = true
}
}),
)
if (trimmed) {
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashPath, content).catch(() => {})
return
}
appendFile(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
},
pop() {
if (store.entries.length === 0) return undefined
const entry = store.entries[store.entries.length - 1]
setStore(
produce((draft) => {
draft.entries.pop()
}),
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashPath, content).catch(() => {})
return entry
},
remove(index: number) {
if (index < 0 || index >= store.entries.length) return
setStore(
produce((draft) => {
draft.entries.splice(index, 1)
}),
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashPath, content).catch(() => {})
},
}
},
})

View File

@ -1,88 +0,0 @@
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { TuiKeybind } from "./keybind"
import { Schema } from "effect"
import { isRecord } from "@/util/record"
import { Filesystem } from "@/util/filesystem"
import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
export type TuiAttentionSoundPaths = Partial<Record<TuiAttentionSoundName, string>>
export function isAttentionSoundName(value: string): value is TuiAttentionSoundName {
return TuiAttentionSoundNames.includes(value as TuiAttentionSoundName)
}
export function resolveAttentionSoundPaths(
root: string,
sounds: unknown,
options?: { trim?: boolean },
): TuiAttentionSoundPaths {
if (!isRecord(sounds)) return {}
return Object.fromEntries(
Object.entries(sounds).flatMap(([name, file]) => {
if (!isAttentionSoundName(name)) return []
if (typeof file !== "string") return []
const value = options?.trim ? file.trim() : file
if (!value) return []
return [[name, Filesystem.resolveFilePath(root, value)]]
}),
)
}
export const KeymapLeaderTimeoutDefault = 2000
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
description: "Leader key timeout in milliseconds",
})
const TuiAttentionSounds = Schema.Struct({
default: Schema.optional(Schema.String),
question: Schema.optional(Schema.String),
permission: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
done: Schema.optional(Schema.String),
subagent_done: Schema.optional(Schema.String),
})
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
export const ScrollAcceleration = Schema.Struct({
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
}).annotate({ description: "Scroll acceleration settings" })
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})
export const Attention = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
sound: Schema.optional(Schema.Boolean),
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
sound_pack: Schema.optional(Schema.String),
sounds: Schema.optional(TuiAttentionSounds),
}).annotate({ description: "Attention notification and sound settings" })
const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
export const Prompt = Schema.Struct({
max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
}),
}).annotate({ description: "Prompt size settings" })
export const TuiInfo = Schema.Struct({
$schema: Schema.optional(Schema.String),
theme: Schema.optional(Schema.String),
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
plugin: Schema.optional(Schema.Array(ConfigPluginV1.Spec)),
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
leader_timeout: Schema.optional(KeymapLeaderTimeout),
attention: Schema.optional(Attention),
prompt: Schema.optional(Prompt),
scroll_speed: Schema.optional(ScrollSpeed).annotate({
description: "TUI scroll speed",
}),
scroll_acceleration: Schema.optional(ScrollAcceleration),
diff_style: Schema.optional(DiffStyle),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
})

View File

@ -1,9 +0,0 @@
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Resolved }) => {
return props.config
},
})

View File

@ -1,6 +0,0 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/core/npm"
import { Observability } from "@opencode-ai/core/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

View File

@ -1,42 +0,0 @@
import HomeFooter from "../feature-plugins/home/footer"
import HomeTips from "../feature-plugins/home/tips"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarTodo from "../feature-plugins/sidebar/todo"
import SidebarFiles from "../feature-plugins/sidebar/files"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import PluginManager from "../feature-plugins/system/plugins"
import Notifications from "../feature-plugins/system/notifications"
import SessionV2Debug from "../feature-plugins/system/session-v2"
import WhichKey from "../feature-plugins/system/which-key"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import SessionSwitcher from "../feature-plugins/session"
import { Flag } from "@opencode-ai/core/flag/flag"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
tui: TuiPlugin
enabled?: boolean
}
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return [
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarTodo,
SidebarFiles,
SidebarFooter,
Notifications,
PluginManager,
WhichKey,
DiffViewer,
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER ? [SessionSwitcher] : []),
]
}

View File

@ -1,60 +0,0 @@
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import { isRecord } from "@/util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
type Slot = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type HostPluginApi = TuiPluginApi
export type HostSlots = {
register: {
(plugin: HostSlotPlugin): () => void
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
}
}
function empty<Name extends string>(_props: TuiSlotProps<Name>) {
return null
}
let view: Slot = empty
export const Slot: Slot = (props) => view(props)
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
if (!isRecord(value.slots)) return false
return true
}
export function setupSlots(api: HostPluginApi): HostSlots {
const reg = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
api.renderer,
{
theme: api.theme,
},
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(reg)
view = (props) => slot(props)
return {
register(plugin: HostSlotPlugin) {
if (!isHostSlotPlugin(plugin)) return () => {}
return reg.register(plugin)
},
}
}

View File

@ -1,11 +1 @@
export const logo = { export * from "@opencode-ai/tui/logo"
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
}
export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
}
export const marks = "_^~,"

View File

@ -9,10 +9,10 @@ import type {
TuiAttentionSoundPack, TuiAttentionSoundPack,
TuiAttentionSoundPackInfo, TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui" } from "@opencode-ai/plugin/tui"
import { AttentionSoundName, type TuiConfig } from "@opencode-ai/tui/config"
import { Schema } from "effect"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import type { TuiConfig } from "./config/tui" import * as TuiAudio from "./audio"
import { isAttentionSoundName } from "./config/tui-schema"
import * as TuiAudio from "@tui/util/audio"
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" } import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" } import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
@ -100,7 +100,9 @@ function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undef
sounds: Object.fromEntries( sounds: Object.fromEntries(
Object.entries(pack.sounds).filter( Object.entries(pack.sounds).filter(
(item): item is [TuiAttentionSoundName, string] => (item): item is [TuiAttentionSoundName, string] =>
isAttentionSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0, Schema.is(AttentionSoundName)(item[0]) &&
typeof item[1] === "string" &&
item[1].trim().length > 0,
), ),
), ),
} }
@ -198,7 +200,9 @@ export function createTuiAttention(input: {
const requestedSound = typeof request.sound === "object" ? request.sound : undefined const requestedSound = typeof request.sound === "object" ? request.sound : undefined
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus) const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
const soundName = const soundName =
requestedSound?.name && isAttentionSoundName(requestedSound.name) ? requestedSound.name : "default" requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name)
? requestedSound.name
: "default"
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume) const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
if (!notification && !sound) { if (!notification && !sound) {

View File

@ -1,13 +1,13 @@
import { platform, release } from "os" import { platform, release } from "os"
import { lazy } from "../../../../util/lazy.js" import { lazy } from "../../util/lazy.js"
import { tmpdir } from "os" import { tmpdir } from "os"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Effect } from "effect" import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import * as Filesystem from "../../../../util/filesystem" import * as Filesystem from "../../util/filesystem"
import * as Process from "../../../../util/process" import * as Process from "../../util/process"
const writeWithStdin = (cmd: string[], text: string): Promise<void> => const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
Effect.runPromise( Effect.runPromise(

View File

@ -1,9 +1,9 @@
import { Database } from "bun:sqlite" import { Database } from "bun:sqlite"
import { statSync } from "node:fs"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { Option, Schema } from "effect" import { Option, Schema } from "effect"
import { Filesystem } from "@/util/filesystem" import type { EditorSelection } from "@opencode-ai/tui/context/editor"
import type { EditorSelection } from "./editor"
const ZedEditorRowSchema = Schema.Struct({ const ZedEditorRowSchema = Schema.Struct({
item_kind: Schema.String, item_kind: Schema.String,
@ -201,7 +201,7 @@ export function isZedTerminal() {
function isFile(item: string) { function isFile(item: string) {
try { try {
return Filesystem.stat(item)?.isFile() === true return statSync(item).isFile()
} catch { } catch {
return false return false
} }

View File

@ -0,0 +1,36 @@
import type { TuiHost, TuiInput } from "@opencode-ai/tui"
import { Log } from "@opencode-ai/core/util/log"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { createTuiAttention } from "./attention"
import { createLegacyTuiPlatform } from "./platform"
import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
export function createLegacyTuiHost(renderer: TuiInput["renderer"]): TuiHost {
return {
platform: createLegacyTuiPlatform(renderer),
attention: createTuiAttention,
logger: Log.Default,
disposeAudio: TuiAudio.dispose,
formatError: FormatError,
formatUnknownError: FormatUnknownError,
lifecycle: {
prepare() {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
return unguard
},
flushInput: win32FlushInputBuffer,
onSighup(handler) {
process.on("SIGHUP", handler)
return () => process.off("SIGHUP", handler)
},
writeStdout: (text) => process.stdout.write(text),
writeStderr: (text) => process.stderr.write(text),
suspend(resume) {
process.once("SIGCONT", resume)
process.kill(0, "SIGTSTP")
},
},
}
}

View File

@ -0,0 +1,120 @@
import type { CliRenderer } from "@opentui/core"
import type { TuiPlatform } from "@opencode-ai/tui/platform"
import { Filesystem } from "@/util/filesystem"
import { Clipboard } from "./clipboard"
import { Editor } from "./editor"
import { Flock } from "@opencode-ai/core/util/flock"
import { Glob } from "@opencode-ai/core/util/glob"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "@opencode-ai/tui/util/persistence"
import path from "path"
import os from "node:os"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { resolveZedSelection } from "./editor-zed"
export function createLegacyTuiPlatform(renderer: CliRenderer): TuiPlatform {
const statePath = path.join(Global.Path.state, "kv.json")
const stateLock = `tui-kv:${statePath}`
return {
files: {
readText: Filesystem.readText,
readBytes: Filesystem.readBytes,
mime: Filesystem.mimeType,
},
state: {
read: () => Flock.withLock(stateLock, () => readJson<Record<string, unknown>>(statePath)),
write: (value) => Flock.withLock(stateLock, () => writeJsonAtomic(statePath, value)),
},
themes: {
async discover() {
const directories = [
Global.Path.config,
...(await Array.fromAsync(Filesystem.up({ targets: [".opencode"], start: process.cwd() }))),
]
const result: Record<string, unknown> = {}
for (const dir of directories) {
for (const item of await Glob.scan("themes/*.json", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
result[path.basename(item, ".json")] = await Filesystem.readJson(item)
}
}
return result
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
},
clipboard: {
read: Clipboard.read,
write: Clipboard.copy,
},
editor: {
open: (input) => Editor.open({ ...input, renderer }),
connection: discoverEditorConnection,
selection: (directory) => resolveZedSelection(resolveZedDbPath(), directory),
},
export: {
write: Filesystem.write,
},
}
}
export function discoverEditorConnection(directory: string) {
const root = path.join(os.homedir(), ".claude", "ide")
const contains = (parent: string) => {
const resolved = path.resolve(parent)
const relative = path.relative(resolved, path.resolve(directory))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
try {
return readdirSync(root)
.filter((entry) => entry.endsWith(".lock"))
.flatMap((entry) => {
const file = path.join(root, entry)
const port = Number.parseInt(path.basename(file, ".lock"), 10)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
try {
const value = JSON.parse(readFileSync(file, "utf-8")) as Record<string, unknown>
if (value.transport !== undefined && value.transport !== "ws") return []
const folders = Array.isArray(value.workspaceFolders)
? value.workspaceFolders.filter((item): item is string => typeof item === "string")
: []
const score = Math.max(0, ...folders.map(contains))
if (!score) return []
return [{
url: `ws://127.0.0.1:${port}`,
authToken: typeof value.authToken === "string" ? value.authToken : undefined,
source: `lock:${port}`,
score,
mtime: statSync(file).mtimeMs,
}]
} catch {
return []
}
})
.sort((left, right) => right.score - left.score || right.mtime - left.mtime)
.map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
} catch {
return undefined
}
}
function resolveZedDbPath() {
const candidates = [
process.env.OPENCODE_ZED_DB,
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item))
return candidates.find((item) => {
try {
return statSync(item).isFile()
} catch {
return false
}
}) ?? ""
}

View File

@ -0,0 +1,57 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import type { TuiConfig } from "@opencode-ai/tui/config"
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
import path from "path"
import { isZedTerminal, resolveZedDbPath } from "./editor-zed"
export function resolveTuiRuntime(config: TuiConfig.Resolved) {
return {
environment: createTuiEnvironment({
cwd: process.cwd(),
platform: process.platform,
initialRoute: parseInitialRoute(process.env.OPENCODE_ROUTE),
paths: {
home: Global.Path.home,
state: Global.Path.state,
worktree: path.join(Global.Path.data, "worktree"),
},
capabilities: {
mouse: !Flag.OPENCODE_DISABLE_MOUSE && (config.mouse ?? true),
copyOnSelect: !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT,
terminalTitle: !Flag.OPENCODE_DISABLE_TERMINAL_TITLE,
terminalSuspend: process.platform !== "win32",
workspaces: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
showTimeToFirstDraw: Flag.OPENCODE_SHOW_TTFD,
},
terminal: {
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
},
editor: {
command: process.env.VISUAL || process.env.EDITOR,
port: parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT),
zedTerminal: isZedTerminal(),
zedDatabase: resolveZedDbPath(),
},
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}),
build: createTuiBuildInfo({
version: InstallationVersion,
channel: InstallationChannel,
}),
}
}
function parsePort(value: string | undefined) {
if (!value) return
const port = Number.parseInt(value, 10)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return
return port
}
function parseInitialRoute(value: string | undefined) {
if (!value) return
return JSON.parse(value) as unknown
}

View File

@ -0,0 +1,21 @@
import { TuiConfig } from "@opencode-ai/tui/config"
import { isRecord } from "@opencode-ai/tui/util/record"
import { Filesystem } from "@/util/filesystem"
import { Schema } from "effect"
export function resolveHostAttentionSoundPaths(
root: string,
sounds: unknown,
options?: { trim?: boolean },
): TuiConfig.AttentionSoundPaths {
if (!isRecord(sounds)) return {}
return Object.fromEntries(
Object.entries(sounds).flatMap(([name, file]) => {
if (!Schema.is(TuiConfig.AttentionSoundName)(name)) return []
if (typeof file !== "string") return []
const value = options?.trim ? file.trim() : file
if (!value) return []
return [[name, Filesystem.resolveFilePath(root, value)]]
}),
)
}

View File

@ -2,7 +2,7 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda" import { unique } from "remeda"
import { Option, Schema } from "effect" import { Option, Schema } from "effect"
import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema" import { TuiConfig } from "@opencode-ai/tui/config"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@ -15,9 +15,9 @@ const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const decodeTheme = Schema.decodeUnknownOption(Schema.String) const decodeTheme = Schema.decodeUnknownOption(Schema.String)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed) const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed)
const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration) const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration)
const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle) const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle)
interface MigrateInput { interface MigrateInput {
cwd: string cwd: string

View File

@ -1,57 +1,47 @@
export * as TuiConfig from "./tui" export * as TuiConfig from "./tui"
import path from "path" import path from "path"
import { createBindingLookup } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect" import { Cause, Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse" import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths" import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate" import { migrateTuiConfig } from "./tui-migrate"
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema" import { resolveHostAttentionSoundPaths } from "./tui-host-attention"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record" import { isRecord } from "@opencode-ai/tui/util/record"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { CurrentWorkingDirectory } from "./cwd" import { CurrentWorkingDirectory } from "./tui-cwd"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { TuiKeybind } from "./keybind" import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable" import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import type { DeepMutable } from "@opencode-ai/core/schema"
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { TuiConfig } from "@opencode-ai/tui/config"
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo export const Info = TuiConfig.Info
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> export type Info = TuiConfig.Info
type Acc = { type Acc = {
result: Info result: Info
plugin_origins: ConfigPlugin.Origin[] plugin_origins: ConfigPlugin.Origin[]
} }
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & { export type Resolved = TuiConfig.Resolved
attention: {
enabled: boolean export type HostMetadata = {
notifications: boolean
sound: boolean
volume: number
sound_pack: string
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[] plugin_origins?: ConfigPlugin.Origin[]
} }
export interface Interface { export interface Interface {
readonly get: () => Effect.Effect<Resolved> readonly get: () => Effect.Effect<Resolved>
readonly pluginOrigins: () => Effect.Effect<ConfigPlugin.Origin[]>
readonly waitForDependencies: () => Effect.Effect<void> readonly waitForDependencies: () => Effect.Effect<void>
} }
@ -104,10 +94,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
Effect.gen(function* () { Effect.gen(function* () {
const plugins = config.plugin const plugins = config.plugin
if (!plugins) return config if (!plugins) return config
for (let i = 0; i < plugins.length; i++) { return {
plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath)) ...config,
plugin: yield* Effect.forEach(plugins, (plugin) =>
Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)),
),
} }
return config
}) })
const load = (text: string, configFilepath: string): Effect.Effect<Info> => const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
@ -126,7 +118,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
...parsed, ...parsed,
attention: { attention: {
...parsed.attention, ...parsed.attention,
sounds: resolveAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds), sounds: resolveHostAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
}, },
} }
: parsed : parsed
@ -183,9 +175,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const scope = pluginScope(file, ctx) const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([ const plugins = ConfigPlugin.deduplicatePluginOrigins([
...acc.plugin_origins, ...acc.plugin_origins,
...data.plugin.map((spec) => ({ spec, scope, source: file })), ...data.plugin.map((spec) => ({ spec: spec as ConfigPlugin.Origin["spec"], scope, source: file })),
]) ])
acc.result.plugin = plugins.map((item) => item.spec) acc.result = {
...acc.result,
plugin: plugins.map((item) => item.spec),
}
acc.plugin_origins = plugins acc.plugin_origins = plugins
}) })
@ -230,34 +225,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
} }
} }
const keybinds = { ...acc.result.keybinds } const result = TuiConfig.resolve(
if (process.platform === "win32") { {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo. ...acc.result,
keybinds.terminal_suspend = "none"
const inputUndo = TuiKeybind.defaultValue("input_undo")
keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",")
}
const parsedKeybinds = TuiKeybind.parse(keybinds)
const result: Resolved = {
...acc.result,
attention: {
enabled: acc.result.attention?.enabled ?? false,
notifications: acc.result.attention?.notifications ?? true,
sound: acc.result.attention?.sound ?? true,
volume: acc.result.attention?.volume ?? 0.4,
sound_pack: acc.result.attention?.sound_pack ?? "opencode.default",
sounds: acc.result.attention?.sounds ?? {},
}, },
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { {
commandMap: TuiKeybind.CommandMap, terminalSuspend: process.platform !== "win32",
bindingDefaults: TuiKeybind.bindingDefaults(), },
}), )
leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault,
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
}
return { return {
config: result, config: result,
pluginOrigins: acc.plugin_origins,
dirs: result.plugin?.length ? dirs : [], dirs: result.plugin?.length ? dirs : [],
} }
}) })
@ -287,11 +266,12 @@ export const layer = Layer.effect(
) )
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
) )
return Service.of({ get, waitForDependencies }) return Service.of({ get, pluginOrigins, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")), }).pipe(Effect.withSpan("TuiConfig.layer")),
) )
@ -306,3 +286,7 @@ export async function waitForDependencies() {
export async function get() { export async function get() {
return runPromise((svc) => svc.get()) return runPromise((svc) => svc.get())
} }
export async function pluginOrigins() {
return runPromise((svc) => svc.pluginOrigins())
}

View File

@ -21,8 +21,8 @@ import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github" import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export" import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import" import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/tui/attach" import { AttachCommand } from "./cli/cmd/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { TuiThreadCommand } from "./cli/cmd/tui"
import { AcpCommand } from "./cli/cmd/acp" import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os" import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web" import { WebCommand } from "./cli/cmd/web"

View File

@ -25,7 +25,7 @@ import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth" import { McpAuth } from "./auth"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/server/tui-event"
import open from "open" import open from "open"
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"

View File

@ -0,0 +1,12 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return createBuiltinPlugins({
experimentalEventSystem: flags.experimentalEventSystem,
experimentalSessionSwitcher: Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER,
})
}

View File

@ -13,11 +13,11 @@ import {
} from "@opencode-ai/plugin/tui" } from "@opencode-ai/plugin/tui"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/config/tui"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@opencode-ai/tui/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@opencode-ai/tui/util/record"
import { resolveAttentionSoundPaths } from "../config/tui-schema" import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention"
import { import {
readPackageThemes, readPackageThemes,
readPluginId, readPluginId,
@ -29,20 +29,20 @@ import {
import { PluginLoader } from "@/plugin/loader" import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta" import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { hasTheme, upsertTheme } from "../context/theme" import { hasTheme, upsertTheme } from "@opencode-ai/tui/context/theme"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Flock } from "@opencode-ai/core/util/flock" import { Flock } from "@opencode-ai/core/util/flock"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { internalTuiPlugins, type InternalTuiPlugin } from "./internal" import { internalTuiPlugins, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "@opencode-ai/tui/plugin/slots"
import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { createCommandShim } from "./command-shim" import { createCommandShim } from "@opencode-ai/tui/plugin/command-shim"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { Effect } from "effect" import { Effect } from "effect"
import { createPluginRuntime, type PluginRuntime, type TuiPluginHost } from "@opencode-ai/tui/plugin/runtime"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
@ -110,6 +110,7 @@ const ScopedKeymapMethods = new Set<PropertyKey>([
type RuntimeState = { type RuntimeState = {
directory: string directory: string
api: Api api: Api
view: PluginRuntime
dispose?: () => void dispose?: () => void
slots: HostSlots slots: HostSlots
plugins: PluginEntry[] plugins: PluginEntry[]
@ -176,7 +177,7 @@ function createScopedAttention(
return scope.track( return scope.track(
attention.soundboard.registerPack({ attention.soundboard.registerPack({
...pack, ...pack,
sounds: resolveAttentionSoundPaths(root, pack.sounds, { trim: true }), sounds: resolveHostAttentionSoundPaths(root, pack.sounds, { trim: true }),
}), }),
) )
}, },
@ -524,17 +525,24 @@ function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) { async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
plugin.enabled = false plugin.enabled = false
if (persist) writePluginEnabledState(state.api, plugin.id, false) if (persist) writePluginEnabledState(state.api, plugin.id, false)
if (!plugin.scope) return true if (!plugin.scope) {
state.view.update({ status: listPluginStatus(state) })
return true
}
const scope = plugin.scope const scope = plugin.scope
plugin.scope = undefined plugin.scope = undefined
await scope.dispose() await scope.dispose()
state.view.update({ status: listPluginStatus(state) })
return true return true
} }
async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) { async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
plugin.enabled = true plugin.enabled = true
if (persist) writePluginEnabledState(state.api, plugin.id, true) if (persist) writePluginEnabledState(state.api, plugin.id, true)
if (plugin.scope) return true if (plugin.scope) {
state.view.update({ status: listPluginStatus(state) })
return true
}
const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms) const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms)
const api = pluginApi(state, plugin, scope, plugin.id) const api = pluginApi(state, plugin, scope, plugin.id)
@ -555,15 +563,18 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (!ok) { if (!ok) {
await scope.dispose() await scope.dispose()
state.view.update({ status: listPluginStatus(state) })
return false return false
} }
if (!plugin.enabled) { if (!plugin.enabled) {
await scope.dispose() await scope.dispose()
state.view.update({ status: listPluginStatus(state) })
return true return true
} }
plugin.scope = scope plugin.scope = scope
state.view.update({ status: listPluginStatus(state) })
return true return true
} }
@ -1014,11 +1025,11 @@ async function installPluginBySpec(
let dir = "" let dir = ""
let loaded: Promise<void> | undefined let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { export async function init(input: {
api: HostPluginApi api: HostPluginApi
config: TuiConfig.Resolved config: TuiConfig.Resolved & TuiConfig.HostMetadata
runtime?: PluginRuntime
dispose?: () => void dispose?: () => void
disposeTimeoutMs?: number disposeTimeoutMs?: number
}) { }) {
@ -1031,7 +1042,7 @@ export async function init(input: {
} }
dir = cwd dir = cwd
loaded = load(input) loaded = load({ ...input, runtime: input.runtime ?? createPluginRuntime() })
return loaded return loaded
} }
@ -1060,24 +1071,38 @@ export async function dispose() {
const task = loaded const task = loaded
loaded = undefined loaded = undefined
dir = "" dir = ""
if (task) await task if (task) await task.catch((error) => fail("failed to finish loading tui plugins during disposal", { error }))
const state = runtime const state = runtime
runtime = undefined runtime = undefined
if (!state) return if (!state) return
const queue = [...state.plugins].reverse() const queue = [...state.plugins].reverse()
for (const plugin of queue) { for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false) await deactivatePluginEntry(state, plugin, false).catch((error) =>
fail("failed to dispose tui plugin", { id: plugin.id, error }),
)
}
try {
state.dispose?.()
} finally {
state.slots.dispose()
state.view.clear()
} }
state.dispose?.()
} }
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void; disposeTimeoutMs?: number }) { async function load(input: {
api: Api
config: TuiConfig.Resolved & TuiConfig.HostMetadata
runtime: PluginRuntime
dispose?: () => void
disposeTimeoutMs?: number
}) {
const { api, config } = input const { api, config } = input
const cwd = process.cwd() const cwd = process.cwd()
const slots = setupSlots(api) const slots = input.runtime.setupSlots(api)
const next: RuntimeState = { const next: RuntimeState = {
directory: cwd, directory: cwd,
api, api,
view: input.runtime,
dispose: input.dispose, dispose: input.dispose,
slots, slots,
plugins: [], plugins: [],
@ -1086,15 +1111,25 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS, dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
} }
runtime = next runtime = next
next.view.update({
commands: {
activate: activatePlugin,
deactivate: deactivatePlugin,
add: addPlugin,
install: installPlugin,
},
status: listPluginStatus(next),
})
try { try {
const flags = await Effect.runPromise( const flags = await Effect.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
return yield* RuntimeFlags.Service return yield* RuntimeFlags.Service
}).pipe(Effect.provide(RuntimeFlags.defaultLayer)), }).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
) )
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins())
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { const records = Flag.OPENCODE_PURE ? [] : pluginOrigins
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) if (Flag.OPENCODE_PURE && pluginOrigins.length) {
log.info("skipping external tui plugins in pure mode", { count: pluginOrigins.length })
} }
for (const item of internalTuiPlugins(flags)) { for (const item of internalTuiPlugins(flags)) {
@ -1123,9 +1158,17 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
// and hook chains rely on stable plugin ordering. // and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false) await activatePluginEntry(next, plugin, false)
} }
next.view.update({ status: listPluginStatus(next) })
} catch (error) { } catch (error) {
fail("failed to load tui plugins", { directory: cwd, error }) fail("failed to load tui plugins", { directory: cwd, error })
} }
} }
export function createLegacyTuiPluginHost(): TuiPluginHost {
return {
start: init,
dispose,
}
}
export * as TuiPluginRuntime from "./runtime" export * as TuiPluginRuntime from "./runtime"

View File

@ -17,11 +17,12 @@ import { ProjectCopyApi } from "./groups/project-copy"
import { ProviderApi } from "./groups/provider" import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty" import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question" import { QuestionApi } from "./groups/question"
import { ReferenceApi } from "./groups/reference"
import { SessionApi } from "./groups/session" import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync" import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui" import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace" import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
// GlobalEventSchema snapshots the registry after event-producing groups register their variants. // GlobalEventSchema snapshots the registry after event-producing groups register their variants.
import { GlobalApi } from "./groups/global" import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization" import { Authorization } from "./middleware/authorization"
@ -60,6 +61,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(QuestionApi) .addHttpApi(QuestionApi)
.addHttpApi(PermissionApi) .addHttpApi(PermissionApi)
.addHttpApi(ProviderApi) .addHttpApi(ProviderApi)
.addHttpApi(ReferenceApi)
.addHttpApi(SessionApi) .addHttpApi(SessionApi)
.addHttpApi(SyncApi) .addHttpApi(SyncApi)
.addHttpApi(TuiApi) .addHttpApi(TuiApi)
@ -70,7 +72,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi) .addHttpApi(RootHttpApi)
.addHttpApi(EventApi) .addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi) .addHttpApi(InstanceHttpApi)
.addHttpApi(V2Api) .addHttpApi(Api)
.addHttpApi(PtyConnectApi) .addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected]) .annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])

View File

@ -0,0 +1,60 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
export const ReferenceDescriptor = Schema.Union([
Schema.Struct({
name: Schema.String,
kind: Schema.Literal("local"),
path: Schema.String,
}),
Schema.Struct({
name: Schema.String,
kind: Schema.Literal("git"),
repository: Schema.String,
path: Schema.String,
branch: Schema.optional(Schema.String),
}),
Schema.Struct({
name: Schema.String,
kind: Schema.Literal("invalid"),
repository: Schema.optional(Schema.String),
message: Schema.String,
}),
]).annotate({ identifier: "ReferenceDescriptor" })
export const ReferenceApi = HttpApi.make("reference")
.add(
HttpApiGroup.make("reference")
.add(
HttpApiEndpoint.get("list", "/reference", {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ReferenceDescriptor), "Resolved configured references"),
}).annotateMerge(
OpenApi.annotations({
identifier: "reference.list",
summary: "List configured references",
description: "List configured references resolved in the current workspace.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "reference",
description: "Configured reference routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)

View File

@ -1,4 +1,4 @@
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/server/tui-event"
import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control" import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"

View File

@ -0,0 +1,27 @@
import { Reference } from "@/reference/reference"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const referenceHandlers = HttpApiBuilder.group(InstanceHttpApi, "reference", (handlers) =>
Effect.gen(function* () {
const reference = yield* Reference.Service
return handlers.handle("list", () =>
reference.list().pipe(
Effect.map((references) =>
references.map((item) => {
if (item.kind !== "git") return item
return {
name: item.name,
kind: item.kind,
repository: item.repository,
path: item.path,
...(item.branch !== undefined ? { branch: item.branch } : {}),
}
}),
),
),
)
}),
)

View File

@ -1,5 +1,5 @@
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/server/tui-event"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"

View File

@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
import { isPublicUIPath } from "@/server/shared/public-ui" import { isPublicUIPath } from "@/server/shared/public-ui"
export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization" export { Authorization as ServerAuthorization, authorizationLayer as serverAuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
const AUTH_TOKEN_QUERY = "auth_token" const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401 const UNAUTHORIZED = 401

View File

@ -35,6 +35,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question" import { Question } from "@/question"
import { Reference } from "@/reference/reference"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session/compaction"
import { LLM } from "@/session/llm" import { LLM } from "@/session/llm"
@ -60,13 +61,13 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
import { serveUIEffect } from "@/server/shared/ui" import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api" import { InstanceHttpApi, RootHttpApi } from "./api"
import { V2Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { PublicApi } from "./public" import { PublicApi } from "./public"
import { import {
authorizationLayer, authorizationLayer,
authorizationRouterMiddleware, authorizationRouterMiddleware,
ptyConnectAuthorizationLayer, ptyConnectAuthorizationLayer,
v2AuthorizationLayer, serverAuthorizationLayer,
} from "./middleware/authorization" } from "./middleware/authorization"
import { EventApi } from "./groups/event" import { EventApi } from "./groups/event"
import { PtyConnectApi } from "./groups/pty" import { PtyConnectApi } from "./groups/pty"
@ -85,10 +86,11 @@ import { projectCopyHandlers } from "./handlers/project-copy"
import { providerHandlers } from "./handlers/provider" import { providerHandlers } from "./handlers/provider"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty" import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question" import { questionHandlers } from "./handlers/question"
import { referenceHandlers } from "./handlers/reference"
import { sessionHandlers } from "./handlers/session" import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync" import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui" import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "@opencode-ai/server/handlers" import { handlers } from "@opencode-ai/server/handlers"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace" import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context" import { instanceContextLayer } from "./middleware/instance-context"
@ -121,7 +123,7 @@ const cors = (corsOptions?: CorsOptions) =>
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]), Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
@ -147,6 +149,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
projectCopyHandlers, projectCopyHandlers,
ptyHandlers, ptyHandlers,
questionHandlers, questionHandlers,
referenceHandlers,
permissionHandlers, permissionHandlers,
providerHandlers, providerHandlers,
sessionHandlers, sessionHandlers,
@ -159,9 +162,9 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const instanceRoutes = instanceApiRoutes.pipe( const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]), Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
) )
const v2Routes = HttpApiBuilder.layer(V2Api).pipe( const serverRoutes = HttpApiBuilder.layer(Api).pipe(
Layer.provide(v2Handlers), Layer.provide(handlers),
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]), Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
) )
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so // `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@ -201,7 +204,7 @@ export function createRoutes(
eventApiRoutes, eventApiRoutes,
ptyConnectApiRoutes, ptyConnectApiRoutes,
instanceRoutes, instanceRoutes,
v2Routes, serverRoutes,
docRoute, docRoute,
uiRoute, uiRoute,
).pipe( ).pipe(
@ -234,6 +237,7 @@ export function createRoutes(
Provider.defaultLayer, Provider.defaultLayer,
PtyTicket.defaultLayer, PtyTicket.defaultLayer,
Question.defaultLayer, Question.defaultLayer,
Reference.defaultLayer,
Ripgrep.defaultLayer, Ripgrep.defaultLayer,
RuntimeFlags.defaultLayer, RuntimeFlags.defaultLayer,
Session.defaultLayer, Session.defaultLayer,

View File

@ -12,6 +12,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { NotFoundError } from "@/storage/storage" import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
@ -967,6 +968,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer), Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionV2.defaultLayer), Layer.provide(SessionV2.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer),
) )

View File

@ -1,5 +1,5 @@
import yargs from "yargs" import yargs from "yargs"
import { TuiThreadCommand } from "./cli/cmd/tui/thread" import { TuiThreadCommand } from "./cli/cmd/tui"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { Log } from "./node" import { Log } from "./node"

View File

@ -1,88 +1 @@
import { isRecord } from "./record" export * from "@opencode-ai/tui/util/error"
export function errorFormat(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`
}
if (typeof error === "object" && error !== null) {
try {
const json = JSON.stringify(error, null, 2)
// Plain objects whose own properties are all non-enumerable (or empty)
// serialize to "{}", which prints as a useless bare `{}` on stderr.
// Fall back to a custom toString first, then to ctor name + own prop names.
if (json === "{}") {
const str = String(error)
if (str && str !== "[object Object]") return str
const ctor = error.constructor?.name
const prefix = ctor && ctor !== "Object" ? ctor : "Error"
const names = Object.getOwnPropertyNames(error)
return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }`
}
return json
} catch {
return "Unexpected error (unserializable)"
}
}
return String(error)
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.message) return error.message
if (error.name) return error.name
}
if (isRecord(error) && typeof error.message === "string" && error.message) {
return error.message
}
if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) {
return error.data.message
}
const text = String(error)
if (text && text !== "[object Object]") return text
const formatted = errorFormat(error)
if (formatted) return formatted
return "unknown error"
}
export function errorData(error: unknown) {
if (error instanceof Error) {
return {
type: error.name,
message: errorMessage(error),
stack: error.stack,
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
formatted: errorFormat(error),
}
}
if (!isRecord(error)) {
return {
type: typeof error,
message: errorMessage(error),
formatted: errorFormat(error),
}
}
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
const value = error[key]
if (value === undefined) return acc
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
acc[key] = value
return acc
}
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
acc[key] = value instanceof Error ? value.message : String(value)
return acc
}, {})
if (typeof data.message !== "string") data.message = errorMessage(error)
if (typeof data.type !== "string") data.type = error.constructor?.name
data.formatted = errorFormat(error)
return data
}

View File

@ -1,86 +1,2 @@
export function titlecase(str: string) { export * from "@opencode-ai/tui/util/locale"
return str.replace(/\b\w/g, (c) => c.toUpperCase()) export { Locale } from "@opencode-ai/tui/util/locale"
}
export function time(input: number): string {
const date = new Date(input)
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export function datetime(input: number): string {
const date = new Date(input)
const localTime = time(input)
const localDate = date.toLocaleDateString()
return `${localTime} · ${localDate}`
}
export function todayTimeOrDateTime(input: number): string {
const date = new Date(input)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) {
return time(input)
} else {
return datetime(input)
}
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
export function duration(input: number) {
if (input < 1000) {
return `${input}ms`
}
if (input < 60000) {
return `${(input / 1000).toFixed(1)}s`
}
if (input < 3600000) {
const minutes = Math.floor(input / 60000)
const seconds = Math.floor((input % 60000) / 1000)
return `${minutes}m ${seconds}s`
}
if (input < 86400000) {
const hours = Math.floor(input / 3600000)
const minutes = Math.floor((input % 3600000) / 60000)
return `${hours}h ${minutes}m`
}
const hours = Math.floor(input / 3600000)
const days = Math.floor((input % 3600000) / 86400000)
return `${days}d ${hours}h`
}
export function truncate(str: string, len: number): string {
if (str.length <= len) return str
return str.slice(0, len - 1) + "…"
}
export function truncateLeft(str: string, len: number): string {
if (str.length <= len) return str
return "…" + str.slice(-(len - 1))
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
const ellipsis = "…"
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
}
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
}
export * as Locale from "./locale"

View File

@ -1,3 +1 @@
export function isRecord(value: unknown): value is Record<string, unknown> { export * from "@opencode-ai/tui/util/record"
return !!value && typeof value === "object" && !Array.isArray(value)
}

View File

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { AudioPlayOptions, AudioSound } from "@opentui/core" import type { AudioPlayOptions, AudioSound } from "@opentui/core"
import { createTuiAttention } from "@/cli/cmd/tui/attention" import { createTuiAttention } from "@/cli/tui/attention"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui" import type { TuiConfig } from "@opencode-ai/tui/config"
type FocusEvent = "focus" | "blur" type FocusEvent = "focus" | "blur"

View File

@ -1,44 +0,0 @@
import { describe, expect, test } from "bun:test"
import { isDuplicateEntry, type PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
describe("prompt history dedupe", () => {
test("returns false when there is no previous entry", () => {
expect(isDuplicateEntry(undefined, entry("hello"))).toBe(false)
})
test("dedupes identical consecutive entries", () => {
const a = entry("hello world this is over twenty chars")
const b = entry("hello world this is over twenty chars")
expect(isDuplicateEntry(a, b)).toBe(true)
})
test("does not dedupe when input text differs", () => {
expect(isDuplicateEntry(entry("foo"), entry("bar"))).toBe(false)
})
test("does not dedupe when parts differ", () => {
const a = entry("describe this", [
{
type: "file",
mime: "image/png",
filename: "a.png",
url: "data:image/png;base64,AAA",
},
])
const b = entry("describe this", [
{
type: "file",
mime: "image/png",
filename: "b.png",
url: "data:image/png;base64,BBB",
},
])
expect(isDuplicateEntry(a, b)).toBe(false)
})
test("does not dedupe when mode differs", () => {
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
})
})

View File

@ -1,77 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
describe("prompt part", () => {
test("strip removes persisted ids from reused file parts", () => {
const part = {
id: "prt_old",
sessionID: "ses_old",
messageID: "msg_old",
type: "file" as const,
mime: "image/png",
filename: "tiny.png",
url: "data:image/png;base64,abc",
}
expect(strip(part)).toEqual({
type: "file",
mime: "image/png",
filename: "tiny.png",
url: "data:image/png;base64,abc",
})
})
test("assign overwrites stale runtime ids", () => {
const part = {
id: "prt_old",
sessionID: "ses_old",
messageID: "msg_old",
type: "file" as const,
mime: "image/png",
filename: "tiny.png",
url: "data:image/png;base64,abc",
} as PromptInfo["parts"][number]
const next = assign(part)
expect(next.id).not.toBe("prt_old")
expect(next.id.startsWith("prt_")).toBe(true)
expect(next).toMatchObject({
type: "file",
mime: "image/png",
filename: "tiny.png",
url: "data:image/png;base64,abc",
})
})
test("expandTrackedPastedText preserves wide characters around pasted text", () => {
const marker = "[Pasted ~3 lines]"
const prefix = "你好你好\n"
expect(
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
{
start: Bun.stringWidth("你好你好") + 1,
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
text: "public:\n\tvoid ExecuteTask();\nprivate:",
},
]),
).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来")
})
test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => {
const marker = "[Pasted ~3 lines]"
const prefix = `keep ${marker} then `
expect(
expandTrackedPastedText(prefix + marker + " tail", [
{
start: Bun.stringWidth(prefix),
end: Bun.stringWidth(prefix + marker),
text: "alpha\nbeta\ngamma",
},
]),
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
})
})

View File

@ -5,7 +5,7 @@ import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap" import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { import {
RUN_COMMAND_PANEL_ROWS, RUN_COMMAND_PANEL_ROWS,
RUN_SUBAGENT_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS,

View File

@ -1,11 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
createPromptHistory, createPromptHistory,
displayCharAt,
displaySlice,
isExitCommand, isExitCommand,
isNewCommand, isNewCommand,
mentionTriggerIndex,
movePromptHistory, movePromptHistory,
pushPromptHistory, pushPromptHistory,
} from "@/cli/cmd/run/prompt.shared" } from "@/cli/cmd/run/prompt.shared"
@ -90,35 +87,6 @@ describe("run prompt shared", () => {
expect(draft.cursor).toBe(Bun.stringWidth("草稿")) expect(draft.cursor).toBe(Bun.stringWidth("草稿"))
}) })
test("uses display-width offsets for mention helpers", () => {
expect(mentionTriggerIndex("@")).toBe(0)
expect(mentionTriggerIndex("test @")).toBe(5)
expect(mentionTriggerIndex("中文 @")).toBe(5)
expect(mentionTriggerIndex("こんにちは @")).toBe(11)
expect(mentionTriggerIndex("한국어 @")).toBe(7)
expect(mentionTriggerIndex("🙂 @")).toBe(3)
expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5)
expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s")
expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src")
expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src")
expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3)
expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s")
expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src")
expect(mentionTriggerIndex("@file1\n@file2", 13)).toBe(7)
expect(displayCharAt("@file1\n@file2", 6)).toBe("\n")
expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2")
expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11)
expect(mentionTriggerIndex("中文 @one\n@two", 14)).toBe(10)
expect(displaySlice("中文 @one\n@two", 11, 14)).toBe("two")
expect(mentionTriggerIndex("中文@")).toBeUndefined()
expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
expect(mentionTriggerIndex("한국어@")).toBeUndefined()
expect(mentionTriggerIndex("🙂@")).toBeUndefined()
expect(mentionTriggerIndex("hello@")).toBeUndefined()
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
})
test("recognizes exit commands", () => { test("recognizes exit commands", () => {
expect(isExitCommand("/exit")).toBe(true) expect(isExitCommand("/exit")).toBe(true)
expect(isExitCommand(" /Quit ")).toBe(true) expect(isExitCommand(" /Quit ")).toBe(true)

View File

@ -1,6 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import type { Resolved } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot" import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"

View File

@ -4,12 +4,13 @@ import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime" import { tui, type TuiHandle } from "@opencode-ai/tui"
import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app" import { createLegacyTuiHost } from "../../../src/cli/tui/host"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio" import * as TuiAudio from "../../../src/cli/tui/audio"
import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap" import * as TuiKeymap from "@opencode-ai/tui/keymap"
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>> type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
type TmpDir = Awaited<ReturnType<typeof tmpdir>> type TmpDir = Awaited<ReturnType<typeof tmpdir>>
@ -39,7 +40,6 @@ afterEach(async () => {
current?.restore?.() current?.restore?.()
await Bun.sleep(20) await Bun.sleep(20)
await current?.tmp?.[Symbol.asyncDispose]() await current?.tmp?.[Symbol.asyncDispose]()
await TuiPluginRuntime.dispose().catch(() => {})
}) })
test("returns a handle immediately and resolves ready after async mount setup", async () => { test("returns a handle immediately and resolves ready after async mount setup", async () => {
@ -61,6 +61,23 @@ test("production can await done only and still receives mount failures", async (
expect(app.setup.renderer.isDestroyed).toBe(true) expect(app.setup.renderer.isDestroyed).toBe(true)
}) })
test("plugin startup failure does not fail the app", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
try {
const app = await startTui({ rejectPlugins: new Error("plugins failed") })
app.theme.resolve("dark")
await expect(app.handle.ready).resolves.toBeUndefined()
await app.pluginHost.started
expect(app.setup.renderer.isDestroyed).toBe(false)
expect(app.pluginHost.starts).toBe(1)
await app.handle.exit()
await app.handle.done
} finally {
error.mockRestore()
}
})
test("exit destroys the renderer, resolves done, and runs cleanup once", async () => { test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
const beforeSighup = process.listenerCount("SIGHUP") const beforeSighup = process.listenerCount("SIGHUP")
const app = await startTui() const app = await startTui()
@ -73,7 +90,7 @@ test("exit destroys the renderer, resolves done, and runs cleanup once", async (
await app.handle.done await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true) expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
}) })
test("exit preserves reason formatting and exit messages", async () => { test("exit preserves reason formatting and exit messages", async () => {
@ -124,7 +141,7 @@ test("direct renderer destruction still cleans up and resolves done", async () =
app.setup.renderer.destroy() app.setup.renderer.destroy()
await app.handle.done await app.handle.done
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
}) })
test("SIGHUP exits before ready and removes its listener", async () => { test("SIGHUP exits before ready and removes its listener", async () => {
@ -135,7 +152,7 @@ test("SIGHUP exits before ready and removes its listener", async () => {
await app.handle.done await app.handle.done
expect(app.setup.renderer.isDestroyed).toBe(true) expect(app.setup.renderer.isDestroyed).toBe(true)
expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
}) })
test("SIGHUP exits after ready and removes its listener", async () => { test("SIGHUP exits after ready and removes its listener", async () => {
@ -161,7 +178,6 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
unregister() unregister()
} }
}) })
const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
const disposeAudio = spyOn(TuiAudio, "dispose") const disposeAudio = spyOn(TuiAudio, "dispose")
try { try {
@ -175,18 +191,37 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
expect(registerKeymap).toHaveBeenCalledTimes(1) expect(registerKeymap).toHaveBeenCalledTimes(1)
expect(unregisterKeymapCalls).toBe(1) expect(unregisterKeymapCalls).toBe(1)
expect(disposePlugins).toHaveBeenCalledTimes(1) expect(app.pluginHost.disposes).toBe(1)
expect(disposeAudio).toHaveBeenCalledTimes(1) expect(disposeAudio).toHaveBeenCalledTimes(1)
} finally { } finally {
registerKeymap.mockRestore() registerKeymap.mockRestore()
disposePlugins.mockRestore()
disposeAudio.mockRestore() disposeAudio.mockRestore()
} }
}) })
async function startTui(options: { rejectTheme?: Error } = {}) { test("plugin disposal failure does not stop remaining cleanup", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
const disposeAudio = spyOn(TuiAudio, "dispose")
try {
const app = await startTui({ rejectPluginDispose: new Error("dispose failed") })
app.theme.resolve("dark")
await app.handle.ready
await app.handle.exit()
await app.handle.done
expect(app.pluginHost.disposes).toBe(1)
expect(disposeAudio).toHaveBeenCalledTimes(1)
expect(app.setup.renderer.isDestroyed).toBe(true)
} finally {
error.mockRestore()
disposeAudio.mockRestore()
}
})
async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) {
const tmp = await tmpdir() const tmp = await tmpdir()
const restore = await isolateGlobalPaths(tmp.path) const isolated = await isolateGlobalPaths(tmp.path)
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY }) const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
const theme = deferred<"dark" | "light" | null>() const theme = deferred<"dark" | "light" | null>()
const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => { const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
@ -197,13 +232,48 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
const calls = createFetch() const calls = createFetch()
const events = createEventSource() const events = createEventSource()
const pluginStarted = deferred<void>()
const pluginHost = {
starts: 0,
disposes: 0,
started: pluginStarted.promise,
async start() {
pluginHost.starts++
pluginStarted.resolve()
if (options.rejectPlugins) throw options.rejectPlugins
},
async dispose() {
pluginHost.disposes++
if (options.rejectPluginDispose) throw options.rejectPluginDispose
},
}
const environment = createTuiEnvironment({
cwd: tmp.path,
platform: "linux",
paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") },
capabilities: {
mouse: true,
copyOnSelect: true,
terminalTitle: false,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: {},
editor: { zedTerminal: false },
skipInitialLoading: false,
})
const handle = tui({ const handle = tui({
environment,
build: createTuiBuildInfo({ version: "test", channel: "test" }),
url: "http://test", url: "http://test",
renderer: setup.renderer, renderer: setup.renderer,
host: createLegacyTuiHost(setup.renderer),
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }), config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
directory, directory,
fetch: calls.fetch, fetch: calls.fetch,
events: events.source, events: events.source,
pluginHost,
args: {}, args: {},
}) })
active = { active = {
@ -212,27 +282,26 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
tmp, tmp,
restore: () => { restore: () => {
waitForThemeMode.mockRestore() waitForThemeMode.mockRestore()
restore() isolated.restore()
}, },
} }
return { handle, setup, theme } return { handle, setup, theme, pluginHost }
} }
async function isolateGlobalPaths(root: string) { async function isolateGlobalPaths(root: string) {
const previous = { const previous = Global.Path.config
config: Global.Path.config,
state: Global.Path.state,
}
Global.Path.config = path.join(root, "config") Global.Path.config = path.join(root, "config")
Global.Path.state = path.join(root, "state") const state = path.join(root, "state")
await mkdir(Global.Path.config, { recursive: true }) await mkdir(Global.Path.config, { recursive: true })
await mkdir(Global.Path.state, { recursive: true }) await mkdir(state, { recursive: true })
await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false })) await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
return () => { return {
Global.Path.config = previous.config state,
Global.Path.state = previous.state restore() {
Global.Path.config = previous
},
} }
} }

View File

@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test"
describe("tui attach", () => {
test("loads the public TUI API and legacy hosts lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
expect(source).toContain('await import("../tui/host")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
})

View File

@ -8,7 +8,7 @@ import {
offsetToPosition, offsetToPosition,
resolveZedDbPath, resolveZedDbPath,
resolveZedSelection, resolveZedSelection,
} from "../../../src/cli/cmd/tui/context/editor-zed" } from "../../../src/cli/tui/editor-zed"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
const originalZedTerm = process.env.ZED_TERM const originalZedTerm = process.env.ZED_TERM

View File

@ -3,9 +3,12 @@ import os from "node:os"
import path from "node:path" import path from "node:path"
import { afterEach, expect, spyOn, test } from "bun:test" import { afterEach, expect, spyOn, test } from "bun:test"
import { createRoot } from "solid-js" import { createRoot } from "solid-js"
import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor" import { EditorContextProvider, useEditorContext } from "@opencode-ai/tui/context/editor"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { FakeWebSocket } from "../../lib/websocket" import { FakeWebSocket } from "../../lib/websocket"
import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
import { TuiPlatformProvider, type TuiPlatform } from "@opencode-ai/tui/platform"
import { discoverEditorConnection } from "../../../src/cli/tui/platform"
const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
@ -31,10 +34,19 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
return null return null
} }
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
return ( return (
<EditorContextProvider WebSocketImpl={WebSocketImpl}> <TestTuiEnvironmentProvider
<Consumer /> cwd={process.cwd()}
</EditorContextProvider> paths={{ home: os.homedir() }}
editor={{ port: value ? Number.parseInt(value, 10) : undefined }}
>
<TuiPlatformProvider value={platform}>
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
<Consumer />
</EditorContextProvider>
</TuiPlatformProvider>
</TestTuiEnvironmentProvider>
) )
}) })
@ -44,6 +56,18 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
} }
} }
const platform: TuiPlatform = {
files: {
readText: (file) => Bun.file(file).text(),
readBytes: (file) => Bun.file(file).bytes(),
mime: () => Promise.resolve("application/octet-stream"),
},
editor: {
open: () => Promise.resolve(undefined),
connection: discoverEditorConnection,
},
}
function createWebSocketImpl(...sockets: FakeWebSocket[]) { function createWebSocketImpl(...sockets: FakeWebSocket[]) {
let index = 0 let index = 0

View File

@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("adds tui plugin at runtime from spec", async () => { test("adds tui plugin at runtime from spec", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({

View File

@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("installs plugin without loading it", async () => { test("installs plugin without loading it", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({

View File

@ -6,7 +6,7 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { mockTuiRuntime } from "../../fixture/tui-runtime" import { mockTuiRuntime } from "../../fixture/tui-runtime"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("runs onDispose callbacks with aborted signal and is idempotent", async () => { test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({

View File

@ -5,10 +5,10 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/config/tui"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("loads npm tui plugin from package ./tui export", async () => { test("loads npm tui plugin from package ./tui export", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({

View File

@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("skips external tui plugins in pure mode", async () => { test("skips external tui plugins in pure mode", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({

View File

@ -8,12 +8,12 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime" import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/config/tui"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { PluginLoader } from "../../../src/plugin/loader" import { PluginLoader } from "../../../src/plugin/loader"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { allThemes, addTheme } = await import("@opencode-ai/tui/context/theme")
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
type Row = Record<string, unknown> type Row = Record<string, unknown>

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