diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index 699ad57a71..b7a3f701f8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -28,7 +28,6 @@ interface MigrateInput { cwd: string directories: string[] custom?: string - managed: string } /** @@ -134,14 +133,13 @@ async function backupAndStripLegacy(file: string, source: string) { }) } -async function opencodeFiles(input: { directories: string[]; managed: string; cwd: string }) { +async function opencodeFiles(input: { directories: string[]; cwd: string }) { const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd) 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) => { diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 5433d22e97..66569efea5 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,10 +1,10 @@ import z from "zod" -import { Config } from "@/config/config" import { ConfigPlugin } from "@/config/plugin" +import { ConfigKeybinds } from "@/config/keybinds" const KeybindOverride = z .object( - Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< + Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< string, z.ZodOptional >, diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index ca85395b3b..00c7387002 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,10 +1,7 @@ -import { existsSync } from "fs" import z from "zod" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" -import { Config } from "@/config/config" import { ConfigPaths } from "@/config/paths" -import { ConfigPlugin } from "@/config/plugin" import { migrateTuiConfig } from "./tui-migrate" import { TuiInfo } from "./tui-schema" import { Flag } from "@/flag/flag" @@ -17,6 +14,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Npm } from "@opencode-ai/shared/npm" import { Installation } from "@/installation" import { CurrentWorkingDirectory } from "./cwd" +import { ConfigPlugin } from "@/config/plugin" +import { ConfigKeybinds } from "@/config/keybinds" export namespace TuiConfig { const log = Log.create({ service: "tui.config" }) @@ -78,9 +77,9 @@ export namespace TuiConfig { const scope = pluginScope(file, ctx) const plugins = ConfigPlugin.deduplicatePluginOrigins([ ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec: ConfigPlugin.Spec) => ({ spec, scope, source: file })), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), ]) - acc.result.plugin = plugins.map((item: ConfigPlugin.Origin) => item.spec) + acc.result.plugin = plugins.map((item) => item.spec) acc.result.plugin_origins = plugins } @@ -88,8 +87,7 @@ export namespace TuiConfig { let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) const directories = await ConfigPaths.directories(ctx.directory) const custom = customPath() - const managed = Config.managedConfigDir() - await migrateTuiConfig({ directories, custom, managed, cwd: ctx.directory }) + await migrateTuiConfig({ directories, custom, cwd: ctx.directory }) // Re-compute after migration since migrateTuiConfig may have created new tui.json files projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) @@ -119,21 +117,16 @@ export namespace TuiConfig { } } - if (existsSync(managed)) { - for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { - await mergeFile(acc, file, ctx) - } - } - const keybinds = { ...(acc.result.keybinds ?? {}) } if (process.platform === "win32") { // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join( - ",", - ) + keybinds.input_undo ??= unique([ + "ctrl+z", + ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ]).join(",") } - acc.result.keybinds = Config.Keybinds.parse(keybinds) + acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) return { config: acc.result, @@ -169,7 +162,7 @@ export namespace TuiConfig { }).pipe(Effect.withSpan("TuiConfig.layer")), ) - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Npm.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index f776e08ffb..2bec798680 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -8,19 +8,12 @@ import { Global } from "@/global" import { iife } from "@/util/iife" import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" +import { Provider } from "@/provider/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util/filesystem" -export function parseModel(model: string) { - const [providerID, ...rest] = model.split("/") - return { - providerID: providerID, - modelID: rest.join("/"), - } -} - export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { @@ -158,7 +151,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const args = useArgs() const fallbackModel = createMemo(() => { if (args.model) { - const { providerID, modelID } = parseModel(args.model) + const { providerID, modelID } = Provider.parseModel(args.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -168,7 +161,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } if (sync.data.config.model) { - const { providerID, modelID } = parseModel(sync.data.config.model) + const { providerID, modelID } = Provider.parseModel(sync.data.config.model) if (isModelValid({ providerID, modelID })) { return { providerID, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index d9137573d5..7835235712 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,7 +17,6 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, - SnapshotFileDiff, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "@tui/context/project" @@ -25,6 +24,7 @@ import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/shared/util/binary" import { createSimpleContext } from "./helper" +import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" import { batch, createEffect, on } from "solid-js" @@ -59,7 +59,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: Snapshot.FileDiff[] } todo: { [sessionID: string]: Todo[] diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index d2b495ca31..c9a96c1997 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" -import { InstallationVersion } from "@/installation/version" +import { Installation } from "@/installation" type RouteEntry = { key: symbol @@ -189,7 +189,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { function appApi(): TuiPluginApi["app"] { return { get version() { - return InstallationVersion + return Installation.VERSION }, } } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/index.ts b/packages/opencode/src/cli/cmd/tui/plugin/index.ts index 718812fbf6..c970a318f2 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/index.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/index.ts @@ -1,70 +1,3 @@ -import type { TuiPluginApi, TuiPluginInstallResult, TuiPluginStatus } from "@opencode-ai/plugin/tui" -import type { TuiConfig } from "@/cli/cmd/tui/config/tui" -import type { RouteMap } from "./api" - -// Lazy wrappers for heavy modules - only loaded when actually used - -async function loadRuntime() { - const mod = await import("./runtime") - return mod.TuiPluginRuntime -} - -async function loadApi() { - const mod = await import("./api") - return mod.createTuiApi -} - -// Re-export type only - no runtime cost +export { TuiPluginRuntime } from "./runtime" +export { createTuiApi } from "./api" export type { RouteMap } from "./api" - -// Lazy wrapper for TuiPluginRuntime - methods deferred until called -export namespace TuiPluginRuntime { - export const Slot = { - name: "app" as const, - } - - export async function init(input: { api: TuiPluginApi; config: TuiConfig.Info }) { - const Runtime = await loadRuntime() - return Runtime.init(input) - } - - export function list(): TuiPluginStatus[] { - // Runtime not loaded yet, return empty - return [] - } - - export async function activatePlugin(id: string) { - const Runtime = await loadRuntime() - return Runtime.activatePlugin(id) - } - - export async function deactivatePlugin(id: string) { - const Runtime = await loadRuntime() - return Runtime.deactivatePlugin(id) - } - - export async function addPlugin(spec: string) { - const Runtime = await loadRuntime() - return Runtime.addPlugin(spec) - } - - export async function installPlugin(spec: string, options?: { global?: boolean }): Promise { - const Runtime = await loadRuntime() - return Runtime.installPlugin(spec, options) - } - - export async function dispose() { - const Runtime = await loadRuntime() - return Runtime.dispose() - } -} - -// Lazy wrapper for createTuiApi - module loaded on first call -let createTuiApiCached: ((input: Parameters[0]) => TuiPluginApi) | undefined - -export async function createTuiApi(input: Parameters[0]): Promise { - if (!createTuiApiCached) { - createTuiApiCached = await loadApi() - } - return createTuiApiCached(input) -} diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 95d357d996..cd67635e9b 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,46 +1,19 @@ +import { AccountServiceError, AccountTransportError } from "@/account" +import { ConfigMarkdown } from "@/config/markdown" import { errorFormat } from "@/util/error" - -// Error name constants to avoid importing heavy modules -const ERROR_NAMES = { - MCP_FAILED: "MCPFailed", - ACCOUNT_TRANSPORT: "AccountTransportError", - ACCOUNT_SERVICE: "AccountServiceError", - PROVIDER_MODEL_NOT_FOUND: "ProviderModelNotFoundError", - PROVIDER_INIT: "ProviderInitError", - CONFIG_JSON: "ConfigJsonError", - CONFIG_DIR_TYPO: "ConfigConfigDirectoryTypoError", - CONFIG_MARKDOWN_FRONTMATTER: "ConfigMarkdownFrontmatterError", - CONFIG_INVALID: "ConfigInvalidError", - UI_CANCELLED: "UICancelledError", -} as const - -function isNamedError(input: unknown, name: string): boolean { - return input instanceof Error && input.name === name -} - -function getErrorData(input: unknown): Record | undefined { - if (input instanceof Error && "data" in input) { - return (input as any).data - } - return undefined -} +import { Config } from "../config/config" +import { MCP } from "../mcp" +import { Provider } from "../provider/provider" +import { UI } from "./ui" export function FormatError(input: unknown) { - // MCP.Failed - if (isNamedError(input, ERROR_NAMES.MCP_FAILED)) { - const data = getErrorData(input) - return `MCP server "${data?.name}" failed. Note, opencode does not support MCP authentication yet.` + if (MCP.Failed.isInstance(input)) + return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` + if (input instanceof AccountTransportError || input instanceof AccountServiceError) { + return input.message } - - // Account errors - if (isNamedError(input, ERROR_NAMES.ACCOUNT_TRANSPORT) || isNamedError(input, ERROR_NAMES.ACCOUNT_SERVICE)) { - return (input as Error).message - } - - // Provider.ModelNotFoundError - if (isNamedError(input, ERROR_NAMES.PROVIDER_MODEL_NOT_FOUND)) { - const data = getErrorData(input) - const { providerID, modelID, suggestions } = data ?? {} + if (Provider.ModelNotFoundError.isInstance(input)) { + const { providerID, modelID, suggestions } = input.data return [ `Model not found: ${providerID}/${modelID}`, ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), @@ -48,41 +21,28 @@ export function FormatError(input: unknown) { `Or check your config (opencode.json) provider/model names`, ].join("\n") } - - // Provider.InitError - if (isNamedError(input, ERROR_NAMES.PROVIDER_INIT)) { - const data = getErrorData(input) - return `Failed to initialize provider "${data?.providerID}". Check credentials and configuration.` + if (Provider.InitError.isInstance(input)) { + return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.` } - - // Config.JsonError - if (isNamedError(input, ERROR_NAMES.CONFIG_JSON)) { - const data = getErrorData(input) - return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "") + if (Config.JsonError.isInstance(input)) { + return ( + `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") + ) } - - // Config.ConfigDirectoryTypoError - if (isNamedError(input, ERROR_NAMES.CONFIG_DIR_TYPO)) { - const data = getErrorData(input) - return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.` + if (Config.ConfigDirectoryTypoError.isInstance(input)) { + return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.` } - - // ConfigMarkdown.FrontmatterError - if (isNamedError(input, ERROR_NAMES.CONFIG_MARKDOWN_FRONTMATTER)) { - return (input as Error).message + if (ConfigMarkdown.FrontmatterError.isInstance(input)) { + return input.data.message } + if (Config.InvalidError.isInstance(input)) + return [ + `Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` + + (input.data.message ? `: ${input.data.message}` : ""), + ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), + ].join("\n") - // Config.InvalidError - if (isNamedError(input, ERROR_NAMES.CONFIG_INVALID)) { - const data = getErrorData(input) - const path = data?.path && data.path !== "config" ? ` at ${data.path}` : "" - const message = data?.message ? `: ${data.message}` : "" - const issues = data?.issues?.map((issue: any) => "↳ " + issue.message + " " + issue.path.join(".")) ?? [] - return [`Configuration is invalid${path}${message}`, ...issues].join("\n") - } - - // UI.CancelledError - if (isNamedError(input, ERROR_NAMES.UI_CANCELLED)) return "" + if (UI.CancelledError.isInstance(input)) return "" } export function FormatUnknownError(input: unknown): string { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 180f6afe4a..dbff7adce2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -496,167 +496,6 @@ export namespace Config { }) export type Agent = z.infer - export const Keybinds = z - .object({ - leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), - app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), - editor_open: z.string().optional().default("e").describe("Open external editor"), - theme_list: z.string().optional().default("t").describe("List available themes"), - sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), - scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), - username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), - status_view: z.string().optional().default("s").describe("View status"), - session_export: z.string().optional().default("x").describe("Export session to editor"), - session_new: z.string().optional().default("n").describe("Create a new session"), - session_list: z.string().optional().default("l").describe("List all sessions"), - session_timeline: z.string().optional().default("g").describe("Show session timeline"), - session_fork: z.string().optional().default("none").describe("Fork session from message"), - session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), - session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), - stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), - model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), - model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), - session_share: z.string().optional().default("none").describe("Share current session"), - session_unshare: z.string().optional().default("none").describe("Unshare current session"), - session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), - session_compact: z.string().optional().default("c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), - messages_page_down: z - .string() - .optional() - .default("pagedown,ctrl+alt+f") - .describe("Scroll messages down by one page"), - messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), - messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), - messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), - messages_half_page_down: z - .string() - .optional() - .default("ctrl+alt+d") - .describe("Scroll messages down by half page"), - messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), - messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), - messages_next: z.string().optional().default("none").describe("Navigate to next message"), - messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), - messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), - messages_copy: z.string().optional().default("y").describe("Copy message"), - messages_undo: z.string().optional().default("u").describe("Undo message"), - messages_redo: z.string().optional().default("r").describe("Redo message"), - messages_toggle_conceal: z - .string() - .optional() - .default("h") - .describe("Toggle code block concealment in messages"), - tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), - model_list: z.string().optional().default("m").describe("List available models"), - model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), - model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), - model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), - model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), - command_list: z.string().optional().default("ctrl+p").describe("List available commands"), - agent_list: z.string().optional().default("a").describe("List agents"), - agent_cycle: z.string().optional().default("tab").describe("Next agent"), - agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), - variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), - variant_list: z.string().optional().default("none").describe("List model variants"), - input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), - input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), - input_submit: z.string().optional().default("return").describe("Submit input"), - input_newline: z - .string() - .optional() - .default("shift+return,ctrl+return,alt+return,ctrl+j") - .describe("Insert newline in input"), - input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), - input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), - input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), - input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), - input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), - input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), - input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), - input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), - input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), - input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), - input_select_line_home: z - .string() - .optional() - .default("ctrl+shift+a") - .describe("Select to start of line in input"), - input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), - input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), - input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), - input_select_visual_line_home: z - .string() - .optional() - .default("alt+shift+a") - .describe("Select to start of visual line in input"), - input_select_visual_line_end: z - .string() - .optional() - .default("alt+shift+e") - .describe("Select to end of visual line in input"), - input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), - input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), - input_select_buffer_home: z - .string() - .optional() - .default("shift+home") - .describe("Select to start of buffer in input"), - input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), - input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), - input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), - input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), - input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), - input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), - input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), - input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), - input_word_forward: z - .string() - .optional() - .default("alt+f,alt+right,ctrl+right") - .describe("Move word forward in input"), - input_word_backward: z - .string() - .optional() - .default("alt+b,alt+left,ctrl+left") - .describe("Move word backward in input"), - input_select_word_forward: z - .string() - .optional() - .default("alt+shift+f,alt+shift+right") - .describe("Select word forward in input"), - input_select_word_backward: z - .string() - .optional() - .default("alt+shift+b,alt+shift+left") - .describe("Select word backward in input"), - input_delete_word_forward: z - .string() - .optional() - .default("alt+d,alt+delete,ctrl+delete") - .describe("Delete word forward in input"), - input_delete_word_backward: z - .string() - .optional() - .default("ctrl+w,ctrl+backspace,alt+backspace") - .describe("Delete word backward in input"), - history_previous: z.string().optional().default("up").describe("Previous history item"), - history_next: z.string().optional().default("down").describe("Next history item"), - session_child_first: z.string().optional().default("down").describe("Go to first child session"), - session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), - session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), - session_parent: z.string().optional().default("up").describe("Go to parent session"), - terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), - terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), - tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), - plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), - display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), - }) - .strict() - .meta({ - ref: "KeybindsConfig", - }) - export const Server = z .object({ port: z.number().int().positive().optional().describe("Port to listen on"), diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts new file mode 100644 index 0000000000..9b8d9e2834 --- /dev/null +++ b/packages/opencode/src/config/keybinds.ts @@ -0,0 +1,164 @@ +import z from "zod" + +export namespace ConfigKeybinds { + export const Keybinds = z + .object({ + leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), + app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), + editor_open: z.string().optional().default("e").describe("Open external editor"), + theme_list: z.string().optional().default("t").describe("List available themes"), + sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), + scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), + username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), + status_view: z.string().optional().default("s").describe("View status"), + session_export: z.string().optional().default("x").describe("Export session to editor"), + session_new: z.string().optional().default("n").describe("Create a new session"), + session_list: z.string().optional().default("l").describe("List all sessions"), + session_timeline: z.string().optional().default("g").describe("Show session timeline"), + session_fork: z.string().optional().default("none").describe("Fork session from message"), + session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), + session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), + stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), + model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), + model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), + session_share: z.string().optional().default("none").describe("Share current session"), + session_unshare: z.string().optional().default("none").describe("Unshare current session"), + session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), + session_compact: z.string().optional().default("c").describe("Compact the session"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), + messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), + messages_half_page_down: z + .string() + .optional() + .default("ctrl+alt+d") + .describe("Scroll messages down by half page"), + messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), + messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), + messages_next: z.string().optional().default("none").describe("Navigate to next message"), + messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), + messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), + messages_copy: z.string().optional().default("y").describe("Copy message"), + messages_undo: z.string().optional().default("u").describe("Undo message"), + messages_redo: z.string().optional().default("r").describe("Redo message"), + messages_toggle_conceal: z + .string() + .optional() + .default("h") + .describe("Toggle code block concealment in messages"), + tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), + model_list: z.string().optional().default("m").describe("List available models"), + model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), + model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), + command_list: z.string().optional().default("ctrl+p").describe("List available commands"), + agent_list: z.string().optional().default("a").describe("List agents"), + agent_cycle: z.string().optional().default("tab").describe("Next agent"), + agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), + variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), + variant_list: z.string().optional().default("none").describe("List model variants"), + input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), + input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_submit: z.string().optional().default("return").describe("Submit input"), + input_newline: z + .string() + .optional() + .default("shift+return,ctrl+return,alt+return,ctrl+j") + .describe("Insert newline in input"), + input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), + input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), + input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), + input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), + input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), + input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), + input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), + input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), + input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), + input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), + input_select_line_home: z + .string() + .optional() + .default("ctrl+shift+a") + .describe("Select to start of line in input"), + input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), + input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), + input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), + input_select_visual_line_home: z + .string() + .optional() + .default("alt+shift+a") + .describe("Select to start of visual line in input"), + input_select_visual_line_end: z + .string() + .optional() + .default("alt+shift+e") + .describe("Select to end of visual line in input"), + input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), + input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), + input_select_buffer_home: z + .string() + .optional() + .default("shift+home") + .describe("Select to start of buffer in input"), + input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), + input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), + input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), + input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), + input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), + input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), + input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), + input_word_forward: z + .string() + .optional() + .default("alt+f,alt+right,ctrl+right") + .describe("Move word forward in input"), + input_word_backward: z + .string() + .optional() + .default("alt+b,alt+left,ctrl+left") + .describe("Move word backward in input"), + input_select_word_forward: z + .string() + .optional() + .default("alt+shift+f,alt+shift+right") + .describe("Select word forward in input"), + input_select_word_backward: z + .string() + .optional() + .default("alt+shift+b,alt+shift+left") + .describe("Select word backward in input"), + input_delete_word_forward: z + .string() + .optional() + .default("alt+d,alt+delete,ctrl+delete") + .describe("Delete word forward in input"), + input_delete_word_backward: z + .string() + .optional() + .default("ctrl+w,ctrl+backspace,alt+backspace") + .describe("Delete word backward in input"), + history_previous: z.string().optional().default("up").describe("Previous history item"), + history_next: z.string().optional().default("down").describe("Next history item"), + session_child_first: z.string().optional().default("down").describe("Go to first child session"), + session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), + session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), + session_parent: z.string().optional().default("up").describe("Go to parent session"), + terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), + terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), + tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), + plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"), + display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"), + }) + .strict() + .meta({ + ref: "KeybindsConfig", + }) +} diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts new file mode 100644 index 0000000000..d13a9d5adc --- /dev/null +++ b/packages/opencode/src/config/plugin.ts @@ -0,0 +1,75 @@ +import { Glob } from "@opencode-ai/shared/util/glob" +import z from "zod" +import { pathToFileURL } from "url" +import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import path from "path" + +export namespace ConfigPlugin { + const Options = z.record(z.string(), z.unknown()) + export type Options = z.infer + + export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) + export type Spec = z.infer + + export type Scope = "global" | "local" + + export type Origin = { + spec: Spec + source: string + scope: Scope + } + + export async function load(dir: string) { + const plugins: ConfigPlugin.Spec[] = [] + + for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + plugins.push(pathToFileURL(item).href) + } + return plugins + } + + export function pluginSpecifier(plugin: ConfigPlugin.Spec): string { + return Array.isArray(plugin) ? plugin[0] : plugin + } + + export function pluginOptions(plugin: Spec): Options | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined + } + + export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise { + const spec = pluginSpecifier(plugin) + if (!isPathPluginSpec(spec)) return plugin + + const base = path.dirname(configFilepath) + const file = (() => { + if (spec.startsWith("file://")) return spec + if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href + return pathToFileURL(path.resolve(base, spec)).href + })() + + const resolved = await resolvePathPluginTarget(file).catch(() => file) + + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } + + export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { + const seen = new Set() + const list: Origin[] = [] + + for (const plugin of plugins.toReversed()) { + const spec = pluginSpecifier(plugin.spec) + const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg + if (seen.has(name)) continue + seen.add(name) + list.push(plugin) + } + + return list.toReversed() + } +} diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts index f79306bf1e..3c89fcd6f1 100644 --- a/packages/opencode/src/effect/observability.ts +++ b/packages/opencode/src/effect/observability.ts @@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" import { EffectLogger } from "@/effect/logger" import { Flag } from "@/flag/flag" -import { CHANNEL, VERSION } from "@/installation/meta" +import { InstallationChannel, InstallationVersion } from "@/installation/version" export namespace Observability { const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT @@ -22,9 +22,9 @@ export namespace Observability { const resource = { serviceName: "opencode", - serviceVersion: VERSION, + serviceVersion: InstallationVersion, attributes: { - "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, + "deployment.environment.name": InstallationChannel === "local" ? "local" : InstallationChannel, "opencode.client": Flag.OPENCODE_CLIENT, }, } diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 5897e63f31..8ee107e008 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -8,7 +8,7 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" import { Log } from "../util/log" -import { InstallationChannel as channel, InstallationVersion as version } from "./version" +import { InstallationChannel, InstallationVersion } from "./version" import semver from "semver" @@ -55,12 +55,12 @@ export namespace Installation { }) export type Info = z.infer - export const VERSION = version - export const CHANNEL = channel - export const USER_AGENT = `opencode/${channel}/${version}/${Flag.OPENCODE_CLIENT}` + export const VERSION = InstallationVersion + export const CHANNEL = InstallationChannel + export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` export function isPreview() { - return CHANNEL !== "latest" + return InstallationChannel !== "latest" } export function isLocal() { diff --git a/packages/opencode/src/installation/meta.ts b/packages/opencode/src/installation/meta.ts deleted file mode 100644 index 4b19c89721..0000000000 --- a/packages/opencode/src/installation/meta.ts +++ /dev/null @@ -1 +0,0 @@ -export { InstallationVersion as VERSION, InstallationChannel as CHANNEL } from "./version" diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index d8a0a7c6c4..0245d311e0 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -1,4 +1,3 @@ -import { Installation } from "@/installation" import { checkPluginCompatibility, createPluginEntry, @@ -10,6 +9,7 @@ import { type PluginSource, } from "./shared" import { ConfigPlugin } from "@/config/plugin" +import { InstallationVersion } from "@/installation/version" export namespace PluginLoader { export type Plan = { @@ -88,7 +88,7 @@ export namespace PluginLoader { if (base.source === "npm") { try { - await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) + await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) } catch (error) { return { ok: false, stage: "compatibility", error } } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 68a41e471f..680ea85bb2 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -11,7 +11,7 @@ import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "../flag/flag" -import { CHANNEL } from "../installation/meta" +import { InstallationChannel } from "../installation/version" import { InstanceState } from "@/effect/instance-state" import { iife } from "@/util/iife" import { init } from "#db" @@ -29,9 +29,10 @@ const log = Log.create({ service: "db" }) export namespace Database { export function getChannelPath() { - if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + const channel = InstallationChannel + if (["latest", "beta", "prod"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") - const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") + const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 064c28df51..ee1ed4e630 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1982,7 +1982,7 @@ describe("deduplicatePluginOrigins", () => { source: "", scope: "global" as const, })), - ).map((item: ConfigPlugin.Origin) => item.spec) + ).map((item) => item.spec) test("removes duplicates keeping higher priority (later entries)", () => { const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"] diff --git a/packages/opencode/test/config/plugin.test.ts b/packages/opencode/test/config/plugin.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index bd02817054..5aa2dd91a6 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -4,13 +4,13 @@ import fs from "fs/promises" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Config } from "../../src/config/config" -import { ConfigPlugin } from "../../src/config/plugin" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect, Layer } from "effect" import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" +import { ConfigPlugin } from "@/config/plugin" const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const wintest = process.platform === "win32" ? test : test.skip