squashed
This commit is contained in:
@@ -6,7 +6,7 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { online, proxied } from "@/util/network"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
@@ -68,12 +68,13 @@ export namespace BunProc {
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version !== "latest" && cachedVersion === version) {
|
||||
return mod
|
||||
} else if (version === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!isOutdated) return mod
|
||||
if (!online()) return mod
|
||||
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!stale) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
} else if (cachedVersion === version) {
|
||||
return mod
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import semver from "semver"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { online } from "@/util/network"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
@@ -10,6 +11,11 @@ export namespace PackageRegistry {
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
if (!online()) {
|
||||
log.debug("offline, skipping bun info", { pkg, field })
|
||||
return null
|
||||
}
|
||||
|
||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
env: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { EOL } from "os"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
const QueryCommand = cmd({
|
||||
command: "$0 [query]",
|
||||
@@ -39,7 +40,7 @@ const QueryCommand = cmd({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
UI.error(err instanceof Error ? err.message : String(err))
|
||||
UI.error(errorMessage(err))
|
||||
process.exit(1)
|
||||
}
|
||||
db.close()
|
||||
@@ -100,7 +101,7 @@ const MigrateCommand = cmd({
|
||||
}
|
||||
} catch (err) {
|
||||
if (tty) process.stderr.write("\x1b[?25h")
|
||||
UI.error(`Migration failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
UI.error(`Migration failed: ${errorMessage(err)}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
sqlite.close()
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { cmd } from "./cmd"
|
||||
import type { Argv } from "yargs"
|
||||
import { spinner, log, intro, outro } from "@clack/prompts"
|
||||
import path from "path"
|
||||
import type { BigIntStats, Stats } from "fs"
|
||||
import { mkdir } from "fs/promises"
|
||||
import {
|
||||
type ParseError as JsoncParseError,
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
printParseErrorCode,
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Global } from "../../global"
|
||||
import { UI } from "../ui"
|
||||
import { ConfigPaths } from "../../config/paths"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Flock } from "../../util/flock"
|
||||
import { Process } from "../../util/process"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { parsePluginSpecifier, resolvePluginTarget } from "../../plugin/shared"
|
||||
|
||||
type Mode = "noop" | "add" | "replace"
|
||||
|
||||
function pluginSpec(item: unknown) {
|
||||
if (typeof item === "string") return item
|
||||
if (!Array.isArray(item)) return
|
||||
if (typeof item[0] !== "string") return
|
||||
return item[0]
|
||||
}
|
||||
|
||||
function patchPluginList(list: unknown[], mod: string, force = false): { mode: Mode; list: unknown[] } {
|
||||
const pkg = parsePluginSpecifier(mod).pkg
|
||||
const rows = list.map((item, i) => ({
|
||||
item,
|
||||
i,
|
||||
spec: pluginSpec(item),
|
||||
}))
|
||||
const dup = rows.filter((item) => {
|
||||
if (!item.spec) return false
|
||||
if (item.spec === mod) return true
|
||||
if (item.spec.startsWith("file://")) return false
|
||||
return parsePluginSpecifier(item.spec).pkg === pkg
|
||||
})
|
||||
|
||||
if (!dup.length) {
|
||||
return {
|
||||
mode: "add",
|
||||
list: [...list, mod],
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
}
|
||||
}
|
||||
|
||||
const keep = dup[0]
|
||||
if (!keep) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
}
|
||||
}
|
||||
|
||||
if (dup.length === 1 && keep.spec === mod) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = new Set(dup.map((item) => item.i))
|
||||
return {
|
||||
mode: "replace",
|
||||
list: rows.flatMap((item) => {
|
||||
if (!idx.has(item.i)) return [item.item]
|
||||
if (item.i !== keep.i) return []
|
||||
if (typeof item.item === "string") return [mod]
|
||||
if (Array.isArray(item.item) && typeof item.item[0] === "string") {
|
||||
return [[mod, ...item.item.slice(1)]]
|
||||
}
|
||||
return [item.item]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type Spin = {
|
||||
start: (msg: string) => void
|
||||
stop: (msg: string, code?: number) => void
|
||||
}
|
||||
|
||||
export type PlugDeps = {
|
||||
spinner: () => Spin
|
||||
log: {
|
||||
error: (msg: string) => void
|
||||
info: (msg: string) => void
|
||||
success: (msg: string) => void
|
||||
}
|
||||
mkdir: (dir: string, opts: { recursive: true }) => Promise<void>
|
||||
resolve: (spec: string) => Promise<string>
|
||||
stat: (file: string) => Stats | BigIntStats | undefined
|
||||
readJson: <T = unknown>(file: string) => Promise<T>
|
||||
readText: (file: string) => Promise<string>
|
||||
write: (file: string, text: string) => Promise<void>
|
||||
exists: (file: string) => Promise<boolean>
|
||||
files: (dir: string, name: "opencode" | "tui") => string[]
|
||||
global: string
|
||||
}
|
||||
|
||||
export type PlugInput = {
|
||||
mod: string
|
||||
global?: boolean
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export type PlugCtx = {
|
||||
vcs?: string
|
||||
worktree: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
const defaultPlugDeps: PlugDeps = {
|
||||
spinner: () => spinner(),
|
||||
log: {
|
||||
error: (msg) => log.error(msg),
|
||||
info: (msg) => log.info(msg),
|
||||
success: (msg) => log.success(msg),
|
||||
},
|
||||
mkdir: async (dir, opts) => {
|
||||
await mkdir(dir, opts)
|
||||
},
|
||||
resolve: (spec) => resolvePluginTarget(spec),
|
||||
stat: (file) => Filesystem.stat(file),
|
||||
readJson: (file) => Filesystem.readJson(file),
|
||||
readText: (file) => Filesystem.readText(file),
|
||||
write: async (file, text) => {
|
||||
await Filesystem.write(file, text)
|
||||
},
|
||||
exists: (file) => Filesystem.exists(file),
|
||||
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
|
||||
global: Global.Path.config,
|
||||
}
|
||||
|
||||
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
|
||||
const mod = input.mod
|
||||
const force = Boolean(input.force)
|
||||
const global = Boolean(input.global)
|
||||
|
||||
return async (ctx: PlugCtx) => {
|
||||
const root = ctx.vcs === "git" ? ctx.worktree : ctx.directory
|
||||
const dir = global ? dep.global : path.join(root, ".opencode")
|
||||
await dep.mkdir(dir, { recursive: true })
|
||||
|
||||
const install = dep.spinner()
|
||||
install.start("Installing plugin package...")
|
||||
const target = await dep.resolve(mod).catch((err) => err)
|
||||
if (target instanceof Error) {
|
||||
install.stop("Install failed", 1)
|
||||
dep.log.error(`Could not install "${mod}"`)
|
||||
if (target instanceof Process.RunFailedError) {
|
||||
const lines = target.stderr
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const errors = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||
const detail = errors[0] ?? lines.at(-1)
|
||||
if (detail) dep.log.error(detail)
|
||||
if (lines.some((line) => line.includes("No version matching"))) {
|
||||
dep.log.info("This package depends on a version that is not available in your npm registry.")
|
||||
dep.log.info("Check npm registry/auth settings and try again.")
|
||||
}
|
||||
} else {
|
||||
dep.log.error(errorMessage(target))
|
||||
}
|
||||
return false
|
||||
}
|
||||
install.stop("Plugin package ready")
|
||||
|
||||
const inspect = dep.spinner()
|
||||
inspect.start("Reading plugin manifest...")
|
||||
const stat = dep.stat(target)
|
||||
const base = stat?.isDirectory() ? target : path.dirname(target)
|
||||
const file = path.join(base, "package.json")
|
||||
const json = await dep.readJson<Record<string, unknown>>(file).catch((err) => err)
|
||||
if (json instanceof Error) {
|
||||
inspect.stop("Manifest read failed", 1)
|
||||
dep.log.error(`Installed "${mod}" but failed to read ${file}`)
|
||||
dep.log.error(errorMessage(json))
|
||||
return false
|
||||
}
|
||||
|
||||
const raw = json["oc-plugin"]
|
||||
const kinds = Array.isArray(raw) ? raw.filter((x): x is "server" | "tui" => x === "server" || x === "tui") : []
|
||||
|
||||
if (!kinds.length) {
|
||||
inspect.stop("No plugin targets found", 1)
|
||||
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
|
||||
dep.log.info('Expected: "oc-plugin": ["server", "tui"] (or either one).')
|
||||
return false
|
||||
}
|
||||
inspect.stop(`Detected ${kinds.join(" + ")} target${kinds.length === 1 ? "" : "s"}`)
|
||||
|
||||
const patch = async (name: "opencode" | "tui", kind: "server" | "tui") => {
|
||||
const spin = dep.spinner()
|
||||
spin.start(`Updating ${kind} config...`)
|
||||
|
||||
await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
|
||||
|
||||
const files = dep.files(dir, name)
|
||||
let cfg = files[0]
|
||||
for (const file of files) {
|
||||
if (!(await dep.exists(file))) continue
|
||||
cfg = file
|
||||
break
|
||||
}
|
||||
|
||||
const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw err
|
||||
})
|
||||
const text = src.trim() ? src : "{}"
|
||||
const errs: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errs, { allowTrailingComma: true })
|
||||
if (errs.length) {
|
||||
const err = errs[0]
|
||||
const lines = text.substring(0, err.offset).split("\n")
|
||||
const line = lines.length
|
||||
const col = lines[lines.length - 1].length + 1
|
||||
spin.stop(`Failed updating ${kind} config`, 1)
|
||||
dep.log.error(`Invalid JSON in ${cfg} (${printParseErrorCode(err.error)} at line ${line}, column ${col})`)
|
||||
dep.log.info("Fix the config file and run the command again.")
|
||||
return false
|
||||
}
|
||||
|
||||
const list: unknown[] =
|
||||
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
|
||||
const out = patchPluginList(list, mod, force)
|
||||
|
||||
if (out.mode === "noop") {
|
||||
spin.stop(`Already configured in ${cfg}`)
|
||||
return true
|
||||
}
|
||||
|
||||
const edits = modify(text, ["plugin"], out.list, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
})
|
||||
await dep.write(cfg, applyEdits(text, edits))
|
||||
spin.stop(out.mode === "replace" ? `Replaced in ${cfg}` : `Added to ${cfg}`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (kinds.includes("server")) {
|
||||
const ok = await patch("opencode", "server")
|
||||
if (!ok) return false
|
||||
}
|
||||
|
||||
if (kinds.includes("tui")) {
|
||||
const ok = await patch("tui", "tui")
|
||||
if (!ok) return false
|
||||
}
|
||||
|
||||
dep.log.success(`Installed ${mod}`)
|
||||
dep.log.info(global ? `Scope: global (${dir})` : `Scope: local (${dir})`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const PlugCommand = cmd({
|
||||
command: "plug <module>",
|
||||
aliases: ["plugin"],
|
||||
describe: "install plugin and update config",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("module", {
|
||||
type: "string",
|
||||
describe: "npm module name",
|
||||
})
|
||||
.option("global", {
|
||||
alias: ["g"],
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "install in global config",
|
||||
})
|
||||
.option("force", {
|
||||
alias: ["f"],
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "replace existing plugin version",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
const mod = String(args.module ?? "").trim()
|
||||
if (!mod) {
|
||||
UI.error("module is required")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
intro(`Install plugin ${mod}`)
|
||||
|
||||
const run = createPlugTask({
|
||||
mod,
|
||||
global: Boolean(args.global),
|
||||
force: Boolean(args.force),
|
||||
})
|
||||
let ok = true
|
||||
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
fn: async () => {
|
||||
ok = await run({
|
||||
vcs: Instance.project.vcs,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
outro("Done")
|
||||
if (!ok) process.exitCode = 1
|
||||
},
|
||||
})
|
||||
@@ -1,15 +1,29 @@
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
createMemo,
|
||||
ErrorBoundary,
|
||||
createSignal,
|
||||
onMount,
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import semver from "semver"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
||||
@@ -21,7 +35,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
@@ -40,8 +54,10 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
@@ -104,7 +120,42 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const formatted = FormatError(error)
|
||||
if (formatted !== undefined) return formatted
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"data" in error &&
|
||||
typeof error.data === "object" &&
|
||||
error.data !== null &&
|
||||
"message" in error.data &&
|
||||
typeof error.data.message === "string"
|
||||
) {
|
||||
return error.data.message
|
||||
}
|
||||
return FormatUnknownError(error)
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
@@ -132,77 +183,68 @@ export function tui(input: {
|
||||
resolve()
|
||||
}
|
||||
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const onBeforeExit = async () => {
|
||||
await TuiPluginRuntime.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
|
||||
)}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
})
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -211,12 +253,46 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const routes: RouteMap = new Map()
|
||||
const [routeRev, setRouteRev] = createSignal(0)
|
||||
const routeView = (name: string) => {
|
||||
routeRev()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
}
|
||||
const api = createTuiApi({
|
||||
command,
|
||||
tuiConfig,
|
||||
dialog,
|
||||
keybind,
|
||||
kv,
|
||||
route,
|
||||
routes,
|
||||
bump: () => setRouteRev((x) => x + 1),
|
||||
sync,
|
||||
theme: themeState,
|
||||
toast,
|
||||
})
|
||||
const [ready, setReady] = createSignal(false)
|
||||
TuiPluginRuntime.init({
|
||||
client: sdk.client,
|
||||
event: sdk.event,
|
||||
renderer,
|
||||
...api,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
@@ -259,10 +335,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||
@@ -279,9 +351,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate title to 40 chars max
|
||||
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
||||
renderer.setTerminalTitle(`OC | ${title}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.data.type === "plugin") {
|
||||
renderer.setTerminalTitle(`OC | ${route.data.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -723,17 +799,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = (() => {
|
||||
if (!error) return "An error occurred"
|
||||
|
||||
if (typeof error === "object") {
|
||||
const data = error.data
|
||||
if ("message" in data && typeof data.message === "string") {
|
||||
return data.message
|
||||
}
|
||||
}
|
||||
return String(error)
|
||||
})()
|
||||
const message = errorMessage(error)
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
@@ -789,6 +855,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
exit()
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
if (!ready()) return
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = routeView(route.data.id)
|
||||
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
|
||||
return render({ params: route.data.data })
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
@@ -804,97 +878,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onExit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
// Choose safe fallback colors per mode since theme context may not be available
|
||||
const isLight = props.mode === "light"
|
||||
const colors = {
|
||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
||||
muted: isLight ? "#8a8a8a" : "#808080",
|
||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
||||
}
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
Please report an issue.
|
||||
</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
||||
Copy issue URL (exception info pre-filled)
|
||||
</text>
|
||||
</box>
|
||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={colors.text}>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text fg={colors.muted}>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text fg={colors.text}>{props.error.message}</text>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Show when={ready()}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
{plugin()}
|
||||
<TuiPluginRuntime.Slot name="app" />
|
||||
<StartupLoading ready={ready} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
@@ -21,7 +23,7 @@ export type Slash = {
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: KeybindKey
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
@@ -29,6 +31,7 @@ export type CommandOption = DialogSelectOption<string> & {
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = getOwner()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
@@ -100,11 +103,32 @@ function init() {
|
||||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
const owner = getOwner() ?? root
|
||||
if (!owner) return () => {}
|
||||
|
||||
let list: Accessor<CommandOption[]> | undefined
|
||||
|
||||
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
|
||||
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
|
||||
runWithOwner(owner, () => {
|
||||
list = createMemo(cb)
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => [ref, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
})
|
||||
})
|
||||
|
||||
if (!list) return () => {}
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -16,7 +16,8 @@ export function DialogStatus() {
|
||||
|
||||
const plugins = createMemo(() => {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((value) => {
|
||||
const result = list.map((item) => {
|
||||
const value = typeof item === "string" ? item : item[0]
|
||||
if (value.startsWith("file://")) {
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
reset: () => void
|
||||
onBeforeExit?: () => Promise<void>
|
||||
onExit: () => Promise<void>
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
await props.onBeforeExit?.()
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
// Choose safe fallback colors per mode since theme context may not be available
|
||||
const isLight = props.mode === "light"
|
||||
const colors = {
|
||||
bg: isLight ? "#ffffff" : "#0a0a0a",
|
||||
text: isLight ? "#1a1a1a" : "#eeeeee",
|
||||
muted: isLight ? "#8a8a8a" : "#808080",
|
||||
primary: isLight ? "#3b7dd8" : "#fab283",
|
||||
}
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
Please report an issue.
|
||||
</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
|
||||
Copy issue URL (exception info pre-filled)
|
||||
</text>
|
||||
</box>
|
||||
{copied() && <text fg={colors.muted}>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={colors.text}>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text fg={colors.muted}>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text fg={colors.text}>{props.error.message}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
|
||||
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.text}>go home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function StartupLoading(props: { ready: () => boolean }) {
|
||||
const theme = useTheme().theme
|
||||
const [show, setShow] = createSignal(false)
|
||||
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
|
||||
let wait: NodeJS.Timeout | undefined
|
||||
let hold: NodeJS.Timeout | undefined
|
||||
let stamp = 0
|
||||
|
||||
createEffect(() => {
|
||||
if (props.ready()) {
|
||||
if (wait) {
|
||||
clearTimeout(wait)
|
||||
wait = undefined
|
||||
}
|
||||
if (!show()) return
|
||||
if (hold) return
|
||||
|
||||
const left = 3000 - (Date.now() - stamp)
|
||||
if (left <= 0) {
|
||||
setShow(false)
|
||||
return
|
||||
}
|
||||
|
||||
hold = setTimeout(() => {
|
||||
hold = undefined
|
||||
setShow(false)
|
||||
}, left).unref()
|
||||
return
|
||||
}
|
||||
|
||||
if (hold) {
|
||||
clearTimeout(hold)
|
||||
hold = undefined
|
||||
}
|
||||
if (show()) return
|
||||
if (wait) return
|
||||
|
||||
wait = setTimeout(() => {
|
||||
wait = undefined
|
||||
stamp = Date.now()
|
||||
setShow(true)
|
||||
}, 500).unref()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (wait) clearTimeout(wait)
|
||||
if (hold) clearTimeout(hold)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={show()}>
|
||||
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
|
||||
<box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
|
||||
<Spinner color={theme.textMuted}>{text()}</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
@@ -33,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
(reason?: unknown) => {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
await input.onBeforeExit?.()
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
|
||||
@@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: KeybindKey, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: KeybindKey) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
if (!first) return ""
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base = {
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
const txt = (value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
if (!value.trim()) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPluginKeybind(
|
||||
base: Base,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
)
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,13 @@ export type SessionRoute = {
|
||||
initialPrompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute
|
||||
export type PluginRoute = {
|
||||
type: "plugin"
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
@@ -32,7 +38,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
@@ -128,7 +129,7 @@ type Variant = {
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant | RGBA
|
||||
type ThemeJson = {
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
@@ -174,27 +175,91 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
carbonfox,
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
const pluginThemes: Record<string, ThemeJson> = {}
|
||||
let customThemes: Record<string, ThemeJson> = {}
|
||||
let systemTheme: ThemeJson | undefined
|
||||
|
||||
function listThemes() {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes = {
|
||||
...DEFAULT_THEMES,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
}
|
||||
if (!systemTheme) return themes
|
||||
return {
|
||||
...themes,
|
||||
system: systemTheme,
|
||||
}
|
||||
}
|
||||
|
||||
function syncThemes() {
|
||||
setStore("themes", listThemes())
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: listThemes(),
|
||||
mode: "dark",
|
||||
lock: undefined,
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
|
||||
export function allThemes() {
|
||||
return store.themes
|
||||
}
|
||||
|
||||
function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (!isRecord(theme)) return false
|
||||
if (!isRecord(theme.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasTheme(name: string) {
|
||||
if (!name) return false
|
||||
return allThemes()[name] !== undefined
|
||||
}
|
||||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
pluginThemes[name] = theme
|
||||
syncThemes()
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
if (c instanceof RGBA) return c
|
||||
if (typeof c === "string") {
|
||||
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||
|
||||
if (c.startsWith("#")) return RGBA.fromHex(c)
|
||||
|
||||
if (defs[c] != null) {
|
||||
return resolveColor(defs[c])
|
||||
} else if (theme.theme[c as keyof ThemeColors] !== undefined) {
|
||||
return resolveColor(theme.theme[c as keyof ThemeColors]!)
|
||||
} else {
|
||||
if (chain.includes(c)) {
|
||||
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
|
||||
}
|
||||
|
||||
const next = defs[c] ?? theme.theme[c as keyof ThemeColors]
|
||||
if (next === undefined) {
|
||||
throw new Error(`Color reference "${c}" not found in defs or theme`)
|
||||
}
|
||||
return resolveColor(next, [...chain, c])
|
||||
}
|
||||
if (typeof c === "number") {
|
||||
return ansiToRgba(c)
|
||||
}
|
||||
return resolveColor(c[mode])
|
||||
return resolveColor(c[mode], chain)
|
||||
}
|
||||
|
||||
const resolved = Object.fromEntries(
|
||||
@@ -287,14 +352,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
|
||||
lock,
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const mode = pick(kv.get("theme_mode", props.mode))
|
||||
draft.mode = lock ?? mode ?? props.mode
|
||||
draft.lock = lock
|
||||
const active = config.theme ?? kv.get("theme", "opencode")
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme
|
||||
@@ -302,52 +371,46 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
function init() {
|
||||
resolveSystemTheme(store.mode)
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.active !== "system") {
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
Promise.allSettled([
|
||||
resolveSystemTheme(store.mode),
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
customThemes = custom
|
||||
syncThemes()
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
}),
|
||||
]).finally(() => {
|
||||
setStore("ready", true)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
renderer
|
||||
return renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
.then((colors: TerminalColors) => {
|
||||
if (!colors.palette[0]) {
|
||||
systemTheme = undefined
|
||||
syncThemes()
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
systemTheme = generateSystem(colors, mode)
|
||||
syncThemes()
|
||||
})
|
||||
.catch(() => {
|
||||
systemTheme = undefined
|
||||
syncThemes()
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -377,8 +440,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
|
||||
const refresh = () => {
|
||||
renderer.clearPaletteCache()
|
||||
init()
|
||||
}
|
||||
process.on("SIGUSR2", refresh)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
process.off("SIGUSR2", refresh)
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
@@ -403,7 +474,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
return store.active
|
||||
},
|
||||
all() {
|
||||
return store.themes
|
||||
return allThemes()
|
||||
},
|
||||
has(name: string) {
|
||||
return hasTheme(name)
|
||||
},
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
@@ -423,8 +497,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
pin(mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
kv.set("theme", theme)
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
return store.ready
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiApi, TuiDialogSelectOption, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { useSync } from "@tui/context/sync"
|
||||
import type { useTheme } from "@tui/context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||
import type { useKV } from "../context/kv"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
|
||||
import type { useToast } from "../ui/toast"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
render: TuiRouteDefinition["render"]
|
||||
}
|
||||
|
||||
export type RouteMap = Map<string, RouteEntry[]>
|
||||
|
||||
type Input = {
|
||||
command: ReturnType<typeof useCommandDialog>
|
||||
tuiConfig: TuiConfig.Info
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
keybind: ReturnType<typeof useKeybind>
|
||||
kv: ReturnType<typeof useKV>
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: RouteMap
|
||||
bump: () => void
|
||||
sync: ReturnType<typeof useSync>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}
|
||||
|
||||
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
|
||||
const key = Symbol()
|
||||
for (const item of list) {
|
||||
const prev = routes.get(item.name) ?? []
|
||||
prev.push({ key, render: item.render })
|
||||
routes.set(item.name, prev)
|
||||
}
|
||||
bump()
|
||||
|
||||
return () => {
|
||||
for (const item of list) {
|
||||
const prev = routes.get(item.name)
|
||||
if (!prev) continue
|
||||
const next = prev.filter((x) => x.key !== key)
|
||||
if (!next.length) {
|
||||
routes.delete(item.name)
|
||||
continue
|
||||
}
|
||||
routes.set(item.name, next)
|
||||
}
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
|
||||
if (name === "home") {
|
||||
route.navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "session") {
|
||||
const sessionID = params?.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
route.navigate({ type: "session", sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
route.navigate({ type: "plugin", id: name, data: params })
|
||||
}
|
||||
|
||||
function routeCurrent(route: ReturnType<typeof useRoute>): TuiApi["route"]["current"] {
|
||||
if (route.data.type === "home") return { name: "home" }
|
||||
if (route.data.type === "session") {
|
||||
return {
|
||||
name: "session",
|
||||
params: {
|
||||
sessionID: route.data.sessionID,
|
||||
initialPrompt: route.data.initialPrompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: route.data.id,
|
||||
params: route.data.data,
|
||||
}
|
||||
}
|
||||
|
||||
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
|
||||
return {
|
||||
...item,
|
||||
onSelect: () => item.onSelect?.(),
|
||||
}
|
||||
}
|
||||
|
||||
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
|
||||
return {
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
footer: item.footer,
|
||||
category: item.category,
|
||||
disabled: item.disabled,
|
||||
}
|
||||
}
|
||||
|
||||
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
if (!cb) return
|
||||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(sync: ReturnType<typeof useSync>): TuiApi["state"] {
|
||||
return {
|
||||
session: {
|
||||
diff(sessionID) {
|
||||
return sync.data.session_diff[sessionID] ?? []
|
||||
},
|
||||
todo(sessionID) {
|
||||
return sync.data.todo[sessionID] ?? []
|
||||
},
|
||||
},
|
||||
lsp() {
|
||||
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||
},
|
||||
mcp() {
|
||||
return Object.entries(sync.data.mcp)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, item]) => ({
|
||||
name,
|
||||
status: item.status,
|
||||
error: item.status === "failed" ? item.error : undefined,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiApi(input: Input): TuiApi {
|
||||
return {
|
||||
command: {
|
||||
register(cb) {
|
||||
return input.command.register(() => cb())
|
||||
},
|
||||
trigger(value) {
|
||||
input.command.trigger(value)
|
||||
},
|
||||
},
|
||||
route: {
|
||||
register(list) {
|
||||
return routeRegister(input.routes, list, input.bump)
|
||||
},
|
||||
navigate(name, params) {
|
||||
routeNavigate(input.route, name, params)
|
||||
},
|
||||
get current() {
|
||||
return routeCurrent(input.route)
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog(props) {
|
||||
return (
|
||||
<DialogUI size={props.size} onClose={props.onClose}>
|
||||
{props.children}
|
||||
</DialogUI>
|
||||
)
|
||||
},
|
||||
DialogAlert(props) {
|
||||
return <DialogAlert {...props} />
|
||||
},
|
||||
DialogConfirm(props) {
|
||||
return <DialogConfirm {...props} />
|
||||
},
|
||||
DialogPrompt(props) {
|
||||
return <DialogPrompt {...props} description={props.description} />
|
||||
},
|
||||
DialogSelect(props) {
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.title}
|
||||
placeholder={props.placeholder}
|
||||
options={props.options.map(mapOption)}
|
||||
flat={props.flat}
|
||||
onMove={mapOptionCb(props.onMove)}
|
||||
onFilter={props.onFilter}
|
||||
onSelect={mapOptionCb(props.onSelect)}
|
||||
skipFilter={props.skipFilter}
|
||||
current={props.current}
|
||||
/>
|
||||
)
|
||||
},
|
||||
toast(inputToast) {
|
||||
input.toast.show({
|
||||
title: inputToast.title,
|
||||
message: inputToast.message,
|
||||
variant: inputToast.variant ?? "info",
|
||||
duration: inputToast.duration,
|
||||
})
|
||||
},
|
||||
dialog: {
|
||||
replace(render, onClose) {
|
||||
input.dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
input.dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
input.dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return input.dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return input.dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return input.dialog.stack.length > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
match(key, evt: ParsedKey) {
|
||||
return input.keybind.match(key, evt)
|
||||
},
|
||||
print(key) {
|
||||
return input.keybind.print(key)
|
||||
},
|
||||
create(defaults, overrides) {
|
||||
return createPluginKeybind(input.keybind, defaults, overrides)
|
||||
},
|
||||
},
|
||||
get tuiConfig() {
|
||||
return input.tuiConfig
|
||||
},
|
||||
kv: {
|
||||
get(key, fallback) {
|
||||
return input.kv.get(key, fallback)
|
||||
},
|
||||
set(key, value) {
|
||||
input.kv.set(key, value)
|
||||
},
|
||||
get ready() {
|
||||
return input.kv.ready
|
||||
},
|
||||
},
|
||||
state: stateApi(input.sync),
|
||||
theme: {
|
||||
get current() {
|
||||
return input.theme.theme
|
||||
},
|
||||
get selected() {
|
||||
return input.theme.selected
|
||||
},
|
||||
has(name) {
|
||||
return input.theme.has(name)
|
||||
},
|
||||
set(name) {
|
||||
return input.theme.set(name)
|
||||
},
|
||||
async install(_jsonPath) {
|
||||
throw new Error("theme.install is only available in plugin context")
|
||||
},
|
||||
mode() {
|
||||
return input.theme.mode()
|
||||
},
|
||||
get ready() {
|
||||
return input.theme.ready
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TuiPluginRuntime } from "./runtime"
|
||||
export { createTuiApi } from "./api"
|
||||
export type { RouteMap } from "./api"
|
||||
@@ -0,0 +1,7 @@
|
||||
export type InternalTuiPlugin = {
|
||||
name: string
|
||||
module: Record<string, unknown>
|
||||
root?: string
|
||||
}
|
||||
|
||||
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = []
|
||||
@@ -0,0 +1,581 @@
|
||||
import "@opentui/solid/runtime-plugin-support"
|
||||
import {
|
||||
type TuiDispose,
|
||||
type TuiPlugin,
|
||||
type TuiPluginApi,
|
||||
type TuiPluginMeta,
|
||||
type TuiSlotPlugin,
|
||||
type TuiTheme,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Log } from "@/util/log"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { isDeprecatedPlugin, resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { addTheme, hasTheme } from "../context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlotPlugin, HostSlots } from "./slots"
|
||||
|
||||
type Loaded = {
|
||||
item?: Config.PluginSpec
|
||||
spec: string
|
||||
target: string
|
||||
retry: boolean
|
||||
mod: Record<string, unknown>
|
||||
install: TuiTheme["install"]
|
||||
}
|
||||
type Deps = {
|
||||
wait?: Promise<void>
|
||||
}
|
||||
|
||||
type Api = HostPluginApi & {
|
||||
slots: HostSlots
|
||||
}
|
||||
|
||||
type Scope = {
|
||||
lifecycle: TuiPluginApi<CliRenderer>["lifecycle"]
|
||||
wrap: (fn: (() => void) | undefined) => () => void
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "tui.plugin" })
|
||||
const DISPOSE_TIMEOUT_MS = 5000
|
||||
|
||||
function fail(message: string, data: Record<string, unknown>) {
|
||||
if (!("error" in data)) {
|
||||
log.error(message, data)
|
||||
console.error(`[tui.plugin] ${message}`, data)
|
||||
return
|
||||
}
|
||||
|
||||
const text = `${message}: ${errorMessage(data.error)}`
|
||||
const next = { ...data, error: errorData(data.error) }
|
||||
log.error(text, next)
|
||||
console.error(`[tui.plugin] ${text}`, next)
|
||||
}
|
||||
|
||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||
|
||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ type: "timeout" })
|
||||
}, ms)
|
||||
|
||||
Promise.resolve()
|
||||
.then(fn)
|
||||
.then(
|
||||
() => {
|
||||
resolve({ type: "ok" })
|
||||
},
|
||||
(error) => {
|
||||
resolve({ type: "error", error })
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isTuiPlugin(value: unknown): value is TuiPlugin<CliRenderer> {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function getTuiPlugin(value: unknown) {
|
||||
if (!isRecord(value) || !("tui" in value)) return
|
||||
if (!isTuiPlugin(value.tui)) return
|
||||
return value.tui
|
||||
}
|
||||
|
||||
function isTheme(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (!isRecord(value.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function localDir(file: string) {
|
||||
const dir = path.dirname(file)
|
||||
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
|
||||
return path.join(dir, ".opencode", "themes")
|
||||
}
|
||||
|
||||
function scopeDir(pluginMeta: TuiConfig.PluginMeta) {
|
||||
if (pluginMeta.scope === "local") return localDir(pluginMeta.source)
|
||||
return path.join(Global.Path.config, "themes")
|
||||
}
|
||||
|
||||
function pluginRoot(spec: string, target: string) {
|
||||
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
|
||||
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
|
||||
return target
|
||||
}
|
||||
|
||||
function rootDir(root?: string) {
|
||||
if (!root) return process.cwd()
|
||||
if (root.startsWith("file://")) {
|
||||
const file = fileURLToPath(root)
|
||||
if (root.endsWith("/")) return file
|
||||
return path.dirname(file)
|
||||
}
|
||||
if (path.isAbsolute(root)) return root
|
||||
return path.resolve(process.cwd(), root)
|
||||
}
|
||||
|
||||
function resolveThemePath(root: string, file: string) {
|
||||
if (file.startsWith("file://")) return fileURLToPath(file)
|
||||
if (path.isAbsolute(file)) return file
|
||||
return path.resolve(root, file)
|
||||
}
|
||||
|
||||
function themeName(file: string) {
|
||||
return path.basename(file, path.extname(file))
|
||||
}
|
||||
|
||||
function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) {
|
||||
const key = Config.getPluginName(item)
|
||||
return config.plugin_meta?.[key]
|
||||
}
|
||||
|
||||
function makeInstallFn(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const src = resolveThemePath(root, file)
|
||||
const theme = themeName(src)
|
||||
if (hasTheme(theme)) return
|
||||
|
||||
const text = await Filesystem.readText(src).catch((error) => {
|
||||
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||
return
|
||||
})
|
||||
if (text === undefined) return
|
||||
|
||||
const fail = Symbol()
|
||||
const data = await Promise.resolve(text)
|
||||
.then((x) => JSON.parse(x))
|
||||
.catch((error) => {
|
||||
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
||||
return fail
|
||||
})
|
||||
if (data === fail) return
|
||||
|
||||
if (!isTheme(data)) {
|
||||
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
||||
return
|
||||
}
|
||||
|
||||
const dest = path.join(scopeDir(meta), `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text).catch((error) => {
|
||||
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||
})
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
}
|
||||
}
|
||||
|
||||
function waitDeps(state: Deps) {
|
||||
state.wait ??= TuiConfig.waitForDependencies().catch((error) => {
|
||||
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||
})
|
||||
return state.wait
|
||||
}
|
||||
|
||||
async function prepPlugin(config: TuiConfig.Info, item: Config.PluginSpec, retry = false): Promise<Loaded | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading tui plugin", { path: spec, retry })
|
||||
const target = await resolvePluginTarget(spec).catch((error) => {
|
||||
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!target) return
|
||||
|
||||
const root = pluginRoot(spec, target)
|
||||
const pluginMeta = getPluginMeta(config, item)
|
||||
if (!pluginMeta) {
|
||||
log.warn("missing tui plugin metadata", {
|
||||
path: spec,
|
||||
retry,
|
||||
name: Config.getPluginName(item),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const install = makeInstallFn(pluginMeta, root, spec)
|
||||
const mod = await import(target).catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
retry,
|
||||
mod,
|
||||
install,
|
||||
}
|
||||
}
|
||||
|
||||
function createMeta(
|
||||
spec: string,
|
||||
target: string,
|
||||
meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
|
||||
name?: string,
|
||||
): TuiPluginMeta {
|
||||
if (meta) {
|
||||
return {
|
||||
state: meta.state,
|
||||
...meta.entry,
|
||||
}
|
||||
}
|
||||
|
||||
const source = spec.startsWith("internal:") ? "internal" : spec.startsWith("file://") ? "file" : "npm"
|
||||
const now = Date.now()
|
||||
return {
|
||||
state: source === "internal" ? "same" : "first",
|
||||
name: name ?? spec,
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: target,
|
||||
}
|
||||
}
|
||||
|
||||
function prepInternalPlugin(item: InternalTuiPlugin): Loaded {
|
||||
const spec = `internal:${item.name}`
|
||||
const target = item.root ?? spec
|
||||
const root = rootDir(item.root)
|
||||
|
||||
return {
|
||||
spec,
|
||||
target,
|
||||
retry: false,
|
||||
mod: item.module,
|
||||
install: makeInstallFn(
|
||||
{
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
root,
|
||||
spec,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function scope(load: Loaded, name: string) {
|
||||
const ctrl = new AbortController()
|
||||
let list: { key: symbol; fn: TuiDispose }[] = []
|
||||
let done = false
|
||||
|
||||
const onDispose = (fn: TuiDispose) => {
|
||||
if (done) return () => {}
|
||||
const key = Symbol()
|
||||
list.push({ key, fn })
|
||||
let drop = false
|
||||
return () => {
|
||||
if (drop) return
|
||||
drop = true
|
||||
list = list.filter((x) => x.key !== key)
|
||||
}
|
||||
}
|
||||
|
||||
const wrap = (fn: (() => void) | undefined) => {
|
||||
if (!fn) return () => {}
|
||||
const off = onDispose(fn)
|
||||
let drop = false
|
||||
return () => {
|
||||
if (drop) return
|
||||
drop = true
|
||||
off()
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycle: TuiPluginApi<CliRenderer>["lifecycle"] = {
|
||||
signal: ctrl.signal,
|
||||
onDispose,
|
||||
}
|
||||
|
||||
const dispose = async () => {
|
||||
if (done) return
|
||||
done = true
|
||||
ctrl.abort()
|
||||
const queue = [...list].reverse()
|
||||
list = []
|
||||
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
||||
for (const item of queue) {
|
||||
const left = until - Date.now()
|
||||
if (left <= 0) {
|
||||
fail("timed out cleaning up tui plugin", {
|
||||
path: load.spec,
|
||||
name,
|
||||
timeout: DISPOSE_TIMEOUT_MS,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const out = await runCleanup(item.fn, left)
|
||||
if (out.type === "ok") continue
|
||||
if (out.type === "timeout") {
|
||||
fail("timed out cleaning up tui plugin", {
|
||||
path: load.spec,
|
||||
name,
|
||||
timeout: DISPOSE_TIMEOUT_MS,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (out.type === "error") {
|
||||
fail("failed to clean up tui plugin", {
|
||||
path: load.spec,
|
||||
name,
|
||||
error: out.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lifecycle,
|
||||
wrap,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function sid(meta: TuiPluginMeta, name: string) {
|
||||
if (name === "default") return meta.name
|
||||
return `${meta.name}:${name}`
|
||||
}
|
||||
|
||||
function plug(plugin: TuiSlotPlugin, id: string): HostSlotPlugin {
|
||||
return {
|
||||
...plugin,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
function pluginApi(api: Api, load: Loaded, state: Scope, base: string): TuiPluginApi<CliRenderer> {
|
||||
const command: TuiPluginApi<CliRenderer>["command"] = {
|
||||
register(cb) {
|
||||
return state.wrap(api.command.register(cb))
|
||||
},
|
||||
trigger(value) {
|
||||
api.command.trigger(value)
|
||||
},
|
||||
}
|
||||
|
||||
const route: TuiPluginApi<CliRenderer>["route"] = {
|
||||
register(list) {
|
||||
return state.wrap(api.route.register(list))
|
||||
},
|
||||
navigate(name, params) {
|
||||
api.route.navigate(name, params)
|
||||
},
|
||||
get current() {
|
||||
return api.route.current
|
||||
},
|
||||
}
|
||||
|
||||
const theme: TuiPluginApi<CliRenderer>["theme"] = Object.create(api.theme, {
|
||||
install: {
|
||||
value: load.install,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
},
|
||||
})
|
||||
|
||||
const event: TuiPluginApi<CliRenderer>["event"] = {
|
||||
on(type, handler) {
|
||||
return state.wrap(api.event.on(type, handler))
|
||||
},
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
const slots: TuiPluginApi<CliRenderer>["slots"] = {
|
||||
register(plugin) {
|
||||
const id = count ? `${base}:${count}` : base
|
||||
count += 1
|
||||
state.wrap(api.slots.register(plug(plugin, id)))
|
||||
return id
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...api,
|
||||
command,
|
||||
route,
|
||||
theme,
|
||||
event,
|
||||
slots,
|
||||
lifecycle: state.lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlugin(api: Api, load: Loaded, meta: TuiPluginMeta, all: Scope[]) {
|
||||
const opts = load.item ? Config.pluginOptions(load.item) : undefined
|
||||
|
||||
for (const [name, value] of uniqueModuleEntries(load.mod)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
log.warn("ignoring non-object tui plugin export", {
|
||||
path: load.spec,
|
||||
name,
|
||||
type: value === null ? "null" : typeof value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const tuiPlugin = getTuiPlugin(value)
|
||||
if (!tuiPlugin) continue
|
||||
|
||||
const state = scope(load, name)
|
||||
const plugin = pluginApi(api, load, state, sid(meta, name))
|
||||
const ready = await Promise.resolve()
|
||||
.then(async () => {
|
||||
await tuiPlugin(plugin, opts, meta)
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to initialize tui plugin export", {
|
||||
path: load.spec,
|
||||
name,
|
||||
error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!ready) {
|
||||
await state.dispose()
|
||||
continue
|
||||
}
|
||||
|
||||
all.push(state)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TuiPluginRuntime {
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let list: Scope[] = []
|
||||
export const Slot = View
|
||||
|
||||
export async function init(api: HostPluginApi) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
dir = cwd
|
||||
loaded = load({
|
||||
...api,
|
||||
slots: setupSlots(api),
|
||||
})
|
||||
return loaded
|
||||
}
|
||||
|
||||
export async function dispose() {
|
||||
const task = loaded
|
||||
loaded = undefined
|
||||
dir = ""
|
||||
if (task) await task
|
||||
const queue = [...list].reverse()
|
||||
list = []
|
||||
for (const state of queue) {
|
||||
await state.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function load(api: Api) {
|
||||
const cwd = process.cwd()
|
||||
const next: Scope[] = []
|
||||
|
||||
await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
|
||||
}
|
||||
const deps: Deps = {}
|
||||
|
||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||
log.info("loading internal tui plugin", { name: item.name })
|
||||
const entry = prepInternalPlugin(item)
|
||||
await applyPlugin(api, entry, createMeta(entry.spec, entry.target, undefined, item.name), next)
|
||||
}
|
||||
|
||||
const loaded = await Promise.all(plugins.map((item) => prepPlugin(config, item)))
|
||||
const ready: Loaded[] = []
|
||||
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
let entry = loaded[i]
|
||||
if (!entry) {
|
||||
const item = plugins[i]
|
||||
if (!item) continue
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (!spec.startsWith("file://")) continue
|
||||
await waitDeps(deps)
|
||||
entry = await prepPlugin(config, item, true)
|
||||
}
|
||||
if (!entry) continue
|
||||
ready.push(entry)
|
||||
}
|
||||
|
||||
const meta = await PluginMeta.touchMany(ready.map((item) => ({ spec: item.spec, target: item.target }))).catch(
|
||||
(error) => {
|
||||
log.warn("failed to track tui plugins", { error })
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
|
||||
for (let i = 0; i < ready.length; i++) {
|
||||
const entry = ready[i]
|
||||
if (!entry) continue
|
||||
const hit = meta?.[i]
|
||||
if (hit && hit.state !== "same") {
|
||||
log.info("tui plugin metadata updated", {
|
||||
path: entry.spec,
|
||||
retry: entry.retry,
|
||||
state: hit.state,
|
||||
source: hit.entry.source,
|
||||
version: hit.entry.version,
|
||||
modified: hit.entry.modified,
|
||||
})
|
||||
}
|
||||
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
await applyPlugin(api, entry, createMeta(entry.spec, entry.target, hit), next)
|
||||
}
|
||||
|
||||
list = next
|
||||
},
|
||||
}).catch((error) => {
|
||||
fail("failed to load tui plugins", { directory: cwd, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { type SlotMode, type TuiHostPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||
name: K
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotMap[K]
|
||||
|
||||
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||
export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
|
||||
|
||||
export type HostPluginApi = TuiHostPluginApi<CliRenderer>
|
||||
export type HostSlots = {
|
||||
register: (plugin: HostSlotPlugin) => () => void
|
||||
}
|
||||
|
||||
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||
return null
|
||||
}
|
||||
|
||||
let view: Slot = empty
|
||||
|
||||
export const Slot: Slot = (props) => view(props)
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
|
||||
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<TuiSlotMap, 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<TuiSlotMap, TuiSlotContext>(reg)
|
||||
view = (props) => slot(props)
|
||||
return {
|
||||
register(plugin) {
|
||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||
return reg.register(plugin)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPluginRuntime } from "../plugin"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
@@ -57,8 +58,8 @@ export function Home() {
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
@@ -71,8 +72,8 @@ export function Home() {
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
let prompt: PromptRef
|
||||
@@ -111,7 +112,9 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<Logo />
|
||||
<TuiPluginRuntime.Slot name="home_logo" mode="replace">
|
||||
<Logo />
|
||||
</TuiPluginRuntime.Slot>
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
@@ -124,11 +127,25 @@ export function Home() {
|
||||
workspaceID={route.workspaceID}
|
||||
/>
|
||||
</box>
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={showTips()}>
|
||||
<Tips />
|
||||
</Show>
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="home_tips"
|
||||
mode="replace"
|
||||
show_tips={showTips()}
|
||||
tips_hidden={tipsHidden()}
|
||||
first_time_user={isFirstTimeUser()}
|
||||
>
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={showTips()}>
|
||||
<Tips />
|
||||
</Show>
|
||||
</box>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="home_below_tips"
|
||||
show_tips={showTips()}
|
||||
tips_hidden={tipsHidden()}
|
||||
first_time_user={isFirstTimeUser()}
|
||||
/>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
|
||||
@@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import { Editor } from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { Footer } from "./footer.tsx"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, For, Show, Switch, Match } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "@/util/locale"
|
||||
import path from "path"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Global } from "@/global"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const sync = useSync()
|
||||
@@ -27,36 +29,45 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
lsp: true,
|
||||
})
|
||||
|
||||
// Sort MCP servers alphabetically for consistent display order
|
||||
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
|
||||
const mcp = createMemo(() =>
|
||||
Object.entries(sync.data.mcp)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, item]) => ({
|
||||
name,
|
||||
status: item.status,
|
||||
error: item.status === "failed" ? item.error : undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
// Count connected and error MCP servers for collapsed header display
|
||||
const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length)
|
||||
const lsp = createMemo(() => sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status })))
|
||||
|
||||
const connectedMcpCount = createMemo(() => mcp().filter((item) => item.status === "connected").length)
|
||||
const errorMcpCount = createMemo(
|
||||
() =>
|
||||
mcpEntries().filter(
|
||||
([_, item]) =>
|
||||
mcp().filter(
|
||||
(item) =>
|
||||
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
|
||||
).length,
|
||||
)
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
const cost = createMemo(() => messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0))
|
||||
const mcpStatusColor: Record<string, typeof theme.success> = {
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
needs_auth: theme.warning,
|
||||
needs_client_registration: theme.error,
|
||||
}
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
const last = messages().findLast((x): x is AssistantMessage => x.role === "assistant" && x.tokens.output > 0)
|
||||
if (!last) return { tokens: 0, percentage: null }
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens: total.toLocaleString(),
|
||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
tokens,
|
||||
percentage: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -67,6 +78,16 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
|
||||
const showGettingStarted = createMemo(() => !hasProviders() && !gettingStartedDismissed())
|
||||
const dir = createMemo(() => {
|
||||
const value = directory()
|
||||
const parts = value.split("/")
|
||||
return {
|
||||
value,
|
||||
parent: parts.slice(0, -1).join("/"),
|
||||
name: parts.at(-1) ?? "",
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={session()}>
|
||||
@@ -90,163 +111,192 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
<TuiPluginRuntime.Slot name="sidebar_top" session_id={props.sessionID} />
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_title"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
title={session().title}
|
||||
share_url={session().share?.url}
|
||||
>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_context"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
tokens={context().tokens}
|
||||
percentage={context().percentage}
|
||||
cost={cost()}
|
||||
>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{context().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={theme.textMuted}>{context().percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_mcp"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
items={mcp()}
|
||||
connected={connectedMcpCount()}
|
||||
errors={errorMcpCount()}
|
||||
>
|
||||
<Show when={mcp().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => mcp().length > 2 && setExpanded("mcp", !expanded.mcp)}
|
||||
>
|
||||
<Show when={mcp().length > 2}>
|
||||
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>MCP</b>
|
||||
<Show when={!expanded.mcp}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{" "}
|
||||
({connectedMcpCount()} active
|
||||
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcp().length <= 2 || expanded.mcp}>
|
||||
<For each={mcp()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: mcpStatusColor[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{item.name}{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch fallback={item.status}>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed"}>
|
||||
<i>{item.error}</i>
|
||||
</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled</Match>
|
||||
<Match when={item.status === "needs_auth"}>Needs auth</Match>
|
||||
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length > 0}>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_lsp"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
items={lsp()}
|
||||
disabled={sync.data.config.lsp === false}
|
||||
>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
|
||||
onMouseDown={() => lsp().length > 2 && setExpanded("lsp", !expanded.lsp)}
|
||||
>
|
||||
<Show when={mcpEntries().length > 2}>
|
||||
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
|
||||
<Show when={lsp().length > 2}>
|
||||
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>MCP</b>
|
||||
<Show when={!expanded.mcp}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{" "}
|
||||
({connectedMcpCount()} active
|
||||
{errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
|
||||
</span>
|
||||
</Show>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
|
||||
<For each={mcpEntries()}>
|
||||
{([key, item]) => (
|
||||
<Show when={lsp().length <= 2 || expanded.lsp}>
|
||||
<Show when={lsp().length === 0}>
|
||||
<text fg={theme.textMuted}>
|
||||
{sync.data.config.lsp === false
|
||||
? "LSPs have been disabled in settings"
|
||||
: "LSPs will activate as files are read"}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={lsp()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: (
|
||||
{
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
needs_auth: theme.warning,
|
||||
needs_client_registration: theme.error,
|
||||
} as Record<string, typeof theme.success>
|
||||
)[item.status],
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{key}{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch fallback={item.status}>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled</Match>
|
||||
<Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
|
||||
<Match when={(item.status as string) === "needs_client_registration"}>
|
||||
Needs client ID
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
<text fg={theme.textMuted}>
|
||||
{item.id} {item.root}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
|
||||
>
|
||||
<Show when={sync.data.lsp.length > 2}>
|
||||
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={sync.data.lsp.length <= 2 || expanded.lsp}>
|
||||
<Show when={sync.data.lsp.length === 0}>
|
||||
<text fg={theme.textMuted}>
|
||||
{sync.data.config.lsp === false
|
||||
? "LSPs have been disabled in settings"
|
||||
: "LSPs will activate as files are read"}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
{item.id} {item.root}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot name="sidebar_todo" mode="replace" session_id={props.sessionID} items={todo()}>
|
||||
<Show when={todo().length > 0 && todo().some((item) => item.status !== "completed")}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
|
||||
>
|
||||
<Show when={todo().length > 2}>
|
||||
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={todo().length <= 2 || expanded.todo}>
|
||||
<For each={todo()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
|
||||
>
|
||||
<Show when={todo().length > 2}>
|
||||
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={todo().length <= 2 || expanded.todo}>
|
||||
<For each={todo()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={diff().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
|
||||
>
|
||||
<Show when={diff().length > 2}>
|
||||
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={diff().length <= 2 || expanded.diff}>
|
||||
<For each={diff() || []}>
|
||||
{(item) => {
|
||||
return (
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot name="sidebar_files" mode="replace" session_id={props.sessionID} items={diff()}>
|
||||
<Show when={diff().length > 0}>
|
||||
<box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
|
||||
>
|
||||
<Show when={diff().length > 2}>
|
||||
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={diff().length <= 2 || expanded.diff}>
|
||||
<For each={diff()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{item.file}
|
||||
@@ -260,60 +310,96 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Show when={!hasProviders() && !gettingStartedDismissed()}>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<text flexShrink={0} fg={theme.text}>
|
||||
⬖
|
||||
</text>
|
||||
<box flexGrow={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text}>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
|
||||
✕
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_getting_started"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
show_getting_started={showGettingStarted()}
|
||||
has_providers={hasProviders()}
|
||||
dismissed={gettingStartedDismissed()}
|
||||
>
|
||||
<Show when={showGettingStarted()}>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<text flexShrink={0} fg={theme.text}>
|
||||
⬖
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.text}>Connect provider</text>
|
||||
<text fg={theme.textMuted}>/connect</text>
|
||||
<box flexGrow={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text}>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
|
||||
✕
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.text}>Connect provider</text>
|
||||
<text fg={theme.textMuted}>/connect</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
|
||||
<span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{Installation.VERSION}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_directory"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
directory={dir().value}
|
||||
directory_parent={dir().parent}
|
||||
directory_name={dir().name}
|
||||
>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{dir().parent}/</span>
|
||||
<span style={{ fg: theme.text }}>{dir().name}</span>
|
||||
</text>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_version"
|
||||
mode="replace"
|
||||
session_id={props.sessionID}
|
||||
version={Installation.VERSION}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>Code</b>
|
||||
</span>{" "}
|
||||
<span>{Installation.VERSION}</span>
|
||||
</text>
|
||||
</TuiPluginRuntime.Slot>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="sidebar_bottom"
|
||||
session_id={props.sessionID}
|
||||
directory={dir().value}
|
||||
directory_parent={dir().parent}
|
||||
directory_name={dir().name}
|
||||
version={Installation.VERSION}
|
||||
show_getting_started={showGettingStarted()}
|
||||
has_providers={hasProviders()}
|
||||
dismissed={gettingStartedDismissed()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { Log } from "@/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -145,7 +146,7 @@ export const TuiThreadCommand = cmd({
|
||||
const reload = () => {
|
||||
client.call("reload", undefined).catch((err) => {
|
||||
Log.Default.warn("worker reload failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -162,7 +163,7 @@ export const TuiThreadCommand = cmd({
|
||||
process.off("SIGUSR2", reload)
|
||||
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
|
||||
Log.Default.warn("worker shutdown failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage(error),
|
||||
})
|
||||
})
|
||||
worker.terminate()
|
||||
|
||||
@@ -35,6 +35,7 @@ export function Dialog(
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
@@ -72,6 +73,9 @@ function init() {
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
@@ -151,6 +155,7 @@ export function DialogProvider(props: ParentProps) {
|
||||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { errorFormat } from "@/util/error"
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Provider } from "../provider/provider"
|
||||
@@ -41,17 +42,5 @@ export function FormatError(input: unknown) {
|
||||
}
|
||||
|
||||
export function FormatUnknownError(input: unknown): string {
|
||||
if (input instanceof Error) {
|
||||
return input.stack ?? `${input.name}: ${input.message}`
|
||||
}
|
||||
|
||||
if (typeof input === "object" && input !== null) {
|
||||
try {
|
||||
return JSON.stringify(input, null, 2)
|
||||
} catch {
|
||||
return "Unexpected error (unserializable)"
|
||||
}
|
||||
}
|
||||
|
||||
return String(input)
|
||||
return errorFormat(input)
|
||||
}
|
||||
|
||||
@@ -30,20 +30,26 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { online, proxied } from "@/util/network"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Lock } from "@/util/lock"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Duration, Effect, Layer, ServiceMap } from "effect"
|
||||
import { Flock } from "@/util/flock"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
const PluginOptions = z.record(z.string(), z.unknown())
|
||||
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
|
||||
|
||||
export type PluginOptions = z.infer<typeof PluginOptions>
|
||||
export type PluginSpec = z.infer<typeof PluginSpec>
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -78,34 +84,56 @@ export namespace Config {
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
export type InstallInput = {
|
||||
signal?: AbortSignal
|
||||
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string, input?: InstallInput) {
|
||||
if (!(await needsInstall(dir))) return
|
||||
|
||||
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
|
||||
signal: input?.signal,
|
||||
onWait: (tick) =>
|
||||
input?.waitTick?.({
|
||||
dir,
|
||||
attempt: tick.attempt,
|
||||
delay: tick.delay,
|
||||
waited: tick.waited,
|
||||
}),
|
||||
})
|
||||
|
||||
input?.signal?.throwIfAborted()
|
||||
if (!(await needsInstall(dir))) return
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
||||
dependencies: {},
|
||||
}))
|
||||
json.dependencies = {
|
||||
...json.dependencies,
|
||||
"@opencode-ai/plugin": targetVersion,
|
||||
"@opencode-ai/plugin": target,
|
||||
}
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
const ignore = await Filesystem.exists(gitignore)
|
||||
if (!ignore) {
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
}
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
using _ = await Lock.write("bun-install")
|
||||
await BunProc.run(
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
{
|
||||
cwd: dir,
|
||||
abort: input?.signal,
|
||||
},
|
||||
).catch((err) => {
|
||||
if (err instanceof Process.RunFailedError) {
|
||||
const detail = {
|
||||
@@ -149,8 +177,8 @@ export namespace Config {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeModules = path.join(dir, "node_modules")
|
||||
if (!existsSync(nodeModules)) return true
|
||||
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
|
||||
if (!existsSync(mod)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
@@ -163,8 +191,9 @@ export namespace Config {
|
||||
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
if (targetVersion === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!isOutdated) return false
|
||||
if (!online()) return false
|
||||
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!stale) return false
|
||||
log.info("Cached version is outdated, proceeding with install", {
|
||||
pkg: "@opencode-ai/plugin",
|
||||
cachedVersion: depVersion,
|
||||
@@ -303,7 +332,7 @@ export namespace Config {
|
||||
}
|
||||
|
||||
async function loadPlugin(dir: string) {
|
||||
const plugins: string[] = []
|
||||
const plugins: PluginSpec[] = []
|
||||
|
||||
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: dir,
|
||||
@@ -316,6 +345,32 @@ export namespace Config {
|
||||
return plugins
|
||||
}
|
||||
|
||||
export function pluginSpecifier(plugin: PluginSpec): string {
|
||||
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||
}
|
||||
|
||||
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
|
||||
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||
}
|
||||
|
||||
export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
|
||||
const spec = pluginSpecifier(plugin)
|
||||
try {
|
||||
const resolved = import.meta.resolve!(spec, configFilepath)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
} catch {
|
||||
try {
|
||||
const require = createRequire(configFilepath)
|
||||
const resolved = pathToFileURL(require.resolve(spec)).href
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
} catch {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a canonical plugin name from a plugin specifier.
|
||||
* - For file:// URLs: extracts filename without extension
|
||||
@@ -326,15 +381,16 @@ export namespace Config {
|
||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||
*/
|
||||
export function getPluginName(plugin: string): string {
|
||||
if (plugin.startsWith("file://")) {
|
||||
return path.parse(new URL(plugin).pathname).name
|
||||
export function getPluginName(plugin: PluginSpec): string {
|
||||
const spec = pluginSpecifier(plugin)
|
||||
if (spec.startsWith("file://")) {
|
||||
return path.parse(new URL(spec).pathname).name
|
||||
}
|
||||
const lastAt = plugin.lastIndexOf("@")
|
||||
const lastAt = spec.lastIndexOf("@")
|
||||
if (lastAt > 0) {
|
||||
return plugin.substring(0, lastAt)
|
||||
return spec.substring(0, lastAt)
|
||||
}
|
||||
return plugin
|
||||
return spec
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,14 +404,14 @@ export namespace Config {
|
||||
* Since plugins are added in low-to-high priority order,
|
||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||
*/
|
||||
export function deduplicatePlugins(plugins: string[]): string[] {
|
||||
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
|
||||
// seenNames: canonical plugin names for duplicate detection
|
||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
// uniqueSpecifiers: full plugin specifiers to return
|
||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
||||
const uniqueSpecifiers: string[] = []
|
||||
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
|
||||
const uniqueSpecifiers: PluginSpec[] = []
|
||||
|
||||
for (const specifier of plugins.toReversed()) {
|
||||
const name = getPluginName(specifier)
|
||||
@@ -858,13 +914,13 @@ export namespace Config {
|
||||
ignore: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
plugin: z.string().array().optional(),
|
||||
snapshot: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
),
|
||||
plugin: PluginSpec.array().optional(),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
.optional()
|
||||
@@ -1070,10 +1126,6 @@ export namespace Config {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
const edits = modify(input, path, patch, {
|
||||
@@ -1184,18 +1236,7 @@ export namespace Config {
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (e) {
|
||||
try {
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
|
||||
}
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -29,6 +29,7 @@ export const TuiInfo = z
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
plugin: Config.PluginSpec.array().optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TuiInfo } from "./tui-schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace TuiConfig {
|
||||
@@ -15,16 +16,91 @@ export namespace TuiConfig {
|
||||
|
||||
export const Info = TuiInfo
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
export type PluginMeta = {
|
||||
scope: "global" | "local"
|
||||
source: string
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
item: Config.PluginSpec
|
||||
meta: PluginMeta
|
||||
}
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
entries: PluginEntry[]
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
plugin_meta?: Record<string, PluginMeta>
|
||||
}
|
||||
|
||||
function pluginScope(file: string): PluginMeta["scope"] {
|
||||
if (Instance.containsPath(file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function dedupePlugins(list: PluginEntry[]) {
|
||||
const seen = new Set<string>()
|
||||
const result: PluginEntry[] = []
|
||||
for (const item of list.toReversed()) {
|
||||
const name = Config.getPluginName(item.item)
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push(item)
|
||||
}
|
||||
return result.toReversed()
|
||||
}
|
||||
|
||||
function mergeInfo(target: Info, source: Info): Info {
|
||||
return mergeDeep(target, source)
|
||||
const merged = mergeDeep(target, source)
|
||||
if (target.plugin && source.plugin) {
|
||||
merged.plugin = [...target.plugin, ...source.plugin]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
return Flag.OPENCODE_TUI_CONFIG
|
||||
}
|
||||
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return data
|
||||
}
|
||||
|
||||
const tui = data.tui
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
function installDeps(dir: string): Promise<void> {
|
||||
return Config.installDependencies(dir)
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeInfo(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file)
|
||||
for (const item of data.plugin) {
|
||||
acc.entries.push({
|
||||
item,
|
||||
meta: {
|
||||
scope,
|
||||
source: file,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
@@ -38,38 +114,55 @@ export namespace TuiConfig {
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
|
||||
let result: Info = {}
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
entries: [],
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
result = mergeInfo(result, await loadFile(custom))
|
||||
await mergeFile(acc, custom)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
||||
const merged = dedupePlugins(acc.entries)
|
||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||
acc.result.plugin = merged.map((item) => item.item)
|
||||
acc.result.plugin_meta = merged.length
|
||||
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
|
||||
: undefined
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (acc.result.plugin?.length) {
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
deps.push(installDeps(dir))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: result,
|
||||
config: acc.result,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -77,6 +170,11 @@ export namespace TuiConfig {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
const deps = await state().then((x) => x.deps)
|
||||
await Promise.all(deps)
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
@@ -87,25 +185,12 @@ export namespace TuiConfig {
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
|
||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!isRecord(raw)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = (() => {
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
if (!("tui" in copy)) return copy
|
||||
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
|
||||
delete copy.tui
|
||||
return copy
|
||||
}
|
||||
const tui = copy.tui as Record<string, unknown>
|
||||
delete copy.tui
|
||||
return {
|
||||
...tui,
|
||||
...copy,
|
||||
}
|
||||
})()
|
||||
const normalized = normalize(raw)
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (!parsed.success) {
|
||||
@@ -113,6 +198,13 @@ export namespace TuiConfig {
|
||||
return {}
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,16 @@ export namespace Flag {
|
||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_PURE: boolean
|
||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
|
||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||
@@ -117,6 +120,28 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_PURE
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because the CLI can set this flag at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_PURE", {
|
||||
get() {
|
||||
return truthy("OPENCODE_PURE")
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because tests and external tooling may set this env var at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
|
||||
get() {
|
||||
return process.env["OPENCODE_PLUGIN_META_FILE"]
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CLIENT
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because some commands override the client at runtime
|
||||
|
||||
@@ -33,16 +33,18 @@ import path from "path"
|
||||
import { Global } from "./global"
|
||||
import { JsonMigration } from "./storage/json-migration"
|
||||
import { Database } from "./storage/db"
|
||||
import { errorMessage } from "./util/error"
|
||||
import { PlugCommand } from "./cli/cmd/plug"
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
e: errorMessage(e),
|
||||
})
|
||||
})
|
||||
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error("exception", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
e: errorMessage(e),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +65,15 @@ const cli = yargs(hideBin(process.argv))
|
||||
type: "string",
|
||||
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
||||
})
|
||||
.option("pure", {
|
||||
describe: "run without external plugins",
|
||||
type: "boolean",
|
||||
})
|
||||
.middleware(async (opts) => {
|
||||
if (opts.pure) {
|
||||
process.env.OPENCODE_PURE = "1"
|
||||
}
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
dev: Installation.isLocal(),
|
||||
@@ -143,6 +153,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(GithubCommand)
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.command(PlugCommand)
|
||||
.command(DbCommand)
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
@@ -194,7 +205,7 @@ try {
|
||||
if (formatted) UI.error(formatted)
|
||||
if (formatted === undefined) {
|
||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
||||
process.stderr.write((e instanceof Error ? e.message : String(e)) + EOL)
|
||||
process.stderr.write(errorMessage(e) + EOL)
|
||||
}
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { BunProc } from "../bun"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
@@ -14,6 +13,8 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { isDeprecatedPlugin, parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -22,6 +23,12 @@ export namespace Plugin {
|
||||
hooks: Hooks[]
|
||||
}
|
||||
|
||||
type Loaded = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||
type TriggerName = {
|
||||
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
||||
@@ -46,8 +53,70 @@ export namespace Plugin {
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||
|
||||
// Old npm package names for plugins that are now built-in — skip if users still have them in config
|
||||
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function getServerPlugin(value: unknown) {
|
||||
if (isServerPlugin(value)) return value
|
||||
if (!value || typeof value !== "object" || !("server" in value)) return
|
||||
if (!isServerPlugin(value.server)) return
|
||||
return value.server
|
||||
}
|
||||
|
||||
async function resolvePlugin(spec: string) {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = errorMessage(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!target) return
|
||||
return target
|
||||
}
|
||||
|
||||
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading plugin", { path: spec })
|
||||
const target = await resolvePlugin(spec)
|
||||
if (!target) return
|
||||
|
||||
const mod = await import(target).catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
mod,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// uniqueModuleEntries keeps only the first export for each shared value reference.
|
||||
for (const [, entry] of uniqueModuleEntries(load.mod)) {
|
||||
const server = getServerPlugin(entry)
|
||||
if (!server) throw new TypeError("Plugin export is not a function")
|
||||
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -91,51 +160,27 @@ export namespace Plugin {
|
||||
if (init) hooks.push(init)
|
||||
}
|
||||
|
||||
let plugins = cfg.plugin ?? []
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
|
||||
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
|
||||
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
|
||||
}
|
||||
if (plugins.length) await Config.waitForDependencies()
|
||||
|
||||
for (let plugin of plugins) {
|
||||
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const idx = plugin.lastIndexOf("@")
|
||||
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
|
||||
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
|
||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!plugin) continue
|
||||
}
|
||||
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
|
||||
for (const load of loaded) {
|
||||
if (!load) continue
|
||||
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||
await import(plugin)
|
||||
.then(async (mod) => {
|
||||
const seen = new Set<PluginInstance>()
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
if (seen.has(fn)) continue
|
||||
seen.add(fn)
|
||||
hooks.push(await fn(input))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
// Keep plugin execution sequential so hook registration and execution
|
||||
// order remains deterministic across plugin runs.
|
||||
await applyPlugin(load, input, hooks).catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Notify plugins of current config
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flock } from "@/util/flock"
|
||||
|
||||
import { parsePluginSpecifier } from "./shared"
|
||||
|
||||
export namespace PluginMeta {
|
||||
type Source = "file" | "npm"
|
||||
|
||||
export type Entry = {
|
||||
name: string
|
||||
source: Source
|
||||
spec: string
|
||||
target: string
|
||||
requested?: string
|
||||
version?: string
|
||||
modified?: number
|
||||
first_time: number
|
||||
last_time: number
|
||||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export type State = "first" | "updated" | "same"
|
||||
|
||||
export type Touch = {
|
||||
spec: string
|
||||
target: string
|
||||
}
|
||||
|
||||
type Store = Record<string, Entry>
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
|
||||
type Row = Touch & {
|
||||
id: string
|
||||
core: Core
|
||||
}
|
||||
|
||||
function storePath() {
|
||||
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
|
||||
}
|
||||
|
||||
function lock(file: string) {
|
||||
return `plugin-meta:${file}`
|
||||
}
|
||||
|
||||
function sourceKind(spec: string): Source {
|
||||
if (spec.startsWith("file://")) return "file"
|
||||
return "npm"
|
||||
}
|
||||
|
||||
function entryKey(spec: string) {
|
||||
if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}`
|
||||
return `npm:${parsePluginSpecifier(spec).pkg}`
|
||||
}
|
||||
|
||||
function entryName(spec: string) {
|
||||
if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name
|
||||
return parsePluginSpecifier(spec).pkg
|
||||
}
|
||||
|
||||
function fileTarget(spec: string, target: string) {
|
||||
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
return
|
||||
}
|
||||
|
||||
function modifiedAt(file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
if (!stat) return
|
||||
const value = stat.mtimeMs
|
||||
return Math.floor(typeof value === "bigint" ? Number(value) : value)
|
||||
}
|
||||
|
||||
function resolvedTarget(target: string) {
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
return target
|
||||
}
|
||||
|
||||
async function npmVersion(target: string) {
|
||||
const resolved = resolvedTarget(target)
|
||||
const stat = Filesystem.stat(resolved)
|
||||
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||
.then((item) => item.version)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function entryCore(spec: string, target: string): Promise<Core> {
|
||||
const source = sourceKind(spec)
|
||||
if (source === "file") {
|
||||
const file = fileTarget(spec, target)
|
||||
return {
|
||||
name: entryName(spec),
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
modified: file ? modifiedAt(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: entryName(spec),
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
requested: parsePluginSpecifier(spec).version,
|
||||
version: await npmVersion(target),
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprint(value: Core) {
|
||||
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
|
||||
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
|
||||
}
|
||||
|
||||
async function read(file: string): Promise<Store> {
|
||||
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
|
||||
}
|
||||
|
||||
async function row(item: Touch): Promise<Row> {
|
||||
return {
|
||||
...item,
|
||||
id: entryKey(item.spec),
|
||||
core: await entryCore(item.spec, item.target),
|
||||
}
|
||||
}
|
||||
|
||||
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
|
||||
const entry: Entry = {
|
||||
...core,
|
||||
first_time: prev?.first_time ?? now,
|
||||
last_time: now,
|
||||
time_changed: prev?.time_changed ?? now,
|
||||
load_count: (prev?.load_count ?? 0) + 1,
|
||||
fingerprint: fingerprint(core),
|
||||
}
|
||||
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||
if (state === "updated") entry.time_changed = now
|
||||
return {
|
||||
state,
|
||||
entry,
|
||||
}
|
||||
}
|
||||
|
||||
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
|
||||
if (!items.length) return []
|
||||
const file = storePath()
|
||||
const rows = await Promise.all(items.map((item) => row(item)))
|
||||
|
||||
return Flock.withLock(lock(file), async () => {
|
||||
const store = await read(file)
|
||||
const now = Date.now()
|
||||
const out: Array<{ state: State; entry: Entry }> = []
|
||||
for (const item of rows) {
|
||||
const hit = next(store[item.id], item.core, now)
|
||||
store[item.id] = hit.entry
|
||||
out.push(hit)
|
||||
}
|
||||
await Filesystem.writeJson(file, store)
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> {
|
||||
return touchMany([{ spec, target }]).then((item) => {
|
||||
const hit = item[0]
|
||||
if (hit) return hit
|
||||
throw new Error("Failed to touch plugin metadata.")
|
||||
})
|
||||
}
|
||||
|
||||
export async function list(): Promise<Store> {
|
||||
const file = storePath()
|
||||
return Flock.withLock(lock(file), async () => read(file))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BunProc } from "@/bun"
|
||||
|
||||
// Old npm package names for plugins that are now built-in
|
||||
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||
|
||||
export function isDeprecatedPlugin(spec: string) {
|
||||
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
|
||||
}
|
||||
|
||||
export function parsePluginSpecifier(spec: string) {
|
||||
const lastAt = spec.lastIndexOf("@")
|
||||
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
|
||||
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
|
||||
return { pkg, version }
|
||||
}
|
||||
|
||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||
if (spec.startsWith("file://")) return spec
|
||||
return BunProc.install(parsed.pkg, parsed.version)
|
||||
}
|
||||
|
||||
export function uniqueModuleEntries(mod: Record<string, unknown>) {
|
||||
const seen = new Set<unknown>()
|
||||
const entries: [string, unknown][] = []
|
||||
|
||||
for (const [name, entry] of Object.entries(mod)) {
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
entries.push([name, entry])
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -106,7 +106,7 @@ export namespace ProviderAuth {
|
||||
|
||||
interface State {
|
||||
hooks: Record<ProviderID, Hook>
|
||||
pending: Map<ProviderID, AuthOuathResult>
|
||||
pending: Map<ProviderID, AuthOAuthResult>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
@@ -127,7 +127,7 @@ export namespace ProviderAuth {
|
||||
: Result.failVoid,
|
||||
),
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOuathResult>(),
|
||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/stora
|
||||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
@@ -990,7 +991,7 @@ export namespace MessageV2 {
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof Error:
|
||||
return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
|
||||
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
|
||||
default:
|
||||
try {
|
||||
const parsed = ProviderError.parseStreamError(e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { ProviderID, ModelID } from "../provider/schema"
|
||||
import { errorMessage } from "../util/error"
|
||||
import DESCRIPTION from "./batch.txt"
|
||||
|
||||
const DISALLOWED = new Set(["batch"])
|
||||
@@ -118,7 +119,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
state: {
|
||||
status: "error",
|
||||
input: call.parameters,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: errorMessage(error),
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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 {
|
||||
return JSON.stringify(error, null, 2)
|
||||
} 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
|
||||
}
|
||||
|
||||
const text = String(error)
|
||||
if (text && text !== "[object Object]") return text
|
||||
|
||||
const formatted = errorFormat(error)
|
||||
if (formatted && 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: errorFormatted(error),
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRecord(error)) {
|
||||
return {
|
||||
type: typeof error,
|
||||
message: errorMessage(error),
|
||||
formatted: errorFormatted(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
|
||||
}
|
||||
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 = errorFormatted(error)
|
||||
return data
|
||||
}
|
||||
|
||||
function errorFormatted(error: unknown) {
|
||||
const formatted = errorFormat(error)
|
||||
if (formatted !== "{}") return formatted
|
||||
return String(error)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises"
|
||||
import { Global } from "@/global"
|
||||
import { Hash } from "@/util/hash"
|
||||
|
||||
export namespace Flock {
|
||||
const root = path.join(Global.Path.state, "locks")
|
||||
// Defaults for callers that do not provide timing options.
|
||||
const defaultOpts = {
|
||||
staleMs: 60_000,
|
||||
timeoutMs: 5 * 60_000,
|
||||
baseDelayMs: 100,
|
||||
maxDelayMs: 2_000,
|
||||
}
|
||||
|
||||
export interface WaitEvent {
|
||||
key: string
|
||||
attempt: number
|
||||
delay: number
|
||||
waited: number
|
||||
}
|
||||
|
||||
export type Wait = (input: WaitEvent) => void | Promise<void>
|
||||
|
||||
export interface Options {
|
||||
dir?: string
|
||||
signal?: AbortSignal
|
||||
staleMs?: number
|
||||
timeoutMs?: number
|
||||
baseDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
onWait?: Wait
|
||||
}
|
||||
|
||||
type Opts = {
|
||||
staleMs: number
|
||||
timeoutMs: number
|
||||
baseDelayMs: number
|
||||
maxDelayMs: number
|
||||
}
|
||||
|
||||
type Owned = {
|
||||
acquired: true
|
||||
startHeartbeat: (intervalMs?: number) => void
|
||||
release: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Lease {
|
||||
release: () => Promise<void>
|
||||
[Symbol.asyncDispose]: () => Promise<void>
|
||||
}
|
||||
|
||||
function code(err: unknown) {
|
||||
if (typeof err !== "object" || err === null || !("code" in err)) return
|
||||
const value = err.code
|
||||
if (typeof value !== "string") return
|
||||
return value
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason ?? new Error("Aborted"))
|
||||
return
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
const done = () => {
|
||||
signal?.removeEventListener("abort", abort)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const abort = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
signal?.removeEventListener("abort", abort)
|
||||
reject(signal?.reason ?? new Error("Aborted"))
|
||||
}
|
||||
|
||||
signal?.addEventListener("abort", abort, { once: true })
|
||||
timer = setTimeout(done, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function jitter(ms: number) {
|
||||
const j = Math.floor(ms * 0.3)
|
||||
const d = Math.floor(Math.random() * (2 * j + 1)) - j
|
||||
return Math.max(0, ms + d)
|
||||
}
|
||||
|
||||
function mono() {
|
||||
return performance.now()
|
||||
}
|
||||
|
||||
function wall() {
|
||||
return performance.timeOrigin + mono()
|
||||
}
|
||||
|
||||
async function stats(file: string) {
|
||||
try {
|
||||
return await stat(file)
|
||||
} catch (err) {
|
||||
const errCode = code(err)
|
||||
if (errCode === "ENOENT" || errCode === "ENOTDIR") return
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
|
||||
// Stale detection allows automatic recovery after crashed owners.
|
||||
const now = wall()
|
||||
const heartbeat = await stats(heartbeatPath)
|
||||
if (heartbeat) {
|
||||
return now - heartbeat.mtimeMs > staleMs
|
||||
}
|
||||
|
||||
const meta = await stats(metaPath)
|
||||
if (meta) {
|
||||
return now - meta.mtimeMs > staleMs
|
||||
}
|
||||
|
||||
const dir = await stats(lockDir)
|
||||
if (!dir) {
|
||||
return false
|
||||
}
|
||||
|
||||
return now - dir.mtimeMs > staleMs
|
||||
}
|
||||
|
||||
async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> {
|
||||
const token = randomUUID?.() ?? randomBytes(16).toString("hex")
|
||||
const metaPath = path.join(lockDir, "meta.json")
|
||||
const heartbeatPath = path.join(lockDir, "heartbeat")
|
||||
|
||||
try {
|
||||
await mkdir(lockDir, { mode: 0o700 })
|
||||
} catch (err) {
|
||||
if (code(err) !== "EEXIST") {
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
|
||||
return { acquired: false }
|
||||
}
|
||||
|
||||
const breakerPath = lockDir + ".breaker"
|
||||
try {
|
||||
await mkdir(breakerPath, { mode: 0o700 })
|
||||
} catch (claimErr) {
|
||||
const errCode = code(claimErr)
|
||||
if (errCode === "EEXIST") {
|
||||
const breaker = await stats(breakerPath)
|
||||
if (breaker && wall() - breaker.mtimeMs > opts.staleMs) {
|
||||
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
return { acquired: false }
|
||||
}
|
||||
|
||||
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
|
||||
return { acquired: false }
|
||||
}
|
||||
|
||||
throw claimErr
|
||||
}
|
||||
|
||||
try {
|
||||
// Breaker ownership ensures only one contender performs stale cleanup.
|
||||
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
|
||||
return { acquired: false }
|
||||
}
|
||||
|
||||
await rm(lockDir, { recursive: true, force: true })
|
||||
|
||||
try {
|
||||
await mkdir(lockDir, { mode: 0o700 })
|
||||
} catch (retryErr) {
|
||||
const errCode = code(retryErr)
|
||||
if (errCode === "EEXIST" || errCode === "ENOTEMPTY") {
|
||||
return { acquired: false }
|
||||
}
|
||||
throw retryErr
|
||||
}
|
||||
} finally {
|
||||
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const meta = {
|
||||
token,
|
||||
pid: process.pid,
|
||||
hostname: os.hostname(),
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => {
|
||||
await rm(lockDir, { recursive: true, force: true })
|
||||
throw new Error("Lock acquired but heartbeat already existed (possible compromise).")
|
||||
})
|
||||
|
||||
await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => {
|
||||
await rm(lockDir, { recursive: true, force: true })
|
||||
throw new Error("Lock acquired but meta.json already existed (possible compromise).")
|
||||
})
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => {
|
||||
if (timer) return
|
||||
// Heartbeat prevents long critical sections from being evicted as stale.
|
||||
timer = setInterval(() => {
|
||||
const t = new Date()
|
||||
void utimes(heartbeatPath, t, t).catch(() => undefined)
|
||||
}, intervalMs)
|
||||
timer.unref?.()
|
||||
}
|
||||
|
||||
const release = async () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
const current = await readFile(metaPath, "utf8")
|
||||
.then((raw) => {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object") return {}
|
||||
return {
|
||||
token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined,
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errCode = code(err)
|
||||
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
|
||||
throw new Error("Refusing to release: lock is compromised (metadata missing).")
|
||||
}
|
||||
if (err instanceof SyntaxError) {
|
||||
throw new Error("Refusing to release: lock is compromised (metadata invalid).")
|
||||
}
|
||||
throw err
|
||||
})
|
||||
// Token check prevents deleting a lock that was re-acquired by another process.
|
||||
if (current.token !== token) {
|
||||
throw new Error("Refusing to release: lock token mismatch (not the owner).")
|
||||
}
|
||||
|
||||
await rm(lockDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return {
|
||||
acquired: true,
|
||||
startHeartbeat,
|
||||
release,
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireLockDir(
|
||||
lockDir: string,
|
||||
input: { key: string; onWait?: Wait; signal?: AbortSignal },
|
||||
opts: Opts,
|
||||
) {
|
||||
const stop = mono() + opts.timeoutMs
|
||||
let attempt = 0
|
||||
let waited = 0
|
||||
let delay = opts.baseDelayMs
|
||||
|
||||
while (true) {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
const res = await tryAcquireLockDir(lockDir, opts)
|
||||
if (res.acquired) {
|
||||
return res
|
||||
}
|
||||
|
||||
if (mono() > stop) {
|
||||
throw new Error(`Timed out waiting for lock: ${input.key}`)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
const ms = jitter(delay)
|
||||
await input.onWait?.({
|
||||
key: input.key,
|
||||
attempt,
|
||||
delay: ms,
|
||||
waited,
|
||||
})
|
||||
await sleep(ms, input.signal)
|
||||
waited += ms
|
||||
delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7))
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquire(key: string, input: Options = {}): Promise<Lease> {
|
||||
input.signal?.throwIfAborted()
|
||||
const cfg: Opts = {
|
||||
staleMs: input.staleMs ?? defaultOpts.staleMs,
|
||||
timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs,
|
||||
baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs,
|
||||
maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs,
|
||||
}
|
||||
const dir = input.dir ?? root
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
const lockfile = path.join(dir, Hash.fast(key) + ".lock")
|
||||
const lock = await acquireLockDir(
|
||||
lockfile,
|
||||
{
|
||||
key,
|
||||
onWait: input.onWait,
|
||||
signal: input.signal,
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
lock.startHeartbeat()
|
||||
|
||||
const release = () => lock.release()
|
||||
return {
|
||||
release,
|
||||
[Symbol.asyncDispose]() {
|
||||
return release()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) {
|
||||
await using _ = await acquire(key, input)
|
||||
input.signal?.throwIfAborted()
|
||||
return await fn()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
export function online() {
|
||||
const nav = globalThis.navigator
|
||||
if (!nav || typeof nav.onLine !== "boolean") return true
|
||||
return nav.onLine
|
||||
}
|
||||
|
||||
export function proxied() {
|
||||
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ChildProcess } from "child_process"
|
||||
import launch from "cross-spawn"
|
||||
import { buffer } from "node:stream/consumers"
|
||||
import { errorMessage } from "./error"
|
||||
|
||||
export namespace Process {
|
||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||
@@ -136,7 +137,7 @@ export namespace Process {
|
||||
return {
|
||||
code: 1,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
stderr: Buffer.from(errorMessage(err)),
|
||||
}
|
||||
})
|
||||
if (out.code === 0 || opts.nothrow) return out
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ProjectTable } from "../project/project.sql"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import { Log } from "../util/log"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
@@ -258,7 +259,7 @@ export namespace Worktree {
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = errorMessage(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
@@ -342,9 +343,12 @@ export namespace Worktree {
|
||||
|
||||
function cleanDirectory(target: string) {
|
||||
return Effect.promise(() =>
|
||||
import("fs/promises").then((fsp) =>
|
||||
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
),
|
||||
import("fs/promises")
|
||||
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
||||
.catch((error) => {
|
||||
const message = errorMessage(error)
|
||||
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user