stash
parent
89d6f60d25
commit
b99e3efad2
|
|
@ -1,7 +1,15 @@
|
|||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import {
|
||||
createSlot,
|
||||
createSolidSlotRegistry,
|
||||
render,
|
||||
useKeyboard,
|
||||
useRenderer,
|
||||
useTerminalDimensions,
|
||||
type SolidPlugin,
|
||||
} from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { createCliRenderer, MouseButton, TextAttributes, 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"
|
||||
|
|
@ -41,6 +49,9 @@ import { writeHeapSnapshot } from "v8"
|
|||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import type { TuiSlotContext, TuiSlotMap, TuiSlots } from "@opencode-ai/plugin"
|
||||
|
||||
type TuiSlot = <K extends keyof TuiSlotMap>(props: { name: K } & TuiSlotMap[K]) => unknown
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
|
|
@ -104,6 +115,25 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
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}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
|
|
@ -129,77 +159,74 @@ 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 />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
const registry = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(renderer, {
|
||||
url: input.url,
|
||||
directory: input.directory,
|
||||
})
|
||||
const Slot = createSlot<TuiSlotMap, TuiSlotContext>(registry)
|
||||
const slot: TuiSlot = (props) => Slot(props)
|
||||
const slots: TuiSlots = {
|
||||
register(plugin) {
|
||||
return registry.register(plugin as SolidPlugin<TuiSlotMap, TuiSlotContext>)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
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}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
await 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}
|
||||
renderer={renderer}
|
||||
slots={slots}
|
||||
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 slot={slot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
})
|
||||
}
|
||||
|
||||
function App() {
|
||||
function App(props: { slot: TuiSlot }) {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
|
@ -766,10 +793,10 @@ function App() {
|
|||
>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
<Home slot={props.slot} />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
<Session slot={props.slot} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import type { TuiSlots } from "@opencode-ai/plugin"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { TuiPlugin } from "../plugin"
|
||||
|
||||
export type EventSource = {
|
||||
on: (handler: (event: Event) => void) => () => void
|
||||
|
|
@ -12,6 +15,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
name: "SDK",
|
||||
init: (props: {
|
||||
url: string
|
||||
renderer: CliRenderer
|
||||
slots: TuiSlots
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
|
|
@ -38,6 +43,17 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
TuiPlugin.init({
|
||||
client: sdk,
|
||||
event: emitter,
|
||||
url: props.url,
|
||||
directory: props.directory,
|
||||
renderer: props.renderer,
|
||||
slots: props.slots,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
})
|
||||
|
||||
let queue: Event[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Glob } from "../../../../util/glob"
|
||||
import aura from "./theme/aura.json" with { type: "json" }
|
||||
|
|
@ -138,6 +138,44 @@ type ThemeJson = {
|
|||
}
|
||||
}
|
||||
|
||||
type ThemeRegistry = {
|
||||
themes: Record<string, ThemeJson>
|
||||
listeners: Set<(themes: Record<string, ThemeJson>) => void>
|
||||
}
|
||||
|
||||
const registry: ThemeRegistry = {
|
||||
themes: {},
|
||||
listeners: new Set(),
|
||||
}
|
||||
|
||||
export function registerThemes(themes: Record<string, unknown>) {
|
||||
const entries = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => {
|
||||
const theme = entry[1]
|
||||
if (!theme || typeof theme !== "object") return false
|
||||
if (!("theme" in theme)) return false
|
||||
return true
|
||||
})
|
||||
if (entries.length === 0) return
|
||||
|
||||
for (const [name, theme] of entries) {
|
||||
registry.themes[name] = theme
|
||||
}
|
||||
|
||||
const payload = Object.fromEntries(entries)
|
||||
for (const handler of registry.listeners) {
|
||||
handler(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function registeredThemes() {
|
||||
return registry.themes
|
||||
}
|
||||
|
||||
function onThemes(handler: (themes: Record<string, ThemeJson>) => void) {
|
||||
registry.listeners.add(handler)
|
||||
return () => registry.listeners.delete(handler)
|
||||
}
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
aura,
|
||||
ayu,
|
||||
|
|
@ -296,6 +334,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
|
||||
function init() {
|
||||
resolveSystemTheme()
|
||||
mergeThemes(registeredThemes())
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
|
|
@ -315,6 +354,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
}
|
||||
|
||||
onMount(init)
|
||||
onCleanup(
|
||||
onThemes((themes) => {
|
||||
mergeThemes(themes)
|
||||
}),
|
||||
)
|
||||
|
||||
function mergeThemes(themes: Record<string, ThemeJson>) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
for (const [name, theme] of Object.entries(themes)) {
|
||||
if (draft.themes[name]) continue
|
||||
draft.themes[name] = theme
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolveSystemTheme")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import type { PluginModule, TuiPlugin as TuiPluginFn, TuiPluginInput, TuiSlotPlugin } from "@opencode-ai/plugin"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Log } from "@/util/log"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { registerThemes } from "./context/theme"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
export namespace TuiPlugin {
|
||||
const log = Log.create({ service: "tui.plugin" })
|
||||
let loaded: Promise<void> | undefined
|
||||
|
||||
export async function init(input: TuiPluginInput) {
|
||||
if (loaded) return loaded
|
||||
loaded = load(input)
|
||||
return loaded
|
||||
}
|
||||
|
||||
async function resolve(spec: string) {
|
||||
if (spec.startsWith("file://")) return spec
|
||||
const lastAtIndex = spec.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec
|
||||
const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest"
|
||||
return BunProc.install(pkg, version)
|
||||
}
|
||||
|
||||
function slot(entry: unknown) {
|
||||
if (!entry || typeof entry !== "object") return
|
||||
if ("id" in entry && typeof entry.id === "string" && "slots" in entry && typeof entry.slots === "object") {
|
||||
return entry as TuiSlotPlugin<JSX.Element>
|
||||
}
|
||||
if (!("slots" in entry)) return
|
||||
const value = entry.slots
|
||||
if (!value || typeof value !== "object") return
|
||||
if (!("id" in value) || typeof value.id !== "string") return
|
||||
if (!("slots" in value) || typeof value.slots !== "object") return
|
||||
return value as TuiSlotPlugin<JSX.Element>
|
||||
}
|
||||
|
||||
async function load(input: TuiPluginInput) {
|
||||
const base = input.directory ?? process.cwd()
|
||||
const dir = existsSync(base) ? base : process.cwd()
|
||||
if (dir !== base) {
|
||||
log.info("tui plugin directory not found, using local cwd", {
|
||||
requested: base,
|
||||
directory: dir,
|
||||
})
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
const plugins = config.plugin ?? []
|
||||
if (plugins.length) await TuiConfig.waitForDependencies()
|
||||
|
||||
for (const item of plugins) {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
log.info("loading tui plugin", { path: spec })
|
||||
const path = await resolve(spec).catch((error) => {
|
||||
log.error("failed to install tui plugin", { path: spec, error })
|
||||
return
|
||||
})
|
||||
if (!path) continue
|
||||
|
||||
const mod = await import(path).catch((error) => {
|
||||
log.error("failed to load tui plugin", { path: spec, error })
|
||||
return
|
||||
})
|
||||
if (!mod) continue
|
||||
|
||||
const seen = new Set<unknown>()
|
||||
for (const entry of Object.values<PluginModule>(mod)) {
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
|
||||
const themes = (() => {
|
||||
if (!entry || typeof entry !== "object") return
|
||||
if (!("themes" in entry)) return
|
||||
if (!entry.themes || typeof entry.themes !== "object") return
|
||||
return entry.themes as Record<string, unknown>
|
||||
})()
|
||||
if (themes) registerThemes(themes)
|
||||
|
||||
const plugin = slot(entry)
|
||||
if (plugin) {
|
||||
input.slots.register(plugin)
|
||||
}
|
||||
|
||||
const tui = (() => {
|
||||
if (!entry || typeof entry !== "object") return
|
||||
if (!("tui" in entry)) return
|
||||
if (typeof entry.tui !== "function") return
|
||||
return entry.tui as TuiPluginFn
|
||||
})()
|
||||
if (!tui) continue
|
||||
await tui(input, Config.pluginOptions(item))
|
||||
}
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
log.error("failed to load tui plugins", { directory: dir, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -15,11 +15,14 @@ import { Installation } from "@/installation"
|
|||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
import type { TuiSlotMap } from "@opencode-ai/plugin"
|
||||
|
||||
type Slot = <K extends "home_hint" | "home_footer">(props: { name: K } & TuiSlotMap[K]) => unknown
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
|
||||
export function Home() {
|
||||
export function Home(props: { slot: Slot }) {
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
|
|
@ -57,8 +60,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 +74,9 @@ export function Home() {
|
|||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
{props.slot({ name: "home_hint" }) as never}
|
||||
</box>
|
||||
)
|
||||
|
||||
let prompt: PromptRef
|
||||
|
|
@ -150,6 +154,7 @@ export function Home() {
|
|||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
{props.slot({ name: "home_footer" }) as never}
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</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"
|
||||
|
|
@ -81,9 +80,12 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
|
|||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import type { TuiSlotMap } from "@opencode-ai/plugin"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
type Slot = (props: { name: "session_footer"; session_id: TuiSlotMap["session_footer"]["session_id"] }) => unknown
|
||||
|
||||
class CustomSpeedScroll implements ScrollAcceleration {
|
||||
constructor(private speed: number) {}
|
||||
|
||||
|
|
@ -113,7 +115,7 @@ function use() {
|
|||
return ctx
|
||||
}
|
||||
|
||||
export function Session() {
|
||||
export function Session(props: { slot: Slot }) {
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
|
|
@ -1178,6 +1180,7 @@ export function Session() {
|
|||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
{props.slot({ name: "session_footer", session_id: route.sessionID }) as never}
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
|
|
@ -38,6 +38,11 @@ import { Filesystem } from "@/util/filesystem"
|
|||
|
||||
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" })
|
||||
|
||||
|
|
@ -449,7 +454,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,
|
||||
|
|
@ -462,6 +467,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
|
||||
|
|
@ -472,15 +503,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -494,14 +526,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)
|
||||
|
|
@ -997,7 +1029,7 @@ export namespace Config {
|
|||
ignore: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
plugin: z.string().array().optional(),
|
||||
plugin: PluginSpec.array().optional(),
|
||||
snapshot: z.boolean().optional(),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
|
|
@ -1245,19 +1277,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 {
|
||||
// import.meta.resolve sometimes fails with newly created node_modules
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ export namespace TuiConfig {
|
|||
export type Info = z.output<typeof Info>
|
||||
|
||||
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() {
|
||||
|
|
@ -67,9 +71,23 @@ export namespace TuiConfig {
|
|||
}
|
||||
|
||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
||||
result.plugin = Config.deduplicatePlugins(result.plugin ?? [])
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
deps.push(
|
||||
(async () => {
|
||||
const shouldInstall = await Config.needsInstall(dir)
|
||||
if (!shouldInstall) return
|
||||
await Config.installDependencies(dir)
|
||||
})(),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
config: result,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -77,6 +95,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,13 +110,13 @@ 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 (!raw || typeof raw !== "object" || Array.isArray(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>) }
|
||||
const copy = { ...(raw as Record<string, unknown>) }
|
||||
if (!("tui" in copy)) return copy
|
||||
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
|
||||
delete copy.tui
|
||||
|
|
@ -113,6 +136,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,48 +54,75 @@ export namespace Plugin {
|
|||
plugins = [...BUILTIN, ...plugins]
|
||||
}
|
||||
|
||||
for (let plugin of plugins) {
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const lastAtIndex = plugin.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 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 ""
|
||||
async function resolve(spec: string) {
|
||||
if (spec.startsWith("file://")) return spec
|
||||
const lastAtIndex = spec.lastIndexOf("@")
|
||||
const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec
|
||||
const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest"
|
||||
const builtIn = BUILTIN.some((x) => x.startsWith(pkg + "@"))
|
||||
const installed = 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 })
|
||||
const label = builtIn ? "built-in plugin" : "plugin"
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install ${label} ${pkg}@${version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
if (!plugin) continue
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if (!installed) return
|
||||
return installed
|
||||
}
|
||||
|
||||
for (const item of plugins) {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: spec })
|
||||
const path = await resolve(spec)
|
||||
if (!path) continue
|
||||
const mod = await import(path).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(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) 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 seen = new Set<unknown>()
|
||||
for (const entry of Object.values(mod)) {
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
const server = (() => {
|
||||
if (typeof entry === "function") return entry as PluginInstance
|
||||
if (!entry || typeof entry !== "object") return
|
||||
if (!("server" in entry)) return
|
||||
if (typeof entry.server !== "function") return
|
||||
return entry.server as PluginInstance
|
||||
})()
|
||||
if (!server) continue
|
||||
const init = await server(input, Config.pluginOptions(item)).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
log.error("failed to initialize plugin", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
message: `Failed to initialize plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!init) continue
|
||||
hooks.push(init)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -508,3 +508,57 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports tuple plugin specs with options in tui.json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(Global.Path.config, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
plugin: [
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual([
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.1.86",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,13 +9,16 @@ import type {
|
|||
Message,
|
||||
Part,
|
||||
Auth,
|
||||
Config,
|
||||
Config as SDKConfig,
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { createOpencodeClient as createOpencodeClientV2, Event as TuiEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { CliRenderer, Plugin as SlotPlugin } from "@opentui/core"
|
||||
|
||||
import type { BunShell } from "./shell"
|
||||
import { type ToolDefinition } from "./tool"
|
||||
|
||||
export * from "./tool"
|
||||
export type { CliRenderer, SlotMode } from "@opentui/core"
|
||||
|
||||
export type ProviderContext = {
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
|
|
@ -32,7 +35,80 @@ export type PluginInput = {
|
|||
$: BunShell
|
||||
}
|
||||
|
||||
export type Plugin = (input: PluginInput) => Promise<Hooks>
|
||||
export type PluginOptions = Record<string, unknown>
|
||||
|
||||
export type Config = Omit<SDKConfig, "plugin"> & {
|
||||
plugin?: Array<string | [string, PluginOptions]>
|
||||
}
|
||||
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type Variant = {
|
||||
dark: HexColor | RefName | number
|
||||
light: HexColor | RefName | number
|
||||
}
|
||||
type ThemeColorValue = HexColor | RefName | number | Variant
|
||||
|
||||
export type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Record<string, ThemeColorValue> & {
|
||||
selectedListItemText?: ThemeColorValue
|
||||
backgroundMenu?: ThemeColorValue
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiSlotMap = {
|
||||
home_hint: {}
|
||||
home_footer: {}
|
||||
session_footer: {
|
||||
session_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiSlotContext = {
|
||||
url: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export type TuiSlotPlugin<Node = unknown> = SlotPlugin<Node, TuiSlotMap, TuiSlotContext>
|
||||
|
||||
export type TuiSlots = {
|
||||
register: (plugin: TuiSlotPlugin) => () => void
|
||||
}
|
||||
|
||||
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
|
||||
|
||||
export type TuiEventBus = {
|
||||
on: <Type extends TuiEvent["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<TuiEvent, { type: Type }>) => void,
|
||||
) => () => void
|
||||
}
|
||||
|
||||
export type TuiPluginInput<Renderer = CliRenderer> = {
|
||||
client: ReturnType<typeof createOpencodeClientV2>
|
||||
event: TuiEventBus
|
||||
url: string
|
||||
directory?: string
|
||||
renderer: Renderer
|
||||
slots: TuiSlots
|
||||
}
|
||||
|
||||
export type TuiPlugin<Renderer = CliRenderer> = (
|
||||
input: TuiPluginInput<Renderer>,
|
||||
options?: PluginOptions,
|
||||
) => Promise<void>
|
||||
|
||||
export type PluginModule<Renderer = CliRenderer> =
|
||||
| Plugin
|
||||
| {
|
||||
server?: Plugin
|
||||
tui?: TuiPlugin<Renderer>
|
||||
slots?: TuiSlotPlugin
|
||||
themes?: Record<string, ThemeJson>
|
||||
}
|
||||
|
||||
export type AuthHook = {
|
||||
provider: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue