feat: unwrap config namespaces to flat exports + barrel (#22746)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Config } from "../src/config"
|
import { Config } from "../src/config"
|
||||||
import { TuiConfig } from "../src/config/tui"
|
import { TuiConfig } from "../src/config"
|
||||||
|
|
||||||
function generate(schema: z.ZodType) {
|
function generate(schema: z.ZodType) {
|
||||||
const result = z.toJSONSchema(schema, {
|
const result = z.toJSONSchema(schema, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
|
|
||||||
import { ConfigPaths } from "../../config/paths"
|
import { ConfigPaths } from "../../config"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||||
import { resolvePluginTarget } from "../../plugin/shared"
|
import { resolvePluginTarget } from "../../plugin/shared"
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config"
|
||||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cmd } from "../cmd"
|
|||||||
import { UI } from "@/cli/ui"
|
import { UI } from "@/cli/ui"
|
||||||
import { tui } from "./app"
|
import { tui } from "./app"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { Keybind } from "@/util"
|
import { Keybind } from "@/util"
|
||||||
import { pipe, mapValues } from "remeda"
|
import { pipe, mapValues } from "remeda"
|
||||||
import type { TuiConfig } from "@/config/tui"
|
import type { TuiConfig } from "@/config"
|
||||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
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"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
||||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
|
|||||||
import type { useSync } from "@tui/context/sync"
|
import type { useSync } from "@tui/context/sync"
|
||||||
import type { useTheme } from "@tui/context/theme"
|
import type { useTheme } from "@tui/context/theme"
|
||||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||||
import type { TuiConfig } from "@/config/tui"
|
import type { TuiConfig } from "@/config"
|
||||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||||
import type { useKV } from "../context/kv"
|
import type { useKV } from "../context/kv"
|
||||||
import { DialogAlert } from "../ui/dialog-alert"
|
import { DialogAlert } from "../ui/dialog-alert"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import path from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
import { Config } from "@/config"
|
import { Config } from "@/config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { errorData, errorMessage } from "@/util/error"
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Filesystem } from "@/util"
|
|||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } 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 { TuiConfig } from "@/config"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { writeHeapSnapshot } from "v8"
|
import { writeHeapSnapshot } from "v8"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
||||||
import type { TuiConfig } from "@/config/tui"
|
import type { TuiConfig } from "@/config"
|
||||||
|
|
||||||
export class CustomSpeedScroll implements ScrollAcceleration {
|
export class CustomSpeedScroll implements ScrollAcceleration {
|
||||||
constructor(private speed: number) {}
|
constructor(private speed: number) {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AccountServiceError, AccountTransportError } from "@/account"
|
import { AccountServiceError, AccountTransportError } from "@/account"
|
||||||
import { ConfigMarkdown } from "@/config/markdown"
|
import { ConfigMarkdown } from "@/config"
|
||||||
import { errorFormat } from "@/util/error"
|
import { errorFormat } from "@/util/error"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { Instance, type InstanceContext } from "../project/instance"
|
import { Instance, type InstanceContext } from "../project/instance"
|
||||||
import { LSPServer } from "../lsp/server"
|
import { LSPServer } from "../lsp/server"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { ConfigMarkdown } from "./markdown"
|
import { ConfigMarkdown } from "."
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
@@ -29,7 +29,7 @@ import { Event } from "../server/event"
|
|||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Account } from "@/account"
|
import { Account } from "@/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "."
|
||||||
import type { ConsoleState } from "./console-state"
|
import type { ConsoleState } from "./console-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
export * as ConfigMarkdown from "./markdown"
|
||||||
|
export * as ConfigPaths from "./paths"
|
||||||
|
export * as TuiConfig from "./tui"
|
||||||
|
|||||||
@@ -3,97 +3,95 @@ import matter from "gray-matter"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Filesystem } from "../util"
|
import { Filesystem } from "../util"
|
||||||
|
|
||||||
export namespace ConfigMarkdown {
|
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||||
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
export const SHELL_REGEX = /!`([^`]+)`/g
|
||||||
export const SHELL_REGEX = /!`([^`]+)`/g
|
|
||||||
|
|
||||||
export function files(template: string) {
|
export function files(template: string) {
|
||||||
return Array.from(template.matchAll(FILE_REGEX))
|
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 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(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,161 +7,159 @@ import { Filesystem } from "@/util"
|
|||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
|
||||||
export namespace ConfigPaths {
|
export async function projectFiles(name: string, directory: string, worktree: string) {
|
||||||
export async function projectFiles(name: string, directory: string, worktree: string) {
|
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
||||||
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))
|
||||||
|
}
|
||||||
|
|
||||||
export async function directories(directory: string, worktree: string) {
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||||
return [
|
const fileContent = (
|
||||||
Global.Path.config,
|
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
if (missing === "empty") return ""
|
||||||
? await Array.fromAsync(
|
|
||||||
Filesystem.up({
|
const errMsg = `bad file reference: "${token}"`
|
||||||
targets: [".opencode"],
|
if (error.code === "ENOENT") {
|
||||||
start: directory,
|
throw new InvalidError(
|
||||||
stop: worktree,
|
{
|
||||||
}),
|
path: configSource,
|
||||||
|
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||||
|
},
|
||||||
|
{ cause: error },
|
||||||
)
|
)
|
||||||
: []),
|
}
|
||||||
...(await Array.fromAsync(
|
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||||
Filesystem.up({
|
})
|
||||||
targets: [".opencode"],
|
).trim()
|
||||||
start: Global.Path.home,
|
|
||||||
stop: Global.Path.home,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileInDirectory(dir: string, name: string) {
|
out += JSON.stringify(fileContent).slice(1, -1)
|
||||||
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
cursor = index + token.length
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JsonError = NamedError.create(
|
out += text.slice(cursor)
|
||||||
"ConfigJsonError",
|
return out
|
||||||
z.object({
|
}
|
||||||
path: z.string(),
|
|
||||||
message: z.string().optional(),
|
/** 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)
|
||||||
export const InvalidError = NamedError.create(
|
|
||||||
"ConfigInvalidError",
|
const errors: JsoncParseError[] = []
|
||||||
z.object({
|
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||||
path: z.string(),
|
if (errors.length) {
|
||||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
const lines = text.split("\n")
|
||||||
message: z.string().optional(),
|
const errorDetails = errors
|
||||||
}),
|
.map((e) => {
|
||||||
)
|
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||||
|
const line = beforeOffset.length
|
||||||
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||||
export async function readFile(filepath: string) {
|
const problemLine = lines[line - 1]
|
||||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === "ENOENT") return
|
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||||
throw new JsonError({ path: filepath }, { cause: err })
|
if (!problemLine) return error
|
||||||
})
|
|
||||||
}
|
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||||
|
})
|
||||||
type ParseSource = string | { source: string; dir: string }
|
.join("\n")
|
||||||
|
|
||||||
function source(input: ParseSource) {
|
throw new JsonError({
|
||||||
return typeof input === "string" ? input : input.source
|
path: configSource,
|
||||||
}
|
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||||
|
|
||||||
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. */
|
return data
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "."
|
||||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
|||||||
+184
-186
@@ -3,7 +3,7 @@ import z from "zod"
|
|||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import { Context, Effect, Fiber, Layer } from "effect"
|
import { Context, Effect, Fiber, Layer } from "effect"
|
||||||
import { Config } from "."
|
import { Config } from "."
|
||||||
import { ConfigPaths } from "./paths"
|
import { ConfigPaths } from "."
|
||||||
import { migrateTuiConfig } from "./tui-migrate"
|
import { migrateTuiConfig } from "./tui-migrate"
|
||||||
import { TuiInfo } from "./tui-schema"
|
import { TuiInfo } from "./tui-schema"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
@@ -14,201 +14,199 @@ import { InstanceState } from "@/effect"
|
|||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
|
||||||
export namespace TuiConfig {
|
const log = Log.create({ service: "tui.config" })
|
||||||
const log = Log.create({ service: "tui.config" })
|
|
||||||
|
|
||||||
export const Info = TuiInfo
|
export const Info = TuiInfo
|
||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
result: Info
|
result: Info
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
config: Info
|
config: Info
|
||||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
export type Info = z.output<typeof Info> & {
|
||||||
// Internal resolved plugin list used by runtime loading.
|
// Internal resolved plugin list used by runtime loading.
|
||||||
plugin_origins?: Config.PluginOrigin[]
|
plugin_origins?: Config.PluginOrigin[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: () => Effect.Effect<Info>
|
readonly get: () => Effect.Effect<Info>
|
||||||
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||||
|
|
||||||
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
||||||
if (AppFileSystem.contains(ctx.directory, file)) return "local"
|
if (AppFileSystem.contains(ctx.directory, file)) return "local"
|
||||||
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
|
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
|
||||||
return "global"
|
return "global"
|
||||||
}
|
}
|
||||||
|
|
||||||
function customPath() {
|
function customPath() {
|
||||||
return Flag.OPENCODE_TUI_CONFIG
|
return Flag.OPENCODE_TUI_CONFIG
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(raw: Record<string, unknown>) {
|
function normalize(raw: Record<string, unknown>) {
|
||||||
const data = { ...raw }
|
const data = { ...raw }
|
||||||
if (!("tui" in data)) return data
|
if (!("tui" in data)) return data
|
||||||
if (!isRecord(data.tui)) {
|
if (!isRecord(data.tui)) {
|
||||||
delete data.tui
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
const tui = data.tui
|
|
||||||
delete data.tui
|
delete data.tui
|
||||||
return {
|
|
||||||
...tui,
|
|
||||||
...data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
|
||||||
const data = await loadFile(file)
|
|
||||||
acc.result = mergeDeep(acc.result, data)
|
|
||||||
if (!data.plugin?.length) return
|
|
||||||
|
|
||||||
const scope = pluginScope(file, ctx)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
|
||||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
||||||
? []
|
|
||||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
|
||||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.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", ctx.directory, ctx.worktree)
|
|
||||||
|
|
||||||
const acc: Acc = {
|
|
||||||
result: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (custom) {
|
|
||||||
await mergeFile(acc, custom, ctx)
|
|
||||||
log.debug("loaded custom tui config", { path: custom })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of projectFiles) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
",",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: acc.result,
|
|
||||||
dirs: acc.result.plugin?.length ? dirs : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cfg = yield* Config.Service
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("TuiConfig.state")(function* (ctx) {
|
|
||||||
const data = yield* Effect.promise(() => loadState(ctx))
|
|
||||||
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
})
|
|
||||||
return { config: data.config, deps }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
|
||||||
|
|
||||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
|
||||||
InstanceState.useEffect(state, (s) =>
|
|
||||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ get, waitForDependencies })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function get() {
|
|
||||||
return runPromise((svc) => svc.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForDependencies() {
|
|
||||||
await runPromise((svc) => svc.waitForDependencies())
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tui = data.tui
|
||||||
|
delete data.tui
|
||||||
|
return {
|
||||||
|
...tui,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
||||||
|
const data = await loadFile(file)
|
||||||
|
acc.result = mergeDeep(acc.result, data)
|
||||||
|
if (!data.plugin?.length) return
|
||||||
|
|
||||||
|
const scope = pluginScope(file, ctx)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState(ctx: { directory: string; worktree: string }) {
|
||||||
|
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
? []
|
||||||
|
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||||
|
const directories = await ConfigPaths.directories(ctx.directory, ctx.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", ctx.directory, ctx.worktree)
|
||||||
|
|
||||||
|
const acc: Acc = {
|
||||||
|
result: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
|
await mergeFile(acc, file, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (custom) {
|
||||||
|
await mergeFile(acc, custom, ctx)
|
||||||
|
log.debug("loaded custom tui config", { path: custom })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of projectFiles) {
|
||||||
|
await mergeFile(acc, file, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||||
|
await mergeFile(acc, file, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
",",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: acc.result,
|
||||||
|
dirs: acc.result.plugin?.length ? dirs : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cfg = yield* Config.Service
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("TuiConfig.state")(function* (ctx) {
|
||||||
|
const data = yield* Effect.promise(() => loadState(ctx))
|
||||||
|
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
})
|
||||||
|
return { config: data.config, deps }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
||||||
|
|
||||||
|
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||||
|
InstanceState.useEffect(state, (s) =>
|
||||||
|
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({ get, waitForDependencies })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function get() {
|
||||||
|
return runPromise((svc) => svc.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForDependencies() {
|
||||||
|
await runPromise((svc) => svc.waitForDependencies())
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
printParseErrorCode,
|
printParseErrorCode,
|
||||||
} from "jsonc-parser"
|
} from "jsonc-parser"
|
||||||
|
|
||||||
import { ConfigPaths } from "@/config/paths"
|
import { ConfigPaths } from "@/config"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL, fileURLToPath } from "url"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Global } from "@/global"
|
|||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config"
|
||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import { Discovery } from "./discovery"
|
import { Discovery } from "./discovery"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
import { Npm } from "../../../src/npm"
|
import { Npm } from "../../../src/npm"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { Global } from "../../../src/global"
|
import { Global } from "../../../src/global"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
import { Filesystem } from "../../../src/util"
|
import { Filesystem } from "../../../src/util"
|
||||||
|
|
||||||
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { UI } from "../../../src/cli/ui"
|
|||||||
import * as Timeout from "../../../src/util/timeout"
|
import * as Timeout from "../../../src/util/timeout"
|
||||||
import * as Network from "../../../src/cli/network"
|
import * as Network from "../../../src/cli/network"
|
||||||
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
||||||
import { TuiConfig } from "../../../src/config/tui"
|
import { TuiConfig } from "../../../src/config"
|
||||||
import { Instance } from "../../../src/project/instance"
|
import { Instance } from "../../../src/project/instance"
|
||||||
|
|
||||||
const stop = new Error("stop")
|
const stop = new Error("stop")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, describe } from "bun:test"
|
import { expect, test, describe } from "bun:test"
|
||||||
import { ConfigMarkdown } from "../../src/config/markdown"
|
import { ConfigMarkdown } from "../../src/config"
|
||||||
|
|
||||||
describe("ConfigMarkdown: normal template", () => {
|
describe("ConfigMarkdown: normal template", () => {
|
||||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import fs from "fs/promises"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Config } from "../../src/config"
|
import { Config } from "../../src/config"
|
||||||
import { TuiConfig } from "../../src/config/tui"
|
import { TuiConfig } from "../../src/config"
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spyOn } from "bun:test"
|
import { spyOn } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { TuiConfig } from "../../src/config/tui"
|
import { TuiConfig } from "../../src/config"
|
||||||
|
|
||||||
type PluginSpec = string | [string, Record<string, unknown>]
|
type PluginSpec = string | [string, Record<string, unknown>]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user