merge dev

This commit is contained in:
Shoubhit Dash
2026-04-16 17:30:14 +05:30
992 changed files with 59931 additions and 106582 deletions
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export * as Config from "./config"
export * as ConfigMarkdown from "./markdown"
export * as ConfigPaths from "./paths"
+164
View File
@@ -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,<leader>q").describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>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("<leader>s").describe("View status"),
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>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("<leader>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("<leader>y").describe("Copy message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
messages_toggle_conceal: z
.string()
.optional()
.default("<leader>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("<leader>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("<leader>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("<leader>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("<leader>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",
})
}
+92 -94
View File
@@ -1,99 +1,97 @@
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "@opencode-ai/shared/util/error"
import matter from "gray-matter"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
import { Filesystem } from "../util"
export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /!`([^`]+)`/g
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /!`([^`]+)`/g
export function files(template: string) {
return Array.from(template.matchAll(FILE_REGEX))
}
export function shell(template: string) {
return Array.from(template.matchAll(SHELL_REGEX))
}
// other coding agents like claude code allow invalid yaml in their
// frontmatter, we need to fallback to a more permissive parser for those cases
export function fallbackSanitization(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split(/\r?\n/)
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |-`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) {
const template = await Filesystem.readText(filePath)
try {
const md = matter(template)
return md
} catch {
try {
return matter(fallbackSanitization(template))
} catch (err) {
throw new FrontmatterError(
{
path: filePath,
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
},
{ cause: err },
)
}
}
}
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
path: z.string(),
message: z.string(),
}),
)
export function files(template: string) {
return Array.from(template.matchAll(FILE_REGEX))
}
export function shell(template: string) {
return Array.from(template.matchAll(SHELL_REGEX))
}
// other coding agents like claude code allow invalid yaml in their
// frontmatter, we need to fallback to a more permissive parser for those cases
export function fallbackSanitization(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split(/\r?\n/)
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |-`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) {
const template = await Filesystem.readText(filePath)
try {
const md = matter(template)
return md
} catch {
try {
return matter(fallbackSanitization(template))
} catch (err) {
throw new FrontmatterError(
{
path: filePath,
message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
},
{ cause: err },
)
}
}
}
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
path: z.string(),
message: z.string(),
}),
)
+157 -159
View File
@@ -2,166 +2,164 @@ import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error"
import { Filesystem } from "@/util/filesystem"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
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}.json`), path.join(dir, `${name}.jsonc`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
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}.json`), path.join(dir, `${name}.jsonc`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
+75
View File
@@ -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<typeof Options>
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
export type Spec = z.infer<typeof Spec>
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<Spec> {
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<string>()
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()
}
}
-155
View File
@@ -1,155 +0,0 @@
import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const LegacyTheme = TuiInfo.shape.theme.optional()
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
const TuiLegacy = z
.object({
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined),
})
.strip()
interface MigrateInput {
directories: string[]
custom?: string
managed: string
}
/**
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
* into dedicated tui.json files. Migration is performed per-directory and
* skips only locations where a tui.json already exists.
*/
export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true })
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const extracted = {
theme: theme.success ? theme.data : undefined,
keybinds: keybinds.success ? keybinds.data : undefined,
tui: legacyTui.success ? legacyTui.data : undefined,
}
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
const target = path.join(path.dirname(file), "tui.json")
const targetExists = await Filesystem.exists(target)
if (targetExists) continue
const payload: Record<string, unknown> = {
$schema: TUI_SCHEMA_URL,
}
if (extracted.theme !== undefined) payload.theme = extracted.theme
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
}
}
function normalizeTui(data: Record<string, unknown>) {
const parsed = TuiLegacy.parse(data)
if (
parsed.scroll_speed === undefined &&
parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined
) {
return
}
return parsed
}
async function backupAndStripLegacy(file: string, source: string) {
const backup = file + ".tui-migration.bak"
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Filesystem.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
const edits = modify(acc, [key], undefined, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
if (!edits.length) return acc
return applyEdits(acc, edits)
}, source)
return Filesystem.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
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 Filesystem.exists(file)
return ok ? file : undefined
}),
)
return existing.filter((file): file is string => !!file)
}
@@ -1,37 +0,0 @@
import z from "zod"
import { Config } from "./config"
const KeybindOverride = z
.object(
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
)
.strict()
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
})
export const TuiInfo = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
.strict()
-179
View File
@@ -1,179 +0,0 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
type Acc = {
result: Info
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
}
function pluginScope(file: string): Config.PluginScope {
if (Instance.containsPath(file)) return "local"
return "global"
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
function installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir)
}
async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
const plugins = Config.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
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 migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file)
}
if (custom) {
await mergeFile(acc, custom)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file)
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
await mergeFile(acc, file)
}
}
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(
",",
)
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(installDeps(dir))
}
}
return {
config: acc.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 loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
}
}