Compare commits
4 Commits
dev
...
more-tui-p
| Author | SHA1 | Date |
|---|---|---|
|
|
8da0e61d38 | |
|
|
059a6b3f8b | |
|
|
9a5cf7dfe5 | |
|
|
27090c122d |
7
bun.lock
7
bun.lock
|
|
@ -369,6 +369,7 @@
|
||||||
"version": "1.1.65",
|
"version": "1.1.65",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"@opentui/core": "0.1.79",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -3339,7 +3340,7 @@
|
||||||
|
|
||||||
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
|
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
|
||||||
|
|
||||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
||||||
|
|
||||||
|
|
@ -4673,9 +4674,9 @@
|
||||||
|
|
||||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||||
|
|
||||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||||
|
|
||||||
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import { Clipboard } from "@tui/util/clipboard"
|
||||||
import { Selection } from "@tui/util/selection"
|
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 { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||||
|
|
@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { writeHeapSnapshot } from "v8"
|
import { writeHeapSnapshot } from "v8"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
|
import { TuiConfigProvider } from "./context/tui-config"
|
||||||
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
// can't set raw mode if not a TTY
|
// can't set raw mode if not a TTY
|
||||||
|
|
@ -101,9 +103,47 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
|
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
|
|
||||||
|
function rendererConfig(config: TuiConfig.Info): CliRendererConfig {
|
||||||
|
const input = config.tui?.renderer
|
||||||
|
const kitty = input?.use_kitty_keyboard
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetFps: input?.target_fps ?? 60,
|
||||||
|
maxFps: input?.max_fps,
|
||||||
|
gatherStats: input?.gather_stats ?? false,
|
||||||
|
exitOnCtrlC: false,
|
||||||
|
useMouse: input?.use_mouse,
|
||||||
|
enableMouseMovement: input?.enable_mouse_movement,
|
||||||
|
useAlternateScreen: input?.use_alternate_screen,
|
||||||
|
autoFocus: input?.auto_focus ?? false,
|
||||||
|
useKittyKeyboard:
|
||||||
|
kitty === undefined || kitty === true
|
||||||
|
? {}
|
||||||
|
: kitty === false
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
disambiguate: kitty.disambiguate,
|
||||||
|
alternateKeys: kitty.alternate_keys,
|
||||||
|
events: kitty.events,
|
||||||
|
allKeysAsEscapes: kitty.all_keys_as_escapes,
|
||||||
|
reportText: kitty.report_text,
|
||||||
|
},
|
||||||
|
openConsoleOnError: input?.open_console_on_error ?? 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: {
|
export function tui(input: {
|
||||||
url: string
|
url: string
|
||||||
args: Args
|
args: Args
|
||||||
|
config: TuiConfig.Info
|
||||||
directory?: string
|
directory?: string
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
headers?: RequestInit["headers"]
|
headers?: RequestInit["headers"]
|
||||||
|
|
@ -127,8 +167,9 @@ export function tui(input: {
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||||
() => {
|
|
||||||
|
await render(() => {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
||||||
|
|
@ -138,8 +179,10 @@ export function tui(input: {
|
||||||
<KVProvider>
|
<KVProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouteProvider>
|
<RouteProvider>
|
||||||
|
<TuiConfigProvider config={input.config}>
|
||||||
<SDKProvider
|
<SDKProvider
|
||||||
url={input.url}
|
url={input.url}
|
||||||
|
renderer={renderer}
|
||||||
directory={input.directory}
|
directory={input.directory}
|
||||||
fetch={input.fetch}
|
fetch={input.fetch}
|
||||||
headers={input.headers}
|
headers={input.headers}
|
||||||
|
|
@ -167,6 +210,7 @@ export function tui(input: {
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SyncProvider>
|
</SyncProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
|
</TuiConfigProvider>
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</KVProvider>
|
</KVProvider>
|
||||||
|
|
@ -174,24 +218,7 @@ export function tui(input: {
|
||||||
</ArgsProvider>
|
</ArgsProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
},
|
}, renderer)
|
||||||
{
|
|
||||||
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}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { tui } from "./app"
|
import { tui } from "./app"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
|
||||||
export const AttachCommand = cmd({
|
export const AttachCommand = cmd({
|
||||||
command: "attach <url>",
|
command: "attach <url>",
|
||||||
|
|
@ -47,8 +50,13 @@ export const AttachCommand = cmd({
|
||||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||||
return { Authorization: auth }
|
return { Authorization: auth }
|
||||||
})()
|
})()
|
||||||
|
const config = await Instance.provide({
|
||||||
|
directory: directory && existsSync(directory) ? directory : process.cwd(),
|
||||||
|
fn: () => TuiConfig.get(),
|
||||||
|
})
|
||||||
await tui({
|
await tui({
|
||||||
url: args.url,
|
url: args.url,
|
||||||
|
config,
|
||||||
args: { sessionID: args.session },
|
args: { sessionID: args.session },
|
||||||
directory,
|
directory,
|
||||||
headers,
|
headers,
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,23 @@ import { useTheme } from "../context/theme"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||||
|
import { useTuiConfig } from "../context/tui-config"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
|
||||||
export type DialogStatusProps = {}
|
export type DialogStatusProps = {}
|
||||||
|
|
||||||
export function DialogStatus() {
|
export function DialogStatus() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const config = useTuiConfig()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
||||||
|
|
||||||
const plugins = createMemo(() => {
|
const plugins = createMemo(() => {
|
||||||
const list = sync.data.config.plugin ?? []
|
const list = config.plugin ?? []
|
||||||
const result = list.map((value) => {
|
const result = list.map((item) => {
|
||||||
|
const value = Config.pluginSpecifier(item)
|
||||||
if (value.startsWith("file://")) {
|
if (value.startsWith("file://")) {
|
||||||
const path = fileURLToPath(value)
|
const path = fileURLToPath(value)
|
||||||
const parts = path.split("/")
|
const parts = path.split("/")
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,11 @@ const TIPS = [
|
||||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
|
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
|
||||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
|
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
|
||||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
|
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
|
||||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
|
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
|
||||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
|
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
|
||||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
|
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
|
||||||
"Configure {highlight}model{/highlight} in config to set your default model",
|
"Configure {highlight}model{/highlight} in config to set your default model",
|
||||||
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
|
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
|
||||||
"Set any keybind to {highlight}none{/highlight} to disable it completely",
|
"Set any keybind to {highlight}none{/highlight} to disable it completely",
|
||||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
|
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
|
||||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
|
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useSync } from "@tui/context/sync"
|
|
||||||
import { Keybind } from "@/util/keybind"
|
import { Keybind } from "@/util/keybind"
|
||||||
import { pipe, mapValues } from "remeda"
|
import { pipe, mapValues } from "remeda"
|
||||||
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
|
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
|
||||||
|
|
@ -7,14 +6,15 @@ import type { ParsedKey, Renderable } from "@opentui/core"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
import { useTuiConfig } from "./tui-config"
|
||||||
|
|
||||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||||
name: "Keybind",
|
name: "Keybind",
|
||||||
init: () => {
|
init: () => {
|
||||||
const sync = useSync()
|
const config = useTuiConfig()
|
||||||
const keybinds = createMemo(() => {
|
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
|
||||||
return pipe(
|
return pipe(
|
||||||
sync.data.config.keybinds ?? {},
|
(config.keybinds ?? {}) as Record<string, string>,
|
||||||
mapValues((value) => Keybind.parse(value)),
|
mapValues((value) => Keybind.parse(value)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
import { TuiPlugin } from "../plugin"
|
||||||
|
import type { CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
export type EventSource = {
|
export type EventSource = {
|
||||||
on: (handler: (event: Event) => void) => () => void
|
on: (handler: (event: Event) => void) => () => void
|
||||||
|
|
@ -11,6 +13,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: {
|
init: (props: {
|
||||||
url: string
|
url: string
|
||||||
|
renderer: CliRenderer
|
||||||
directory?: string
|
directory?: string
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
headers?: RequestInit["headers"]
|
headers?: RequestInit["headers"]
|
||||||
|
|
@ -29,6 +32,16 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
TuiPlugin.init({
|
||||||
|
client: sdk,
|
||||||
|
event: emitter,
|
||||||
|
url: props.url,
|
||||||
|
directory: props.directory,
|
||||||
|
renderer: props.renderer,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Failed to load TUI plugins", error)
|
||||||
|
})
|
||||||
|
|
||||||
let queue: Event[] = []
|
let queue: Event[] = []
|
||||||
let timer: Timer | undefined
|
let timer: Timer | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, onMount } from "solid-js"
|
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { useSync } from "@tui/context/sync"
|
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import aura from "./theme/aura.json" with { type: "json" }
|
import aura from "./theme/aura.json" with { type: "json" }
|
||||||
import ayu from "./theme/ayu.json" with { type: "json" }
|
import ayu from "./theme/ayu.json" with { type: "json" }
|
||||||
|
|
@ -41,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { useTuiConfig } from "./tui-config"
|
||||||
|
|
||||||
type ThemeColors = {
|
type ThemeColors = {
|
||||||
primary: RGBA
|
primary: RGBA
|
||||||
|
|
@ -137,6 +137,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> = {
|
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||||
aura,
|
aura,
|
||||||
ayu,
|
ayu,
|
||||||
|
|
@ -279,22 +317,23 @@ function ansiToRgba(code: number): RGBA {
|
||||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
name: "Theme",
|
name: "Theme",
|
||||||
init: (props: { mode: "dark" | "light" }) => {
|
init: (props: { mode: "dark" | "light" }) => {
|
||||||
const sync = useSync()
|
const config = useTuiConfig()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
themes: DEFAULT_THEMES,
|
themes: DEFAULT_THEMES,
|
||||||
mode: kv.get("theme_mode", props.mode),
|
mode: kv.get("theme_mode", props.mode),
|
||||||
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
|
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||||
ready: false,
|
ready: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const theme = sync.data.config.theme
|
const theme = config.theme
|
||||||
if (theme) setStore("active", theme)
|
if (theme) setStore("active", theme)
|
||||||
})
|
})
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme()
|
resolveSystemTheme()
|
||||||
|
mergeThemes(registeredThemes())
|
||||||
getCustomThemes()
|
getCustomThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
setStore(
|
setStore(
|
||||||
|
|
@ -314,6 +353,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(init)
|
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() {
|
function resolveSystemTheme() {
|
||||||
console.log("resolveSystemTheme")
|
console.log("resolveSystemTheme")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
||||||
|
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||||
|
name: "TuiConfig",
|
||||||
|
init: (props: { config: TuiConfig.Info }) => {
|
||||||
|
return props.config
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import type { TuiPlugin as TuiPluginFn, TuiPluginInput } from "@opencode-ai/plugin"
|
||||||
|
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 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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of plugins) {
|
||||||
|
const spec = Config.pluginSpecifier(item)
|
||||||
|
log.info("loading tui plugin", { path: spec })
|
||||||
|
const path = await resolve(spec)
|
||||||
|
const mod = await import(path)
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
for (const [_name, entry] of Object.entries(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 tui = (() => {
|
||||||
|
if (typeof entry === "function") return
|
||||||
|
if (!entry || typeof entry !== "object") return
|
||||||
|
if ("tui" in entry && typeof entry.tui === "function") return entry.tui as TuiPluginFn
|
||||||
|
return
|
||||||
|
})()
|
||||||
|
if (!tui) continue
|
||||||
|
await tui(input, Config.pluginOptions(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
log.error("failed to load tui plugins", { directory: dir, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
|
||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
import { formatTranscript } from "../../util/transcript"
|
import { formatTranscript } from "../../util/transcript"
|
||||||
import { UI } from "@/cli/ui.ts"
|
import { UI } from "@/cli/ui.ts"
|
||||||
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
|
|
@ -100,6 +101,7 @@ const context = createContext<{
|
||||||
showDetails: () => boolean
|
showDetails: () => boolean
|
||||||
diffWrapMode: () => "word" | "none"
|
diffWrapMode: () => "word" | "none"
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
|
tui: ReturnType<typeof useTuiConfig>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function use() {
|
function use() {
|
||||||
|
|
@ -112,6 +114,7 @@ export function Session() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const { navigate } = useRoute()
|
const { navigate } = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
|
|
@ -164,7 +167,7 @@ export function Session() {
|
||||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||||
|
|
||||||
const scrollAcceleration = createMemo(() => {
|
const scrollAcceleration = createMemo(() => {
|
||||||
const tui = sync.data.config.tui
|
const tui = tuiConfig.tui
|
||||||
if (tui?.scroll_acceleration?.enabled) {
|
if (tui?.scroll_acceleration?.enabled) {
|
||||||
return new MacOSScrollAccel()
|
return new MacOSScrollAccel()
|
||||||
}
|
}
|
||||||
|
|
@ -968,6 +971,7 @@ export function Session() {
|
||||||
showDetails,
|
showDetails,
|
||||||
diffWrapMode,
|
diffWrapMode,
|
||||||
sync,
|
sync,
|
||||||
|
tui: tuiConfig,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
|
|
@ -1912,7 +1916,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
const diffStyle = ctx.tui.tui?.diff_style
|
||||||
if (diffStyle === "stacked") return "unified"
|
if (diffStyle === "stacked") return "unified"
|
||||||
// Default to "auto" behavior
|
// Default to "auto" behavior
|
||||||
return ctx.width > 120 ? "split" : "unified"
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
|
|
@ -1983,7 +1987,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||||
const files = createMemo(() => props.metadata.files ?? [])
|
const files = createMemo(() => props.metadata.files ?? [])
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
const diffStyle = ctx.tui.tui?.diff_style
|
||||||
if (diffStyle === "stacked") return "unified"
|
if (diffStyle === "stacked") return "unified"
|
||||||
return ctx.width > 120 ? "split" : "unified"
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
|
|
||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
|
|
||||||
|
|
@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
const theme = themeState.theme
|
const theme = themeState.theme
|
||||||
const syntax = themeState.syntax
|
const syntax = themeState.syntax
|
||||||
const sync = useSync()
|
const config = useTuiConfig()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
|
|
||||||
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
|
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
|
||||||
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
|
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
|
||||||
|
|
||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
const diffStyle = sync.data.config.tui?.diff_style
|
const diffStyle = config.tui?.diff_style
|
||||||
if (diffStyle === "stacked") return "unified"
|
if (diffStyle === "stacked") return "unified"
|
||||||
return dimensions().width > 120 ? "split" : "unified"
|
return dimensions().width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
|
import { TuiConfig } from "@/config/tui"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_WORKER_PATH: string
|
const OPENCODE_WORKER_PATH: string
|
||||||
|
|
@ -133,6 +135,10 @@ export const TuiThreadCommand = cmd({
|
||||||
if (!args.prompt) return piped
|
if (!args.prompt) return piped
|
||||||
return piped ? piped + "\n" + args.prompt : args.prompt
|
return piped ? piped + "\n" + args.prompt : args.prompt
|
||||||
})
|
})
|
||||||
|
const config = await Instance.provide({
|
||||||
|
directory: cwd,
|
||||||
|
fn: () => TuiConfig.get(),
|
||||||
|
})
|
||||||
|
|
||||||
// Check if server should be started (port or hostname explicitly set in CLI or config)
|
// Check if server should be started (port or hostname explicitly set in CLI or config)
|
||||||
const networkOpts = await resolveNetworkOptions(args)
|
const networkOpts = await resolveNetworkOptions(args)
|
||||||
|
|
@ -161,6 +167,8 @@ export const TuiThreadCommand = cmd({
|
||||||
|
|
||||||
const tuiPromise = tui({
|
const tuiPromise = tui({
|
||||||
url,
|
url,
|
||||||
|
config,
|
||||||
|
directory: cwd,
|
||||||
fetch: customFetch,
|
fetch: customFetch,
|
||||||
events,
|
events,
|
||||||
args: {
|
args: {
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,21 @@ import { Event } from "../server/event"
|
||||||
import { PackageRegistry } from "@/bun/registry"
|
import { PackageRegistry } from "@/bun/registry"
|
||||||
import { proxied } from "@/util/proxied"
|
import { proxied } from "@/util/proxied"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
import { ConfigPaths } from "./paths"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||||
|
const PluginOptions = z.record(z.string(), z.unknown())
|
||||||
|
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" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
|
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
|
||||||
// These settings override all user and project settings
|
// These settings override all user and project settings
|
||||||
function getManagedConfigDir(): string {
|
function systemManagedConfigDir(): string {
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return "/Library/Application Support/opencode"
|
return "/Library/Application Support/opencode"
|
||||||
|
|
@ -50,13 +56,17 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
|
export function managedConfigDir() {
|
||||||
|
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedDir = managedConfigDir()
|
||||||
|
|
||||||
// Custom merge function that concatenates array fields instead of replacing them
|
// Custom merge function that concatenates array fields instead of replacing them
|
||||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||||
const merged = mergeDeep(target, source)
|
const merged = mergeDeep(target, source)
|
||||||
if (target.plugin && source.plugin) {
|
if (target.plugin && source.plugin) {
|
||||||
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
|
merged.plugin = [...target.plugin, ...source.plugin]
|
||||||
}
|
}
|
||||||
if (target.instructions && source.instructions) {
|
if (target.instructions && source.instructions) {
|
||||||
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
||||||
|
|
@ -107,11 +117,8 @@ export namespace Config {
|
||||||
|
|
||||||
// Project config overrides global and remote config.
|
// Project config overrides global and remote config.
|
||||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
|
||||||
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
result = mergeConfigConcatArrays(result, await loadFile(file))
|
||||||
for (const resolved of found.toReversed()) {
|
|
||||||
result = mergeConfigConcatArrays(result, await loadFile(resolved))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,31 +126,10 @@ export namespace Config {
|
||||||
result.mode = result.mode || {}
|
result.mode = result.mode || {}
|
||||||
result.plugin = result.plugin || []
|
result.plugin = result.plugin || []
|
||||||
|
|
||||||
const directories = [
|
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
||||||
Global.Path.config,
|
|
||||||
// Only scan project .opencode/ directories when project discovery is enabled
|
|
||||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
||||||
? await Array.fromAsync(
|
|
||||||
Filesystem.up({
|
|
||||||
targets: [".opencode"],
|
|
||||||
start: Instance.directory,
|
|
||||||
stop: Instance.worktree,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: []),
|
|
||||||
// Always scan ~/.opencode/ (user home directory)
|
|
||||||
...(await Array.fromAsync(
|
|
||||||
Filesystem.up({
|
|
||||||
targets: [".opencode"],
|
|
||||||
start: Global.Path.home,
|
|
||||||
stop: Global.Path.home,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
]
|
|
||||||
|
|
||||||
// .opencode directory config overrides (project and global) config sources.
|
// .opencode directory config overrides (project and global) config sources.
|
||||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||||
directories.push(Flag.OPENCODE_CONFIG_DIR)
|
|
||||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,9 +170,9 @@ export namespace Config {
|
||||||
// Kept separate from directories array to avoid write operations when installing plugins
|
// Kept separate from directories array to avoid write operations when installing plugins
|
||||||
// which would fail on system directories requiring elevated permissions
|
// which would fail on system directories requiring elevated permissions
|
||||||
// This way it only loads config file and not skills/plugins/commands
|
// This way it only loads config file and not skills/plugins/commands
|
||||||
if (existsSync(managedConfigDir)) {
|
if (existsSync(managedDir)) {
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||||
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file)))
|
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,8 +211,6 @@ export namespace Config {
|
||||||
result.share = "auto"
|
result.share = "auto"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
|
|
||||||
|
|
||||||
// Apply flag overrides for compaction settings
|
// Apply flag overrides for compaction settings
|
||||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||||
result.compaction = { ...result.compaction, auto: false }
|
result.compaction = { ...result.compaction, auto: false }
|
||||||
|
|
@ -288,7 +272,7 @@ export namespace Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function needsInstall(dir: string) {
|
export async function needsInstall(dir: string) {
|
||||||
// Some config dirs may be read-only.
|
// Some config dirs may be read-only.
|
||||||
// Installing deps there will fail; skip installation in that case.
|
// Installing deps there will fail; skip installation in that case.
|
||||||
const writable = await isWritable(dir)
|
const writable = await isWritable(dir)
|
||||||
|
|
@ -478,16 +462,36 @@ export namespace Config {
|
||||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||||
*/
|
*/
|
||||||
export function getPluginName(plugin: string): string {
|
export function pluginSpecifier(plugin: PluginSpec): string {
|
||||||
if (plugin.startsWith("file://")) {
|
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||||
return path.parse(new URL(plugin).pathname).name
|
|
||||||
}
|
}
|
||||||
const lastAt = plugin.lastIndexOf("@")
|
|
||||||
|
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
|
||||||
|
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginName(plugin: PluginSpec): string {
|
||||||
|
const spec = pluginSpecifier(plugin)
|
||||||
|
if (spec.startsWith("file://")) {
|
||||||
|
return path.parse(new URL(spec).pathname).name
|
||||||
|
}
|
||||||
|
const lastAt = spec.lastIndexOf("@")
|
||||||
if (lastAt > 0) {
|
if (lastAt > 0) {
|
||||||
return plugin.substring(0, lastAt)
|
return spec.substring(0, lastAt)
|
||||||
}
|
}
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicates plugins by name, with later entries (higher priority) winning.
|
* Deduplicates plugins by name, with later entries (higher priority) winning.
|
||||||
|
|
@ -500,14 +504,14 @@ export namespace Config {
|
||||||
* Since plugins are added in low-to-high priority order,
|
* Since plugins are added in low-to-high priority order,
|
||||||
* we reverse, deduplicate (keeping first occurrence), then restore 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
|
// seenNames: canonical plugin names for duplicate detection
|
||||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||||
const seenNames = new Set<string>()
|
const seenNames = new Set<string>()
|
||||||
|
|
||||||
// uniqueSpecifiers: full plugin specifiers to return
|
// uniqueSpecifiers: full plugin specifiers to return
|
||||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
|
||||||
const uniqueSpecifiers: string[] = []
|
const uniqueSpecifiers: PluginSpec[] = []
|
||||||
|
|
||||||
for (const specifier of plugins.toReversed()) {
|
for (const specifier of plugins.toReversed()) {
|
||||||
const name = getPluginName(specifier)
|
const name = getPluginName(specifier)
|
||||||
|
|
@ -916,6 +920,34 @@ export namespace Config {
|
||||||
ref: "KeybindsConfig",
|
ref: "KeybindsConfig",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const TUIRenderer = z
|
||||||
|
.object({
|
||||||
|
target_fps: z.number().int().positive().optional().describe("Target FPS for the renderer"),
|
||||||
|
max_fps: z.number().int().positive().optional().describe("Maximum FPS for immediate rerenders"),
|
||||||
|
gather_stats: z.boolean().optional().describe("Enable renderer frame statistics collection"),
|
||||||
|
use_mouse: z.boolean().optional().describe("Enable mouse tracking"),
|
||||||
|
enable_mouse_movement: z.boolean().optional().describe("Track mouse movement events"),
|
||||||
|
auto_focus: z.boolean().optional().describe("Auto focus nearest focusable item on click"),
|
||||||
|
use_alternate_screen: z.boolean().optional().describe("Use alternate screen buffer"),
|
||||||
|
open_console_on_error: z.boolean().optional().describe("Open renderer console on uncaught errors"),
|
||||||
|
use_kitty_keyboard: z
|
||||||
|
.union([
|
||||||
|
z.boolean(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
disambiguate: z.boolean().optional(),
|
||||||
|
alternate_keys: z.boolean().optional(),
|
||||||
|
events: z.boolean().optional(),
|
||||||
|
all_keys_as_escapes: z.boolean().optional(),
|
||||||
|
report_text: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.describe("Kitty keyboard protocol settings. true enables defaults, false disables it."),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
export const TUI = z.object({
|
export const TUI = z.object({
|
||||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||||
scroll_acceleration: z
|
scroll_acceleration: z
|
||||||
|
|
@ -928,6 +960,7 @@ export namespace Config {
|
||||||
.enum(["auto", "stacked"])
|
.enum(["auto", "stacked"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||||
|
renderer: TUIRenderer.optional().describe("Renderer options for the terminal UI"),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Server = z
|
export const Server = z
|
||||||
|
|
@ -1004,10 +1037,7 @@ export namespace Config {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||||
theme: z.string().optional().describe("Theme name to use for the interface"),
|
|
||||||
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
|
||||||
logLevel: Log.Level.optional().describe("Log level"),
|
logLevel: Log.Level.optional().describe("Log level"),
|
||||||
tui: TUI.optional().describe("TUI specific settings"),
|
|
||||||
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
||||||
command: z
|
command: z
|
||||||
.record(z.string(), Command)
|
.record(z.string(), Command)
|
||||||
|
|
@ -1019,7 +1049,7 @@ export namespace Config {
|
||||||
ignore: z.array(z.string()).optional(),
|
ignore: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
plugin: z.string().array().optional(),
|
plugin: PluginSpec.array().optional(),
|
||||||
snapshot: z.boolean().optional(),
|
snapshot: z.boolean().optional(),
|
||||||
share: z
|
share: z
|
||||||
.enum(["manual", "auto", "disabled"])
|
.enum(["manual", "auto", "disabled"])
|
||||||
|
|
@ -1239,31 +1269,33 @@ export namespace Config {
|
||||||
return load(text, filepath)
|
return load(text, filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(text: string, configFilepath: string) {
|
export async function substitute(text: string, configFilepath: string, missing: "error" | "empty" = "error") {
|
||||||
const original = text
|
|
||||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||||
return process.env[varName] || ""
|
return process.env[varName] || ""
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
||||||
if (fileMatches) {
|
if (!fileMatches) return text
|
||||||
|
|
||||||
const configDir = path.dirname(configFilepath)
|
const configDir = path.dirname(configFilepath)
|
||||||
const lines = text.split("\n")
|
const lines = text.split("\n")
|
||||||
|
|
||||||
for (const match of fileMatches) {
|
for (const match of fileMatches) {
|
||||||
const lineIndex = lines.findIndex((line) => line.includes(match))
|
const lineIndex = lines.findIndex((line) => line.includes(match))
|
||||||
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) continue
|
||||||
continue // Skip if line is commented
|
|
||||||
}
|
|
||||||
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||||
if (filePath.startsWith("~/")) {
|
if (filePath.startsWith("~/")) {
|
||||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||||
const fileContent = (
|
const fileContent = (
|
||||||
await Bun.file(resolvedPath)
|
await Bun.file(resolvedPath)
|
||||||
.text()
|
.text()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (missing === "empty") return ""
|
||||||
|
|
||||||
const errMsg = `bad file reference: "${match}"`
|
const errMsg = `bad file reference: "${match}"`
|
||||||
if (error.code === "ENOENT") {
|
if (error.code === "ENOENT") {
|
||||||
throw new InvalidError(
|
throw new InvalidError(
|
||||||
|
|
@ -1277,11 +1309,17 @@ export namespace Config {
|
||||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
||||||
})
|
})
|
||||||
).trim()
|
).trim()
|
||||||
// escape newlines/quotes, strip outer quotes
|
|
||||||
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function load(text: string, configFilepath: string) {
|
||||||
|
const original = text
|
||||||
|
text = await substitute(text, configFilepath)
|
||||||
|
|
||||||
const errors: JsoncParseError[] = []
|
const errors: JsoncParseError[] = []
|
||||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
|
|
@ -1306,7 +1344,19 @@ export namespace Config {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = Info.safeParse(data)
|
const normalized = (() => {
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||||
|
const copy = { ...(data as Record<string, unknown>) }
|
||||||
|
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||||
|
if (!hadLegacy) return copy
|
||||||
|
delete copy.theme
|
||||||
|
delete copy.keybinds
|
||||||
|
delete copy.tui
|
||||||
|
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: configFilepath })
|
||||||
|
return copy
|
||||||
|
})()
|
||||||
|
|
||||||
|
const parsed = Info.safeParse(normalized)
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
if (!parsed.data.$schema) {
|
if (!parsed.data.$schema) {
|
||||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||||
|
|
@ -1317,10 +1367,7 @@ export namespace Config {
|
||||||
const data = parsed.data
|
const data = parsed.data
|
||||||
if (data.plugin) {
|
if (data.plugin) {
|
||||||
for (let i = 0; i < data.plugin.length; i++) {
|
for (let i = 0; i < data.plugin.length; i++) {
|
||||||
const plugin = data.plugin[i]
|
data.plugin[i] = resolvePluginSpec(data.plugin[i], configFilepath)
|
||||||
try {
|
|
||||||
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import path from "path"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
|
||||||
|
export namespace ConfigPaths {
|
||||||
|
export async function projectFiles(name: string, directory: string, worktree: string) {
|
||||||
|
const files: string[] = []
|
||||||
|
for (const file of [`${name}.jsonc`, `${name}.json`]) {
|
||||||
|
const found = await Filesystem.findUp(file, directory, worktree)
|
||||||
|
for (const resolved of found.toReversed()) {
|
||||||
|
files.push(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function directories(directory: string, worktree: string) {
|
||||||
|
return [
|
||||||
|
Global.Path.config,
|
||||||
|
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
? await Array.fromAsync(
|
||||||
|
Filesystem.up({
|
||||||
|
targets: [".opencode"],
|
||||||
|
start: directory,
|
||||||
|
stop: worktree,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: []),
|
||||||
|
...(await Array.fromAsync(
|
||||||
|
Filesystem.up({
|
||||||
|
targets: [".opencode"],
|
||||||
|
start: Global.Path.home,
|
||||||
|
stop: Global.Path.home,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileInDirectory(dir: string, name: string) {
|
||||||
|
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
import path from "path"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
import z from "zod"
|
||||||
|
import { parse as parseJsonc } from "jsonc-parser"
|
||||||
|
import { mergeDeep, unique } from "remeda"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import { ConfigPaths } from "./paths"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
|
||||||
|
export namespace TuiConfig {
|
||||||
|
const log = Log.create({ service: "tui.config" })
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
$schema: z.string().optional(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
keybinds: Config.Keybinds.optional(),
|
||||||
|
tui: Config.TUI.optional(),
|
||||||
|
plugin: z.array(z.union([z.string(), z.tuple([z.string(), z.record(z.string(), z.unknown())])])).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type Info = z.output<typeof Info>
|
||||||
|
|
||||||
|
function mergeInfo(target: Info, source: Info): Info {
|
||||||
|
const merged = mergeDeep(target, source)
|
||||||
|
if (target.plugin && source.plugin) {
|
||||||
|
merged.plugin = [...target.plugin, ...source.plugin]
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function customPath() {
|
||||||
|
if (!Flag.OPENCODE_CONFIG) return
|
||||||
|
const file = path.basename(Flag.OPENCODE_CONFIG)
|
||||||
|
if (file === "tui.json" || file === "tui.jsonc") return Flag.OPENCODE_CONFIG
|
||||||
|
if (file === "opencode.jsonc") return path.join(path.dirname(Flag.OPENCODE_CONFIG), "tui.jsonc")
|
||||||
|
return path.join(path.dirname(Flag.OPENCODE_CONFIG), "tui.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Instance.state(async () => {
|
||||||
|
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
? []
|
||||||
|
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||||
|
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
||||||
|
const custom = customPath()
|
||||||
|
const managed = Config.managedConfigDir()
|
||||||
|
await migrateFromOpencode({ projectFiles, directories, custom, managed })
|
||||||
|
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
? []
|
||||||
|
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||||
|
|
||||||
|
let result: Info = {}
|
||||||
|
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
|
result = mergeInfo(result, await loadFile(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (custom) {
|
||||||
|
result = mergeInfo(result, await loadFile(custom))
|
||||||
|
log.debug("loaded custom tui config", { path: custom })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of projectFiles) {
|
||||||
|
result = mergeInfo(result, await loadFile(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(managed)) {
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||||
|
result = mergeInfo(result, await loadFile(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.keybinds ??= Config.Keybinds.parse({})
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function get() {
|
||||||
|
return state().then((x) => x.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForDependencies() {
|
||||||
|
const deps = await state().then((x) => x.deps)
|
||||||
|
await Promise.all(deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateFromOpencode(input: {
|
||||||
|
projectFiles: string[]
|
||||||
|
directories: string[]
|
||||||
|
custom?: string
|
||||||
|
managed: string
|
||||||
|
}) {
|
||||||
|
const existing = await hasAnyTuiConfig(input)
|
||||||
|
if (existing) return
|
||||||
|
|
||||||
|
const opencode = await opencodeFiles(input)
|
||||||
|
for (const file of opencode) {
|
||||||
|
const source = await Bun.file(file)
|
||||||
|
.text()
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!source) continue
|
||||||
|
const data = parseJsonc(source)
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) continue
|
||||||
|
|
||||||
|
const extracted = {
|
||||||
|
theme: "theme" in data ? (data.theme as string | undefined) : undefined,
|
||||||
|
keybinds: "keybinds" in data ? (data.keybinds as Info["keybinds"]) : undefined,
|
||||||
|
tui: "tui" in data ? (data.tui as Info["tui"]) : undefined,
|
||||||
|
}
|
||||||
|
if (!extracted.theme && !extracted.keybinds && !extracted.tui) continue
|
||||||
|
|
||||||
|
const target = path.join(path.dirname(file), "tui.json")
|
||||||
|
const targetExists = await Bun.file(target).exists()
|
||||||
|
if (targetExists) continue
|
||||||
|
|
||||||
|
const payload: Info = {
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}
|
||||||
|
if (extracted.theme) payload.theme = extracted.theme
|
||||||
|
if (extracted.keybinds) payload.keybinds = extracted.keybinds
|
||||||
|
if (extracted.tui) payload.tui = extracted.tui
|
||||||
|
|
||||||
|
await Bun.write(target, JSON.stringify(payload, null, 2))
|
||||||
|
log.info("migrated tui config", { from: file, to: target })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasAnyTuiConfig(input: {
|
||||||
|
projectFiles: string[]
|
||||||
|
directories: string[]
|
||||||
|
custom?: string
|
||||||
|
managed: string
|
||||||
|
}) {
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
|
if (await Bun.file(file).exists()) return true
|
||||||
|
}
|
||||||
|
if (input.projectFiles.length) return true
|
||||||
|
for (const dir of unique(input.directories)) {
|
||||||
|
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||||
|
if (await Bun.file(file).exists()) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.custom && (await Bun.file(input.custom).exists())) return true
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(input.managed, "tui")) {
|
||||||
|
if (await Bun.file(file).exists()) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function opencodeFiles(input: { directories: string[]; managed: string }) {
|
||||||
|
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
? []
|
||||||
|
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
|
||||||
|
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
||||||
|
for (const dir of unique(input.directories)) {
|
||||||
|
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
||||||
|
}
|
||||||
|
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
||||||
|
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
|
||||||
|
|
||||||
|
const existing = await Promise.all(
|
||||||
|
unique(files).map(async (file) => {
|
||||||
|
const ok = await Bun.file(file).exists()
|
||||||
|
return ok ? file : undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return existing.filter((file): file is string => !!file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(filepath: string): Promise<Info> {
|
||||||
|
let text = await Bun.file(filepath)
|
||||||
|
.text()
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!text) return {}
|
||||||
|
return load(text, filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||||
|
text = await Config.substitute(text, configFilepath, "empty")
|
||||||
|
|
||||||
|
const parsed = Info.safeParse(parseJsonc(text))
|
||||||
|
if (!parsed.success) return {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,16 +51,13 @@ export namespace Plugin {
|
||||||
plugins = [...BUILTIN, ...plugins]
|
plugins = [...BUILTIN, ...plugins]
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let plugin of plugins) {
|
async function resolve(spec: string) {
|
||||||
// ignore old codex plugin since it is supported first party now
|
if (spec.startsWith("file://")) return spec
|
||||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
const lastAtIndex = spec.lastIndexOf("@")
|
||||||
log.info("loading plugin", { path: plugin })
|
const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec
|
||||||
if (!plugin.startsWith("file://")) {
|
const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest"
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
|
||||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
|
||||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
|
||||||
const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
|
const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
|
||||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
const installed = await BunProc.install(pkg, version).catch((err) => {
|
||||||
if (!builtin) throw err
|
if (!builtin) throw err
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
@ -77,17 +74,33 @@ export namespace Plugin {
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
})
|
})
|
||||||
if (!plugin) continue
|
if (!installed) return
|
||||||
|
return installed
|
||||||
}
|
}
|
||||||
const mod = await import(plugin)
|
|
||||||
|
for (const item of plugins) {
|
||||||
|
// ignore old codex plugin since it is supported first party now
|
||||||
|
const spec = Config.pluginSpecifier(item)
|
||||||
|
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)
|
||||||
// Prevent duplicate initialization when plugins export the same function
|
// 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`).
|
// 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.
|
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||||
const seen = new Set<PluginInstance>()
|
const seen = new Set<unknown>()
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
for (const [_name, entry] of Object.entries(mod)) {
|
||||||
if (seen.has(fn)) continue
|
if (seen.has(entry)) continue
|
||||||
seen.add(fn)
|
seen.add(entry)
|
||||||
const init = await fn(input)
|
const server = (() => {
|
||||||
|
if (typeof entry === "function") return entry as PluginInstance
|
||||||
|
if (!entry || typeof entry !== "object") return
|
||||||
|
if ("server" in entry && typeof entry.server === "function") return entry.server as PluginInstance
|
||||||
|
return
|
||||||
|
})()
|
||||||
|
if (!server) continue
|
||||||
|
const init = await server(input, Config.pluginOptions(item))
|
||||||
hooks.push(init)
|
hooks.push(init)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
|
||||||
await Bun.write(path.join(dir, name), JSON.stringify(config))
|
await Bun.write(path.join(dir, name), JSON.stringify(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spec = (plugin: Config.PluginSpec) => Config.pluginSpecifier(plugin)
|
||||||
|
const name = (plugin: Config.PluginSpec) => Config.getPluginName(plugin)
|
||||||
|
|
||||||
test("loads config with defaults when no files exist", async () => {
|
test("loads config with defaults when no files exist", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|
@ -55,6 +58,28 @@ test("loads JSON config file", async () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("ignores legacy tui keys in opencode config", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await writeConfig(dir, {
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
model: "test/model",
|
||||||
|
theme: "legacy",
|
||||||
|
tui: { scroll_speed: 4 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await Config.get()
|
||||||
|
expect(config.model).toBe("test/model")
|
||||||
|
expect((config as Record<string, unknown>).theme).toBeUndefined()
|
||||||
|
expect((config as Record<string, unknown>).tui).toBeUndefined()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("loads JSONC config file", async () => {
|
test("loads JSONC config file", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|
@ -109,14 +134,14 @@ test("merges multiple config files with correct precedence", async () => {
|
||||||
|
|
||||||
test("handles environment variable substitution", async () => {
|
test("handles environment variable substitution", async () => {
|
||||||
const originalEnv = process.env["TEST_VAR"]
|
const originalEnv = process.env["TEST_VAR"]
|
||||||
process.env["TEST_VAR"] = "test_theme"
|
process.env["TEST_VAR"] = "test-user"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await writeConfig(dir, {
|
await writeConfig(dir, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
theme: "{env:TEST_VAR}",
|
username: "{env:TEST_VAR}",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -124,7 +149,7 @@ test("handles environment variable substitution", async () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
expect(config.theme).toBe("test_theme")
|
expect(config.username).toBe("test-user")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -147,7 +172,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "opencode.json"),
|
path.join(dir, "opencode.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
theme: "{env:PRESERVE_VAR}",
|
username: "{env:PRESERVE_VAR}",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -156,7 +181,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
expect(config.theme).toBe("secret_value")
|
expect(config.username).toBe("secret_value")
|
||||||
|
|
||||||
// Read the file to verify the env variable was preserved
|
// Read the file to verify the env variable was preserved
|
||||||
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
|
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
|
||||||
|
|
@ -177,10 +202,10 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||||
test("handles file inclusion substitution", async () => {
|
test("handles file inclusion substitution", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "included.txt"), "test_theme")
|
await Bun.write(path.join(dir, "included.txt"), "test-user")
|
||||||
await writeConfig(dir, {
|
await writeConfig(dir, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
theme: "{file:included.txt}",
|
username: "{file:included.txt}",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -188,7 +213,7 @@ test("handles file inclusion substitution", async () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
expect(config.theme).toBe("test_theme")
|
expect(config.username).toBe("test-user")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -199,7 +224,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||||
await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
||||||
await writeConfig(dir, {
|
await writeConfig(dir, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
theme: "{file:included.md}",
|
username: "{file:included.md}",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -207,7 +232,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
expect(config.theme).toBe("const out = await Bun.$`echo hi`")
|
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -690,15 +715,79 @@ test("resolves scoped npm plugins in config", async () => {
|
||||||
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
||||||
const expected = import.meta.resolve("@scope/plugin", baseUrl)
|
const expected = import.meta.resolve("@scope/plugin", baseUrl)
|
||||||
|
|
||||||
expect(pluginEntries.includes(expected)).toBe(true)
|
const specs = pluginEntries.map(spec)
|
||||||
|
|
||||||
const scopedEntry = pluginEntries.find((entry) => entry === expected)
|
expect(specs.includes(expected)).toBe(true)
|
||||||
|
|
||||||
|
const scopedEntry = specs.find((entry) => entry === expected)
|
||||||
expect(scopedEntry).toBeDefined()
|
expect(scopedEntry).toBeDefined()
|
||||||
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
|
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves plugin options while resolving specifiers", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
|
||||||
|
await fs.mkdir(pluginDir, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(pluginDir, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "@scope/plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
type: "module",
|
||||||
|
main: "./index.js",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
plugin: [["@scope/plugin", { mode: "tui", nested: { foo: "bar" } }]],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await Config.get()
|
||||||
|
const pluginEntries = config.plugin ?? []
|
||||||
|
const entry = pluginEntries.find(
|
||||||
|
(item) => Array.isArray(item) && spec(item).includes("/node_modules/@scope/plugin/"),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
if (!entry || !Array.isArray(entry)) return
|
||||||
|
|
||||||
|
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
||||||
|
const expected = import.meta.resolve("@scope/plugin", baseUrl)
|
||||||
|
|
||||||
|
expect(entry[0]).toBe(expected)
|
||||||
|
expect(entry[1]).toEqual({ mode: "tui", nested: { foo: "bar" } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("merges plugin arrays from global and local configs", async () => {
|
test("merges plugin arrays from global and local configs", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|
@ -734,12 +823,12 @@ test("merges plugin arrays from global and local configs", async () => {
|
||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
// Should contain both global and local plugins
|
// Should contain both global and local plugins
|
||||||
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
|
expect(plugins.some((p) => name(p) === "global-plugin-1")).toBe(true)
|
||||||
expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
|
expect(plugins.some((p) => name(p) === "global-plugin-2")).toBe(true)
|
||||||
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
|
expect(plugins.some((p) => name(p) === "local-plugin-1")).toBe(true)
|
||||||
|
|
||||||
// Should have all 3 plugins (not replaced, but merged)
|
// Should have all 3 plugins (not replaced, but merged)
|
||||||
const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
|
const pluginNames = plugins.filter((p) => name(p).includes("global-plugin") || name(p).includes("local-plugin"))
|
||||||
expect(pluginNames.length).toBeGreaterThanOrEqual(3)
|
expect(pluginNames.length).toBeGreaterThanOrEqual(3)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -893,17 +982,17 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||||
const plugins = config.plugin ?? []
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
// Should contain all unique plugins
|
// Should contain all unique plugins
|
||||||
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
|
expect(plugins.some((p) => name(p) === "global-plugin-1")).toBe(true)
|
||||||
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
|
expect(plugins.some((p) => name(p) === "local-plugin-1")).toBe(true)
|
||||||
expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
|
expect(plugins.some((p) => name(p) === "duplicate-plugin")).toBe(true)
|
||||||
|
|
||||||
// Should deduplicate the duplicate plugin
|
// Should deduplicate the duplicate plugin
|
||||||
const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
|
const duplicatePlugins = plugins.filter((p) => name(p) === "duplicate-plugin")
|
||||||
expect(duplicatePlugins.length).toBe(1)
|
expect(duplicatePlugins.length).toBe(1)
|
||||||
|
|
||||||
// Should have exactly 3 unique plugins
|
// Should have exactly 3 unique plugins
|
||||||
const pluginNames = plugins.filter(
|
const pluginNames = plugins.filter((p) =>
|
||||||
(p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
|
["global-plugin-1", "local-plugin-1", "duplicate-plugin"].includes(name(p)),
|
||||||
)
|
)
|
||||||
expect(pluginNames.length).toBe(3)
|
expect(pluginNames.length).toBe(3)
|
||||||
},
|
},
|
||||||
|
|
@ -1042,7 +1131,6 @@ test("managed settings override project settings", async () => {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
autoupdate: true,
|
autoupdate: true,
|
||||||
disabled_providers: [],
|
disabled_providers: [],
|
||||||
theme: "dark",
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1059,7 +1147,6 @@ test("managed settings override project settings", async () => {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
expect(config.autoupdate).toBe(false)
|
expect(config.autoupdate).toBe(false)
|
||||||
expect(config.disabled_providers).toEqual(["openai"])
|
expect(config.disabled_providers).toEqual(["openai"])
|
||||||
expect(config.theme).toBe("dark")
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1596,7 +1683,7 @@ describe("deduplicatePlugins", () => {
|
||||||
|
|
||||||
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
|
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
|
||||||
expect(myPlugins.length).toBe(1)
|
expect(myPlugins.length).toBe(1)
|
||||||
expect(myPlugins[0].startsWith("file://")).toBe(true)
|
expect(spec(myPlugins[0]).startsWith("file://")).toBe(true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { TuiConfig } from "../../src/config/tui"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
delete process.env.OPENCODE_CONFIG
|
||||||
|
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
||||||
|
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads tui config with the same precedence order as server config paths", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
|
||||||
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
|
||||||
|
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, ".opencode", "tui.json"),
|
||||||
|
JSON.stringify({ theme: "local", tui: { diff_style: "stacked" } }, null, 2),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.theme).toBe("local")
|
||||||
|
expect(config.tui?.diff_style).toBe("stacked")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
theme: "migrated-theme",
|
||||||
|
tui: { scroll_speed: 5 },
|
||||||
|
keybinds: { app_exit: "ctrl+q" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.theme).toBe("migrated-theme")
|
||||||
|
expect(config.tui?.scroll_speed).toBe(5)
|
||||||
|
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||||
|
expect(await Bun.file(path.join(tmp.path, "tui.json")).exists()).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only reads plugin list from tui.json", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["server-only"] }, null, 2))
|
||||||
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: ["tui-only"] }, null, 2))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.plugin).toEqual(["tui-only"])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses renderer options from tui config", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "tui.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
tui: {
|
||||||
|
renderer: {
|
||||||
|
target_fps: 75,
|
||||||
|
auto_focus: true,
|
||||||
|
use_kitty_keyboard: {
|
||||||
|
events: true,
|
||||||
|
report_text: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.tui?.renderer?.target_fps).toBe(75)
|
||||||
|
expect(config.tui?.renderer?.auto_focus).toBe(true)
|
||||||
|
expect(config.tui?.renderer?.use_kitty_keyboard).toEqual({
|
||||||
|
events: true,
|
||||||
|
report_text: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opentui/core": "0.1.79",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,16 @@ import type {
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Auth,
|
Auth,
|
||||||
Config,
|
Config as SDKConfig,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
|
import type { createOpencodeClient as createOpencodeClientV2, Event as TuiEvent } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
import type { BunShell } from "./shell"
|
import type { BunShell } from "./shell"
|
||||||
import { type ToolDefinition } from "./tool"
|
import { type ToolDefinition } from "./tool"
|
||||||
|
|
||||||
export * from "./tool"
|
export * from "./tool"
|
||||||
|
export type { CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
export type ProviderContext = {
|
export type ProviderContext = {
|
||||||
source: "env" | "config" | "custom" | "api"
|
source: "env" | "config" | "custom" | "api"
|
||||||
|
|
@ -32,7 +35,55 @@ export type PluginInput = {
|
||||||
$: BunShell
|
$: 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TuiPlugin<Renderer = CliRenderer> = (
|
||||||
|
input: TuiPluginInput<Renderer>,
|
||||||
|
options?: PluginOptions,
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
export type PluginModule<Renderer = CliRenderer> =
|
||||||
|
| Plugin
|
||||||
|
| { server?: Plugin; tui?: TuiPlugin<Renderer>; themes?: Record<string, ThemeJson> }
|
||||||
|
|
||||||
export type AuthHook = {
|
export type AuthHook = {
|
||||||
provider: string
|
provider: string
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,12 @@ You can configure TUI-specific settings through the `tui` option.
|
||||||
"scroll_acceleration": {
|
"scroll_acceleration": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"diff_style": "auto"
|
"diff_style": "auto",
|
||||||
|
"renderer": {
|
||||||
|
"target_fps": 60,
|
||||||
|
"auto_focus": false,
|
||||||
|
"use_kitty_keyboard": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -174,6 +179,7 @@ Available options:
|
||||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
|
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
|
||||||
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
|
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
|
||||||
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
|
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
|
||||||
|
- `renderer` - Renderer startup options such as `target_fps`, `max_fps`, `gather_stats`, `use_mouse`, `enable_mouse_movement`, `use_alternate_screen`, `auto_focus`, `open_console_on_error`, and `use_kitty_keyboard`.
|
||||||
|
|
||||||
[Learn more about using the TUI here](/docs/tui).
|
[Learn more about using the TUI here](/docs/tui).
|
||||||
|
|
||||||
|
|
@ -540,10 +546,12 @@ You can configure MCP servers you want to use through the `mcp` option.
|
||||||
|
|
||||||
Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option.
|
Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option.
|
||||||
|
|
||||||
|
Each entry can be a string specifier or a `[specifier, options]` tuple. Options are passed to the plugin initializer.
|
||||||
|
|
||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["opencode-helicone-session", "@my-org/custom-plugin"]
|
"plugin": ["opencode-helicone-session", ["@my-org/custom-plugin", { "arbitrary": "options" }]]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,23 @@ For examples, check out the [plugins](/docs/ecosystem#plugins) created by the co
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## System overview
|
||||||
|
|
||||||
|
OpenCode plugins support two entry points:
|
||||||
|
|
||||||
|
- `server` (loaded by the OpenCode server from `opencode.json`)
|
||||||
|
- `tui` (loaded by the terminal UI from `tui.json`)
|
||||||
|
|
||||||
|
A plugin can implement either entry point, or both in the same module.
|
||||||
|
|
||||||
|
In short:
|
||||||
|
|
||||||
|
- **v1 compatibility**: a default exported function is treated as a server plugin.
|
||||||
|
- **v2 format**: export an object with `server` and/or `tui` keys.
|
||||||
|
- **TUI plugin scope**: only plugins listed in `tui.json` are loaded for the TUI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Use a plugin
|
## Use a plugin
|
||||||
|
|
||||||
There are two ways to load plugins.
|
There are two ways to load plugins.
|
||||||
|
|
@ -33,7 +50,11 @@ Specify npm packages in your config file.
|
||||||
```json title="opencode.json"
|
```json title="opencode.json"
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["opencode-helicone-session", "opencode-wakatime", "@my-org/custom-plugin"]
|
"plugin": [
|
||||||
|
"opencode-helicone-session",
|
||||||
|
["opencode-wakatime", { "project": "vault-33" }],
|
||||||
|
["@my-org/custom-plugin", { "arbitrary": "options" }]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -53,14 +74,25 @@ Browse available plugins in the [ecosystem](/docs/ecosystem#plugins).
|
||||||
|
|
||||||
### Load order
|
### Load order
|
||||||
|
|
||||||
Plugins are loaded from all sources and all hooks run in sequence. The load order is:
|
Plugins are loaded from all sources and all hooks run in sequence. When the same plugin appears multiple times, **higher-priority sources win**:
|
||||||
|
|
||||||
1. Global config (`~/.config/opencode/opencode.json`)
|
1. Project plugin directory (`.opencode/plugins/`)
|
||||||
2. Project config (`opencode.json`)
|
2. Project config (`opencode.json`)
|
||||||
3. Global plugin directory (`~/.config/opencode/plugins/`)
|
3. Global plugin directory (`~/.config/opencode/plugins/`)
|
||||||
4. Project plugin directory (`.opencode/plugins/`)
|
4. Global config (`~/.config/opencode/opencode.json`)
|
||||||
|
|
||||||
Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately.
|
Plugins are deduplicated by canonical name (package name or local file name). A higher‑priority local file named `my-plugin.js` will override a lower‑priority npm package named `my-plugin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Plugin options from config
|
||||||
|
|
||||||
|
Each entry in the `plugin` array can be either:
|
||||||
|
|
||||||
|
- A string specifier (package name or file URL), or
|
||||||
|
- A tuple of `[specifier, options]`
|
||||||
|
|
||||||
|
Options are passed as the **second argument** to your plugin initializer (both `server` and `tui`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -104,7 +136,7 @@ export const MyPlugin = async (ctx) => {
|
||||||
### Basic structure
|
### Basic structure
|
||||||
|
|
||||||
```js title=".opencode/plugins/example.js"
|
```js title=".opencode/plugins/example.js"
|
||||||
export const MyPlugin = async ({ project, client, $, directory, worktree }) => {
|
export const MyPlugin = async ({ project, client, $, directory, worktree, serverUrl }, options) => {
|
||||||
console.log("Plugin initialized!")
|
console.log("Plugin initialized!")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -120,6 +152,94 @@ The plugin function receives:
|
||||||
- `worktree`: The git worktree path.
|
- `worktree`: The git worktree path.
|
||||||
- `client`: An opencode SDK client for interacting with the AI.
|
- `client`: An opencode SDK client for interacting with the AI.
|
||||||
- `$`: Bun's [shell API](https://bun.com/docs/runtime/shell) for executing commands.
|
- `$`: Bun's [shell API](https://bun.com/docs/runtime/shell) for executing commands.
|
||||||
|
- `serverUrl`: The server URL for the current OpenCode instance.
|
||||||
|
|
||||||
|
If you provided options in config, they will be available as the second argument.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TUI plugins
|
||||||
|
|
||||||
|
Plugins can also export a `{ server, tui }` object. The server loader executes `server` (same as a normal plugin function). The TUI loader executes `tui` **only** when a TUI is running.
|
||||||
|
|
||||||
|
To load a TUI plugin, add it to `tui.json`:
|
||||||
|
|
||||||
|
```json title="tui.json"
|
||||||
|
{
|
||||||
|
"plugin": ["my-tui-plugin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts title=".opencode/plugins/example.ts"
|
||||||
|
export const MyPlugin = {
|
||||||
|
server: async (ctx, options) => {
|
||||||
|
return {
|
||||||
|
// Server hooks
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tui: async (ctx, options) => {
|
||||||
|
// TUI-only setup (subscribe to events, call client APIs, etc.)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
TUI input includes:
|
||||||
|
|
||||||
|
- `client`: the SDK client for the connected server
|
||||||
|
- `event`: an event bus for server events
|
||||||
|
- `renderer`: the active OpenTUI renderer instance
|
||||||
|
- `url`: server URL
|
||||||
|
- `directory`: optional working directory
|
||||||
|
|
||||||
|
Example: hook into the renderer and react to terminal resize events.
|
||||||
|
|
||||||
|
```ts title=".opencode/plugins/resize-listener.ts"
|
||||||
|
import type { TuiPlugin } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
export const MyPlugin: { tui: TuiPlugin } = {
|
||||||
|
tui: async (ctx) => {
|
||||||
|
const onResize = (width: number, height: number) => {
|
||||||
|
console.log("terminal resized", { width, height })
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.renderer.on("resize", onResize)
|
||||||
|
|
||||||
|
// later, if needed:
|
||||||
|
// ctx.renderer.off("resize", onResize)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Themes from plugins
|
||||||
|
|
||||||
|
Plugins can register one or more TUI themes. Define them as `themes` on the exported object. The TUI will register them as soon as the plugin module is loaded.
|
||||||
|
|
||||||
|
```ts title=".opencode/plugins/theme-pack.ts"
|
||||||
|
export const MyPlugin = {
|
||||||
|
themes: {
|
||||||
|
"vault-tec": {
|
||||||
|
theme: {
|
||||||
|
primary: "#5ea9ff",
|
||||||
|
secondary: "#7cff7c",
|
||||||
|
accent: "#ffd06a",
|
||||||
|
// ...all required theme colors
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"vault-tec-light": {
|
||||||
|
theme: {
|
||||||
|
primary: "#1b4b8a",
|
||||||
|
secondary: "#2f8a2f",
|
||||||
|
accent: "#a86a00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tui: async () => {},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin themes are added to the theme list. If a theme name already exists (built‑in or custom), the existing theme takes precedence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -141,7 +261,7 @@ export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
|
|
||||||
Plugins can subscribe to events as seen below in the Examples section. Here is a list of the different events available.
|
Plugins can subscribe to events by implementing the `event` hook. The hook receives `{ event }`, where `event.type` is the event name and `event.data` contains the payload. Here is a list of the different events available.
|
||||||
|
|
||||||
#### Command Events
|
#### Command Events
|
||||||
|
|
||||||
|
|
@ -206,6 +326,44 @@ Plugins can subscribe to events as seen below in the Examples section. Here is a
|
||||||
- `tui.prompt.append`
|
- `tui.prompt.append`
|
||||||
- `tui.command.execute`
|
- `tui.command.execute`
|
||||||
- `tui.toast.show`
|
- `tui.toast.show`
|
||||||
|
- `tui.session.select`
|
||||||
|
|
||||||
|
These fire only when a TUI is connected or a client drives the TUI via `/tui/*` APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hook reference
|
||||||
|
|
||||||
|
Beyond the `event` hook, plugins can implement the following **stable** hooks:
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||||
|
| `config` | Receives the merged config after startup. |
|
||||||
|
| `tool` | Register custom tools with `@opencode-ai/plugin`. |
|
||||||
|
| `auth` | Provide custom authentication flows for providers. |
|
||||||
|
| `chat.message` | Runs when a new user message is received (modify `output.message`/`output.parts`). |
|
||||||
|
| `chat.params` | Modify LLM parameters such as temperature, topP, topK, or provider options. |
|
||||||
|
| `chat.headers` | Inject custom request headers for provider calls. |
|
||||||
|
| `permission.ask` | Decide whether a permission request should be allowed, denied, or asked. |
|
||||||
|
| `command.execute.before` | Modify or inject parts before a slash command executes. |
|
||||||
|
| `tool.execute.before` | Modify tool arguments before execution. |
|
||||||
|
| `tool.execute.after` | Modify tool output metadata/title/text after execution. |
|
||||||
|
| `shell.env` | Inject environment variables into all shell executions. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Experimental hooks
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
Experimental hooks are unstable and can change without notice.
|
||||||
|
:::
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
| -------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `experimental.chat.messages.transform` | Transform the full list of message parts sent to the model. |
|
||||||
|
| `experimental.chat.system.transform` | Modify system prompts before sending them to the model. |
|
||||||
|
| `experimental.session.compacting` | Customize compaction context or replace the compaction prompt. |
|
||||||
|
| `experimental.text.complete` | Post-process generated text parts before they are committed. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue