refactor(server): canonicalize service API (#31049)
This commit is contained in:
parent
53ff1b57c9
commit
fe0c4f8c74
48
bun.lock
48
bun.lock
@ -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
1
packages/cli/bunfig.toml
Normal file
@ -0,0 +1 @@
|
|||||||
|
preload = ["@opentui/solid/preload"]
|
||||||
@ -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:*",
|
||||||
|
|||||||
@ -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") } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
13
packages/cli/src/commands/handlers/default.ts
Normal file
13
packages/cli/src/commands/handlers/default.ts
Normal 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))
|
||||||
|
}),
|
||||||
|
)
|
||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
115
packages/cli/src/tui.ts
Normal 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 })
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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 }),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -31,3 +31,5 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||||
|
|||||||
@ -58,3 +58,5 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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" }),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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/"
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>()
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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(() => {})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -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)" }),
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -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))
|
|
||||||
@ -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] : []),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1 @@
|
|||||||
export const logo = {
|
export * from "@opencode-ai/tui/logo"
|
||||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
|
||||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const go = {
|
|
||||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
|
||||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const marks = "_^~,"
|
|
||||||
|
|||||||
@ -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) {
|
||||||
@ -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(
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
36
packages/opencode/src/cli/tui/host.ts
Normal file
36
packages/opencode/src/cli/tui/host.ts
Normal 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")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
120
packages/opencode/src/cli/tui/platform.ts
Normal file
120
packages/opencode/src/cli/tui/platform.ts
Normal 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
|
||||||
|
}
|
||||||
|
}) ?? ""
|
||||||
|
}
|
||||||
57
packages/opencode/src/cli/tui/runtime.ts
Normal file
57
packages/opencode/src/cli/tui/runtime.ts
Normal 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
|
||||||
|
}
|
||||||
21
packages/opencode/src/config/tui-host-attention.ts
Normal file
21
packages/opencode/src/config/tui-host-attention.ts
Normal 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)]]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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())
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
12
packages/opencode/src/plugin/tui/internal.ts
Normal file
12
packages/opencode/src/plugin/tui/internal.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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"
|
||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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"
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
packages/opencode/test/cli/tui/attach.test.ts
Normal file
12
packages/opencode/test/cli/tui/attach.test.ts
Normal 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")')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user