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

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

View File

@ -94,8 +94,12 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/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
View File

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

View File

@ -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:*",

View File

@ -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") } : {}),
},
})

View File

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

View File

@ -1,4 +1,5 @@
import { NodeHttpServer } from "@effect/platform-node"
import { 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))
}

View File

@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
}
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
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
}

View File

@ -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"),
},

View File

@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { 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
View File

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

View File

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

View File

@ -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)
}),

View File

@ -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 }),
)

View File

@ -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)

View File

@ -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),
)

View File

@ -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) =>

View File

@ -425,20 +425,12 @@ export const layer = Layer.effect(
}),
)
const DefaultDatabase = Database.defaultLayer
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
export const defaultLayer = layer.pipe(
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,
)

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer"
/**
* 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
}

View File

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

View File

@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
import { Context, Effect, Layer, Ref, Scope } from "effect"
import { 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" }),
)
}),
})

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { 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")

View File

@ -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",

View File

@ -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"

View File

@ -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/"

View File

@ -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))
}

View File

@ -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>()

View File

@ -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,

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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,

View File

@ -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",
}
}

View File

@ -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"

View File

@ -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,

View File

@ -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"

View 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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

@ -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(

View File

@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { 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

View File

@ -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())
}

View File

@ -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"

View File

@ -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"

View File

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

View File

@ -13,11 +13,11 @@ import {
} from "@opencode-ai/plugin/tui"
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"

View File

@ -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])

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { EventV2Bridge } from "@/event-v2-bridge"
import { 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"

View File

@ -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

View File

@ -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,

View File

@ -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),
)

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js"
import { 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,

View File

@ -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)

View File

@ -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"

View File

@ -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
},
}
}

View File

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

View File

@ -8,7 +8,7 @@ import {
offsetToPosition,
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

View File

@ -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

View File

@ -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({

View File

@ -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({

View File

@ -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({

View File

@ -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({

View File

@ -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({

View File

@ -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