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/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
@ -531,6 +535,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@ -787,6 +792,31 @@
|
||||
"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": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.16.2",
|
||||
@ -1776,6 +1806,8 @@
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@ -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/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/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/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@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=="],
|
||||
@ -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=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/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/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/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=="],
|
||||
|
||||
"@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/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
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 baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@ -43,6 +49,12 @@ const targets = singleFlag
|
||||
})
|
||||
: 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) {
|
||||
const target = [
|
||||
binary,
|
||||
@ -56,8 +68,9 @@ for (const item of targets) {
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
entrypoints: ["./src/index.ts", parserWorker],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@ -79,6 +92,11 @@ for (const item of targets) {
|
||||
OPENCODE_MODELS_DEV: modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
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 { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
@ -34,6 +35,7 @@ function bind(hostname: string, port: number, password: string) {
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
),
|
||||
).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 {
|
||||
const spec: Command.Command.Any = Object.keys(node.commands).length
|
||||
? (node.spec as Command.Command<string, unknown>).pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
const spec = handler
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
)
|
||||
: node.spec
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
if (!handler) return spec as ProvidedCommand
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
return spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
debug: {
|
||||
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 { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "path"
|
||||
|
||||
export interface Interface {
|
||||
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 status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
@ -108,16 +110,20 @@ export const layer = Layer.effect(
|
||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
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)
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}).unref()
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref()
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
|
||||
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* () {
|
||||
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* () {
|
||||
@ -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",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
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 default: () => 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({
|
||||
transform: state.transform,
|
||||
update: Effect.fn("AgentV2.update")(function* (update) {
|
||||
const transform = yield* state.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
update: state.update,
|
||||
get: Effect.fn("AgentV2.get")(function* (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,
|
||||
),
|
||||
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 }),
|
||||
)
|
||||
|
||||
@ -27,6 +27,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@ -54,6 +55,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
update: state.update,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
||||
@ -6,6 +6,7 @@ import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
|
||||
@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.contribute({
|
||||
yield* registry.register({
|
||||
key,
|
||||
load: observe().pipe(
|
||||
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(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
DefaultDatabase,
|
||||
DefaultEvents,
|
||||
DefaultProjector,
|
||||
DefaultStore,
|
||||
SessionExecution.noopLayer,
|
||||
ProjectV2.defaultLayer,
|
||||
),
|
||||
),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
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"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* for current-state adjustments that are intentionally outside the transform
|
||||
* 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> {
|
||||
@ -69,7 +71,7 @@ export function create<State extends Objectish, Editor>(options: Options<State,
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
return {
|
||||
const result: Interface<State, Editor> = {
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* () {
|
||||
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>)
|
||||
yield* update(api)
|
||||
if (options.finalize) yield* options.finalize(api, reason)
|
||||
}, 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 { SystemContext } from "./index"
|
||||
|
||||
export interface Contribution {
|
||||
export interface Entry {
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
|
||||
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
|
||||
|
||||
return Service.of({
|
||||
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
|
||||
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
|
||||
yield* Effect.acquireRelease(
|
||||
Ref.modify(contributions, (current) => {
|
||||
if (current.some((item) => item.key === contribution.key)) return [false, current]
|
||||
return [true, [...current, contribution]]
|
||||
Ref.modify(entries, (current) => {
|
||||
if (current.some((item) => item.key === entry.key)) return [false, current]
|
||||
return [true, [...current, entry]]
|
||||
}).pipe(
|
||||
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* () {
|
||||
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(
|
||||
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"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* MCP and plugin tools later use separate scoped canonical registrations, while
|
||||
* 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
|
||||
* parity, task, LSP,
|
||||
* 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(
|
||||
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* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
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* () {
|
||||
const values = state()
|
||||
const layerScope = yield* Scope.fork(yield* Scope.Scope)
|
||||
|
||||
@ -173,7 +173,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
registry.contribute({
|
||||
registry.register({
|
||||
key: systemContextKey,
|
||||
load: Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
|
||||
@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
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),
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
@ -20,7 +20,7 @@ const contribution = (key: string, text: string, sourceKey = key) => ({
|
||||
const it = testEffect(SystemContextRegistry.layer)
|
||||
|
||||
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* () {
|
||||
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* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.contribute(contribution("test/second", "second"))
|
||||
yield* registry.contribute(contribution("test/first", "first"))
|
||||
yield* registry.register(entry("test/second", "second"))
|
||||
yield* registry.register(entry("test/first", "first"))
|
||||
|
||||
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* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
let loads = 0
|
||||
yield* registry.contribute({
|
||||
yield* registry.register({
|
||||
key: SystemContext.Key.make("test/dynamic"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates contribution producer failures", () =>
|
||||
it.effect("propagates entry producer failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const failure = new Error("contribution failed")
|
||||
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
const failure = new Error("entry failed")
|
||||
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
|
||||
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* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
|
||||
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
|
||||
yield* registry.register(entry("test/first", "first", "test/duplicate"))
|
||||
yield* registry.register(entry("test/second", "second", "test/duplicate"))
|
||||
|
||||
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* () {
|
||||
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)
|
||||
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* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
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")
|
||||
|
||||
|
||||
@ -89,6 +89,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
|
||||
@ -1,386 +1 @@
|
||||
export default {
|
||||
// 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
export { default } from "@opencode-ai/tui/parsers-config"
|
||||
|
||||
@ -158,7 +158,7 @@ for (const item of targets) {
|
||||
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 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
|
||||
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema"
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
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) {
|
||||
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"
|
||||
|
||||
// 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>()
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { validateSession } from "./validate-session"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { resolveTuiRuntime } from "../tui/runtime"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
@ -44,7 +45,7 @@ export const AttachCommand = cmd({
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { TuiConfig } = await import("@/cli/cmd/tui/config/tui")
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
win32DisableProcessedInput()
|
||||
@ -67,6 +68,7 @@ export const AttachCommand = cmd({
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
const runtime = resolveTuiRuntime(config)
|
||||
|
||||
try {
|
||||
await validateSession({
|
||||
@ -81,11 +83,16 @@ export const AttachCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
|
||||
const { createLegacyTuiHost } = await import("../tui/host")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
const renderer = await createTuiRenderer(config, runtime)
|
||||
const handle = tui({
|
||||
...runtime,
|
||||
url: args.url,
|
||||
config,
|
||||
host: createLegacyTuiHost(renderer),
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
renderer,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
@ -1,48 +1 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
export * from "@opencode-ai/tui/prompt/display"
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
movePromptHistory,
|
||||
pushPromptHistory,
|
||||
} 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 type { RunFooterTheme } from "./theme"
|
||||
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 "opentui-spinner/solid"
|
||||
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 type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
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 { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import { createColors, createFrames } from "@opencode-ai/tui/ui/spinner"
|
||||
import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunCommandMenuBody,
|
||||
@ -33,7 +33,7 @@ import {
|
||||
useBindings,
|
||||
useKeymapSelector,
|
||||
type OpenTuiKeymap,
|
||||
} from "@/cli/cmd/tui/keymap"
|
||||
} from "@opencode-ai/tui/keymap"
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
FooterQueuedPrompt,
|
||||
|
||||
@ -6,17 +6,14 @@
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_LEADER_TIMEOUT = 2000
|
||||
|
||||
export type ModelInfo = {
|
||||
providers: RunProvider[]
|
||||
variants: string[]
|
||||
@ -70,13 +67,8 @@ function emptySessionInfo(): SessionInfo {
|
||||
}
|
||||
|
||||
function defaultRunTuiConfig(): RunTuiConfig {
|
||||
const keybinds = TuiKeybind.parse({})
|
||||
return {
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: DEFAULT_LEADER_TIMEOUT,
|
||||
...resolve({}, { terminalSuspend: process.platform !== "win32" }),
|
||||
diff_style: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
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 { withRunSpan } from "./otel"
|
||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||
|
||||
@ -590,7 +590,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
||||
const theme = resolveTheme(generateSystem(colors, pick), pick)
|
||||
const indexed = indexedPalette(colors, 256)
|
||||
const shared = await import("../tui/context/theme")
|
||||
const shared = await import("@opencode-ai/tui/context/theme")
|
||||
const syntaxTheme: SharedSyntaxTheme = {
|
||||
...theme,
|
||||
_hasSelectedListItemText: true,
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
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 = {
|
||||
type: "file"
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import { type rpc } from "../tui/worker"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
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 { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import type { EventSource } from "@opencode-ai/tui/context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import {
|
||||
OPENCODE_PROCESS_ROLE,
|
||||
@ -19,7 +19,8 @@ import {
|
||||
ensureRunID,
|
||||
sanitizedProcessEnv,
|
||||
} 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 {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@ -57,9 +58,9 @@ function createEventSource(client: RpcClient): EventSource {
|
||||
|
||||
async function target() {
|
||||
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
|
||||
return new URL("./worker.ts", import.meta.url)
|
||||
return new URL("../tui/worker.ts", import.meta.url)
|
||||
}
|
||||
|
||||
async function input(value?: string) {
|
||||
@ -112,7 +113,7 @@ export const TuiThreadCommand = cmd({
|
||||
describe: "agent to use",
|
||||
}),
|
||||
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.
|
||||
// (Important when running under `bun run` wrappers on Windows.)
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
@ -188,6 +189,7 @@ export const TuiThreadCommand = cmd({
|
||||
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
const runtime = resolveTuiRuntime(config)
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
@ -228,9 +230,12 @@ export const TuiThreadCommand = cmd({
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
|
||||
const { createLegacyTuiHost } = await import("../tui/host")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
const renderer = await createTuiRenderer(config, runtime)
|
||||
const handle = tui({
|
||||
...runtime,
|
||||
url: transport.url,
|
||||
renderer,
|
||||
async onSnapshot() {
|
||||
@ -239,6 +244,8 @@ export const TuiThreadCommand = cmd({
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
host: createLegacyTuiHost(renderer),
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
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 = {
|
||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const go = {
|
||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~,"
|
||||
export * from "@opencode-ai/tui/logo"
|
||||
|
||||
@ -9,10 +9,10 @@ import type {
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { AttentionSoundName, type TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { TuiConfig } from "./config/tui"
|
||||
import { isAttentionSoundName } from "./config/tui-schema"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import * as TuiAudio from "./audio"
|
||||
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 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(
|
||||
Object.entries(pack.sounds).filter(
|
||||
(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 soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
|
||||
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)
|
||||
|
||||
if (!notification && !sound) {
|
||||
@ -1,13 +1,13 @@
|
||||
import { platform, release } from "os"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { lazy } from "../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import * as Filesystem from "../../../../util/filesystem"
|
||||
import * as Process from "../../../../util/process"
|
||||
import * as Filesystem from "../../util/filesystem"
|
||||
import * as Process from "../../util/process"
|
||||
|
||||
const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
|
||||
Effect.runPromise(
|
||||
@ -1,9 +1,9 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { statSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { EditorSelection } from "./editor"
|
||||
import type { EditorSelection } from "@opencode-ai/tui/context/editor"
|
||||
|
||||
const ZedEditorRowSchema = Schema.Struct({
|
||||
item_kind: Schema.String,
|
||||
@ -201,7 +201,7 @@ export function isZedTerminal() {
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return Filesystem.stat(item)?.isFile() === true
|
||||
return statSync(item).isFile()
|
||||
} catch {
|
||||
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 { unique } from "remeda"
|
||||
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 { Global } from "@opencode-ai/core/global"
|
||||
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 decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed)
|
||||
const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration)
|
||||
const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle)
|
||||
const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed)
|
||||
const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration)
|
||||
const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle)
|
||||
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
@ -1,57 +1,47 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import path from "path"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
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 * as ConfigPaths from "@/config/paths"
|
||||
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 { isRecord } from "@/util/record"
|
||||
import { isRecord } from "@opencode-ai/tui/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CurrentWorkingDirectory } from "./cwd"
|
||||
import { CurrentWorkingDirectory } from "./tui-cwd"
|
||||
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 { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
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 { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
export const Info = TuiConfig.Info
|
||||
export type Info = TuiConfig.Info
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
plugin_origins: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
|
||||
attention: {
|
||||
enabled: boolean
|
||||
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.
|
||||
export type Resolved = TuiConfig.Resolved
|
||||
|
||||
export type HostMetadata = {
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Resolved>
|
||||
readonly pluginOrigins: () => Effect.Effect<ConfigPlugin.Origin[]>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@ -104,10 +94,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
Effect.gen(function* () {
|
||||
const plugins = config.plugin
|
||||
if (!plugins) return config
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath))
|
||||
return {
|
||||
...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> =>
|
||||
@ -126,7 +118,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
...parsed,
|
||||
attention: {
|
||||
...parsed.attention,
|
||||
sounds: resolveAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
|
||||
sounds: resolveHostAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
|
||||
},
|
||||
}
|
||||
: parsed
|
||||
@ -183,9 +175,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...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
|
||||
})
|
||||
|
||||
@ -230,34 +225,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...acc.result.keybinds }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
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 ?? {},
|
||||
const result = TuiConfig.resolve(
|
||||
{
|
||||
...acc.result,
|
||||
},
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault,
|
||||
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
|
||||
}
|
||||
{
|
||||
terminalSuspend: process.platform !== "win32",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
config: result,
|
||||
pluginOrigins: acc.plugin_origins,
|
||||
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 pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
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")),
|
||||
)
|
||||
|
||||
@ -306,3 +286,7 @@ export async function waitForDependencies() {
|
||||
export async function 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 { ExportCommand } from "./cli/cmd/export"
|
||||
import { ImportCommand } from "./cli/cmd/import"
|
||||
import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { AttachCommand } from "./cli/cmd/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
|
||||
@ -25,7 +25,7 @@ import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
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 { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
|
||||
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"
|
||||
import path from "path"
|
||||
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 { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { resolveAttentionSoundPaths } from "../config/tui-schema"
|
||||
import { errorData, errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { isRecord } from "@opencode-ai/tui/util/record"
|
||||
import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention"
|
||||
import {
|
||||
readPackageThemes,
|
||||
readPluginId,
|
||||
@ -29,20 +29,20 @@ import {
|
||||
import { PluginLoader } from "@/plugin/loader"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
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 { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { internalTuiPlugins, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "@opencode-ai/tui/plugin/slots"
|
||||
import { ConfigPlugin } from "@/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 { Effect } from "effect"
|
||||
import { createPluginRuntime, type PluginRuntime, type TuiPluginHost } from "@opencode-ai/tui/plugin/runtime"
|
||||
|
||||
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
||||
|
||||
@ -110,6 +110,7 @@ const ScopedKeymapMethods = new Set<PropertyKey>([
|
||||
type RuntimeState = {
|
||||
directory: string
|
||||
api: Api
|
||||
view: PluginRuntime
|
||||
dispose?: () => void
|
||||
slots: HostSlots
|
||||
plugins: PluginEntry[]
|
||||
@ -176,7 +177,7 @@ function createScopedAttention(
|
||||
return scope.track(
|
||||
attention.soundboard.registerPack({
|
||||
...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) {
|
||||
plugin.enabled = 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
|
||||
plugin.scope = undefined
|
||||
await scope.dispose()
|
||||
state.view.update({ status: listPluginStatus(state) })
|
||||
return true
|
||||
}
|
||||
|
||||
async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||
plugin.enabled = 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 api = pluginApi(state, plugin, scope, plugin.id)
|
||||
@ -555,15 +563,18 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
|
||||
|
||||
if (!ok) {
|
||||
await scope.dispose()
|
||||
state.view.update({ status: listPluginStatus(state) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (!plugin.enabled) {
|
||||
await scope.dispose()
|
||||
state.view.update({ status: listPluginStatus(state) })
|
||||
return true
|
||||
}
|
||||
|
||||
plugin.scope = scope
|
||||
state.view.update({ status: listPluginStatus(state) })
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1014,11 +1025,11 @@ async function installPluginBySpec(
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
|
||||
export async function init(input: {
|
||||
api: HostPluginApi
|
||||
config: TuiConfig.Resolved
|
||||
config: TuiConfig.Resolved & TuiConfig.HostMetadata
|
||||
runtime?: PluginRuntime
|
||||
dispose?: () => void
|
||||
disposeTimeoutMs?: number
|
||||
}) {
|
||||
@ -1031,7 +1042,7 @@ export async function init(input: {
|
||||
}
|
||||
|
||||
dir = cwd
|
||||
loaded = load(input)
|
||||
loaded = load({ ...input, runtime: input.runtime ?? createPluginRuntime() })
|
||||
return loaded
|
||||
}
|
||||
|
||||
@ -1060,24 +1071,38 @@ export async function dispose() {
|
||||
const task = loaded
|
||||
loaded = undefined
|
||||
dir = ""
|
||||
if (task) await task
|
||||
if (task) await task.catch((error) => fail("failed to finish loading tui plugins during disposal", { error }))
|
||||
const state = runtime
|
||||
runtime = undefined
|
||||
if (!state) return
|
||||
const queue = [...state.plugins].reverse()
|
||||
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 cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
const slots = input.runtime.setupSlots(api)
|
||||
const next: RuntimeState = {
|
||||
directory: cwd,
|
||||
api,
|
||||
view: input.runtime,
|
||||
dispose: input.dispose,
|
||||
slots,
|
||||
plugins: [],
|
||||
@ -1086,15 +1111,25 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
|
||||
dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
|
||||
}
|
||||
runtime = next
|
||||
next.view.update({
|
||||
commands: {
|
||||
activate: activatePlugin,
|
||||
deactivate: deactivatePlugin,
|
||||
add: addPlugin,
|
||||
install: installPlugin,
|
||||
},
|
||||
status: listPluginStatus(next),
|
||||
})
|
||||
try {
|
||||
const flags = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* RuntimeFlags.Service
|
||||
}).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
|
||||
)
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||
const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins())
|
||||
const records = Flag.OPENCODE_PURE ? [] : pluginOrigins
|
||||
if (Flag.OPENCODE_PURE && pluginOrigins.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: pluginOrigins.length })
|
||||
}
|
||||
|
||||
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.
|
||||
await activatePluginEntry(next, plugin, false)
|
||||
}
|
||||
next.view.update({ status: listPluginStatus(next) })
|
||||
} catch (error) {
|
||||
fail("failed to load tui plugins", { directory: cwd, error })
|
||||
}
|
||||
}
|
||||
|
||||
export function createLegacyTuiPluginHost(): TuiPluginHost {
|
||||
return {
|
||||
start: init,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiPluginRuntime from "./runtime"
|
||||
@ -17,11 +17,12 @@ import { ProjectCopyApi } from "./groups/project-copy"
|
||||
import { ProviderApi } from "./groups/provider"
|
||||
import { PtyApi, PtyConnectApi } from "./groups/pty"
|
||||
import { QuestionApi } from "./groups/question"
|
||||
import { ReferenceApi } from "./groups/reference"
|
||||
import { SessionApi } from "./groups/session"
|
||||
import { SyncApi } from "./groups/sync"
|
||||
import { TuiApi } from "./groups/tui"
|
||||
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.
|
||||
import { GlobalApi } from "./groups/global"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
@ -60,6 +61,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(QuestionApi)
|
||||
.addHttpApi(PermissionApi)
|
||||
.addHttpApi(ProviderApi)
|
||||
.addHttpApi(ReferenceApi)
|
||||
.addHttpApi(SessionApi)
|
||||
.addHttpApi(SyncApi)
|
||||
.addHttpApi(TuiApi)
|
||||
@ -70,7 +72,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||
.addHttpApi(RootHttpApi)
|
||||
.addHttpApi(EventApi)
|
||||
.addHttpApi(InstanceHttpApi)
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(Api)
|
||||
.addHttpApi(PtyConnectApi)
|
||||
.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 { Schema } from "effect"
|
||||
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 { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { TuiEvent } from "@/server/tui-event"
|
||||
import { Session } from "@/session/session"
|
||||
import { Effect } from "effect"
|
||||
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 { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
|
||||
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 UNAUTHORIZED = 401
|
||||
|
||||
@ -35,6 +35,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { LLM } from "@/session/llm"
|
||||
@ -60,13 +61,13 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { V2Api } from "@opencode-ai/server/api"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { PublicApi } from "./public"
|
||||
import {
|
||||
authorizationLayer,
|
||||
authorizationRouterMiddleware,
|
||||
ptyConnectAuthorizationLayer,
|
||||
v2AuthorizationLayer,
|
||||
serverAuthorizationLayer,
|
||||
} from "./middleware/authorization"
|
||||
import { EventApi } from "./groups/event"
|
||||
import { PtyConnectApi } from "./groups/pty"
|
||||
@ -85,10 +86,11 @@ import { projectCopyHandlers } from "./handlers/project-copy"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
|
||||
import { questionHandlers } from "./handlers/question"
|
||||
import { referenceHandlers } from "./handlers/reference"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
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 { workspaceHandlers } from "./handlers/workspace"
|
||||
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 httpApiAuthLayer = authorizationLayer.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 rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
@ -147,6 +149,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
projectCopyHandlers,
|
||||
ptyHandlers,
|
||||
questionHandlers,
|
||||
referenceHandlers,
|
||||
permissionHandlers,
|
||||
providerHandlers,
|
||||
sessionHandlers,
|
||||
@ -159,9 +162,9 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
const instanceRoutes = instanceApiRoutes.pipe(
|
||||
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
)
|
||||
const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
|
||||
Layer.provide(v2Handlers),
|
||||
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
|
||||
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
|
||||
Layer.provide(handlers),
|
||||
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
@ -201,7 +204,7 @@ export function createRoutes(
|
||||
eventApiRoutes,
|
||||
ptyConnectApiRoutes,
|
||||
instanceRoutes,
|
||||
v2Routes,
|
||||
serverRoutes,
|
||||
docRoute,
|
||||
uiRoute,
|
||||
).pipe(
|
||||
@ -234,6 +237,7 @@ export function createRoutes(
|
||||
Provider.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
|
||||
@ -12,6 +12,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { eq } from "drizzle-orm"
|
||||
@ -967,6 +968,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { hideBin } from "yargs/helpers"
|
||||
import { Log } from "./node"
|
||||
|
||||
@ -1,88 +1 @@
|
||||
import { isRecord } from "./record"
|
||||
|
||||
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
|
||||
}
|
||||
export * from "@opencode-ai/tui/util/error"
|
||||
|
||||
@ -1,86 +1,2 @@
|
||||
export function titlecase(str: string) {
|
||||
return str.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
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"
|
||||
export * from "@opencode-ai/tui/util/locale"
|
||||
export { Locale } from "@opencode-ai/tui/util/locale"
|
||||
|
||||
@ -1,3 +1 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
export * from "@opencode-ai/tui/util/record"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||
import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { createTuiAttention } from "@/cli/tui/attention"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
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 { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
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 {
|
||||
RUN_COMMAND_PANEL_ROWS,
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createPromptHistory,
|
||||
displayCharAt,
|
||||
displaySlice,
|
||||
isExitCommand,
|
||||
isNewCommand,
|
||||
mentionTriggerIndex,
|
||||
movePromptHistory,
|
||||
pushPromptHistory,
|
||||
} from "@/cli/cmd/run/prompt.shared"
|
||||
@ -90,35 +87,6 @@ describe("run prompt shared", () => {
|
||||
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", () => {
|
||||
expect(isExitCommand("/exit")).toBe(true)
|
||||
expect(isExitCommand(" /Quit ")).toBe(true)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
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 { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
|
||||
@ -4,12 +4,13 @@ import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime"
|
||||
import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app"
|
||||
import { tui, type TuiHandle } from "@opencode-ai/tui"
|
||||
import { createLegacyTuiHost } from "../../../src/cli/tui/host"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio"
|
||||
import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap"
|
||||
import * as TuiAudio from "../../../src/cli/tui/audio"
|
||||
import * as TuiKeymap from "@opencode-ai/tui/keymap"
|
||||
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
|
||||
|
||||
type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
|
||||
type TmpDir = Awaited<ReturnType<typeof tmpdir>>
|
||||
@ -39,7 +40,6 @@ afterEach(async () => {
|
||||
current?.restore?.()
|
||||
await Bun.sleep(20)
|
||||
await current?.tmp?.[Symbol.asyncDispose]()
|
||||
await TuiPluginRuntime.dispose().catch(() => {})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const beforeSighup = process.listenerCount("SIGHUP")
|
||||
const app = await startTui()
|
||||
@ -73,7 +90,7 @@ test("exit destroys the renderer, resolves done, and runs cleanup once", async (
|
||||
await app.handle.done
|
||||
|
||||
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 () => {
|
||||
@ -124,7 +141,7 @@ test("direct renderer destruction still cleans up and resolves done", async () =
|
||||
app.setup.renderer.destroy()
|
||||
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 () => {
|
||||
@ -135,7 +152,7 @@ test("SIGHUP exits before ready and removes its listener", async () => {
|
||||
await app.handle.done
|
||||
|
||||
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 () => {
|
||||
@ -161,7 +178,6 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
|
||||
const disposeAudio = spyOn(TuiAudio, "dispose")
|
||||
|
||||
try {
|
||||
@ -175,18 +191,37 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
|
||||
|
||||
expect(registerKeymap).toHaveBeenCalledTimes(1)
|
||||
expect(unregisterKeymapCalls).toBe(1)
|
||||
expect(disposePlugins).toHaveBeenCalledTimes(1)
|
||||
expect(app.pluginHost.disposes).toBe(1)
|
||||
expect(disposeAudio).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
registerKeymap.mockRestore()
|
||||
disposePlugins.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 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 theme = deferred<"dark" | "light" | null>()
|
||||
const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
|
||||
@ -197,13 +232,48 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
|
||||
|
||||
const calls = createFetch()
|
||||
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({
|
||||
environment,
|
||||
build: createTuiBuildInfo({ version: "test", channel: "test" }),
|
||||
url: "http://test",
|
||||
renderer: setup.renderer,
|
||||
host: createLegacyTuiHost(setup.renderer),
|
||||
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
|
||||
directory,
|
||||
fetch: calls.fetch,
|
||||
events: events.source,
|
||||
pluginHost,
|
||||
args: {},
|
||||
})
|
||||
active = {
|
||||
@ -212,27 +282,26 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
|
||||
tmp,
|
||||
restore: () => {
|
||||
waitForThemeMode.mockRestore()
|
||||
restore()
|
||||
isolated.restore()
|
||||
},
|
||||
}
|
||||
|
||||
return { handle, setup, theme }
|
||||
return { handle, setup, theme, pluginHost }
|
||||
}
|
||||
|
||||
async function isolateGlobalPaths(root: string) {
|
||||
const previous = {
|
||||
config: Global.Path.config,
|
||||
state: Global.Path.state,
|
||||
}
|
||||
const previous = Global.Path.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.state, { recursive: true })
|
||||
await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false }))
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
|
||||
|
||||
return () => {
|
||||
Global.Path.config = previous.config
|
||||
Global.Path.state = previous.state
|
||||
return {
|
||||
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,
|
||||
resolveZedDbPath,
|
||||
resolveZedSelection,
|
||||
} from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||
} from "../../../src/cli/tui/editor-zed"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const originalZedTerm = process.env.ZED_TERM
|
||||
|
||||
@ -3,9 +3,12 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, expect, spyOn, test } from "bun:test"
|
||||
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 { 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 originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
|
||||
@ -31,10 +34,19 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
|
||||
return (
|
||||
<EditorContextProvider WebSocketImpl={WebSocketImpl}>
|
||||
<Consumer />
|
||||
</EditorContextProvider>
|
||||
<TestTuiEnvironmentProvider
|
||||
cwd={process.cwd()}
|
||||
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[]) {
|
||||
let index = 0
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@ -6,7 +6,7 @@ import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@ -5,10 +5,10 @@ import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
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"
|
||||
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@ -8,12 +8,12 @@ import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
|
||||
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 { PluginLoader } from "../../../src/plugin/loader"
|
||||
|
||||
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
const { allThemes, addTheme } = await import("@opencode-ai/tui/context/theme")
|
||||
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
|
||||
|
||||
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