mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5ac6ba4f3 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
---
|
||||
|
||||
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
|
||||
@@ -115,6 +115,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
|
||||
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.message.next.description": "Go to the next user message",
|
||||
"command.model.choose": "Choose model",
|
||||
"command.model.choose.description": "Select a different model",
|
||||
"command.mcp.toggle": "Toggle MCPs",
|
||||
"command.mcp.toggle.description": "Toggle MCPs",
|
||||
"command.mcp.toggle": "Manage MCP servers",
|
||||
"command.mcp.toggle.description": "Enable or disable MCP servers",
|
||||
"command.agent.cycle": "Cycle agent",
|
||||
"command.agent.cycle.description": "Switch to the next agent",
|
||||
"command.agent.cycle.reverse": "Cycle agent backwards",
|
||||
@@ -307,9 +307,9 @@ export const dict = {
|
||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.title": "MCP servers",
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
"dialog.mcp.empty": "No MCP servers configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { TuiConfig } from "../../tui-config"
|
||||
import { Config } from "../../config"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Updater } from "../../services/updater"
|
||||
@@ -35,13 +35,18 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
preflight.loading()
|
||||
const config = yield* TuiConfig.load()
|
||||
const config = yield* Config.Service
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
yield* run({
|
||||
server,
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config,
|
||||
config: {
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { ConfigMigration } from "./migrate"
|
||||
import { Info } from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
readonly path: string
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
const empty: Info = {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const file = path.join(global.config, "cli.json")
|
||||
const lock = yield* Semaphore.make(1)
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
})
|
||||
|
||||
const write = Effect.fnUntraced(function* (text: string) {
|
||||
const temp = file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, text, { mode: 0o600 })
|
||||
yield* fs.rename(temp, file)
|
||||
})
|
||||
|
||||
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
)
|
||||
|
||||
const get = Effect.fn("cli.config.get")(function* () {
|
||||
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
})
|
||||
|
||||
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||
lock
|
||||
.withPermits(1)(
|
||||
Effect.gen(function* () {
|
||||
yield* migrate
|
||||
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const errors: ParseError[] = []
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
)
|
||||
|
||||
return Service.of({ path: file, get, update })
|
||||
}),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||
|
||||
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
before !== null &&
|
||||
after !== null &&
|
||||
typeof before === "object" &&
|
||||
typeof after === "object" &&
|
||||
!Array.isArray(before) &&
|
||||
!Array.isArray(after)
|
||||
) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as Config from "./config"
|
||||
@@ -0,0 +1,142 @@
|
||||
export * as ConfigMigration from "./migrate"
|
||||
|
||||
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import type { Info } from "./schema"
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
|
||||
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly file: string
|
||||
readonly config: string
|
||||
readonly state: string
|
||||
}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
const temp = input.file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
||||
yield* fs.rename(temp, input.file)
|
||||
yield* Effect.logInfo("migrated cli config", {
|
||||
from: [
|
||||
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||
].filter(Boolean),
|
||||
to: input.file,
|
||||
})
|
||||
})
|
||||
|
||||
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
||||
const plugins = [
|
||||
...(legacy?.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
) ?? []),
|
||||
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
|
||||
]
|
||||
const themeName = legacy?.theme ?? kv.theme
|
||||
const themeMode = kv.theme_mode_lock
|
||||
const attentionSoundPack = kv.attention_sound_pack
|
||||
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
||||
const thinking =
|
||||
kv.thinking_mode ??
|
||||
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
||||
|
||||
return {
|
||||
...(themeName !== undefined || themeMode !== undefined
|
||||
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
|
||||
: {}),
|
||||
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||
...(plugins.length ? { plugins } : {}),
|
||||
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||
? {}
|
||||
: {
|
||||
scroll: {
|
||||
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
|
||||
...(legacy.scroll_acceleration?.enabled === undefined
|
||||
? {}
|
||||
: { acceleration: legacy.scroll_acceleration.enabled }),
|
||||
},
|
||||
}),
|
||||
...(legacy?.attention === undefined && attentionSoundPack === undefined
|
||||
? {}
|
||||
: {
|
||||
attention: {
|
||||
...legacy?.attention,
|
||||
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
|
||||
},
|
||||
}),
|
||||
...(legacy?.diff_style === undefined &&
|
||||
kv.diff_wrap_mode === undefined &&
|
||||
kv.diff_viewer_show_file_tree === undefined &&
|
||||
kv.diff_viewer_single_patch === undefined &&
|
||||
diffView === undefined
|
||||
? {}
|
||||
: {
|
||||
diffs: {
|
||||
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
|
||||
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
|
||||
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
|
||||
...(diffView === undefined ? {} : { view: diffView }),
|
||||
},
|
||||
}),
|
||||
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
|
||||
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
|
||||
? {}
|
||||
: {
|
||||
prompt: {
|
||||
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
|
||||
...(kv.paste_summary_enabled === undefined
|
||||
? {}
|
||||
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.sidebar === undefined &&
|
||||
kv.scrollbar_visible === undefined &&
|
||||
thinking === undefined &&
|
||||
kv.exploration_grouping === undefined
|
||||
? {}
|
||||
: {
|
||||
session: {
|
||||
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
|
||||
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
|
||||
...(thinking === undefined ? {} : { thinking }),
|
||||
...(kv.exploration_grouping === undefined
|
||||
? {}
|
||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: {
|
||||
hints: {
|
||||
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
|
||||
...(kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: { onboarding: !kv.dismissed_getting_started }),
|
||||
},
|
||||
}),
|
||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||
}
|
||||
}
|
||||
|
||||
const readJson = Effect.fnUntraced(function* (target: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Config } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({ ...Config.Info.fields })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Config } from "../config"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -13,18 +14,26 @@ export type Input<Value> =
|
||||
|
||||
type RuntimeHandler = (
|
||||
input: unknown,
|
||||
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
) => Effect.Effect<
|
||||
void,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (
|
||||
input: Input<Node>,
|
||||
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
) => Effect.Effect<
|
||||
void,
|
||||
any,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Config } from "./config"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export * as TuiConfig from "./tui-config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
|
||||
export const load = Effect.fn("TuiConfig.load")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const filepath = path.join(global.config, "tui.json")
|
||||
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
return TuiConfig.resolve(
|
||||
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
|
||||
{ terminalSuspend: process.platform !== "win32" },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
|
||||
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
|
||||
return Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(
|
||||
path.join(directory, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
scroll_speed: 2,
|
||||
scroll_acceleration: { enabled: true },
|
||||
diff_style: "stacked",
|
||||
mouse: false,
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(directory, "kv.json"),
|
||||
JSON.stringify({
|
||||
theme_mode_lock: "light",
|
||||
paste_summary_enabled: false,
|
||||
exploration_grouping: false,
|
||||
tips_hidden: true,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toMatchObject({
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
diffs: { view: "unified" },
|
||||
prompt: { paste: "full" },
|
||||
session: { grouping: "none" },
|
||||
hints: { tips: false },
|
||||
mouse: false,
|
||||
})
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates before the first update and does not remigrate afterward", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
draft.mouse = false
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
|
||||
)
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { TuiConfig } from "../src/tui-config"
|
||||
|
||||
test("loads the global tui config", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
|
||||
|
||||
try {
|
||||
const config = await Effect.runPromise(
|
||||
TuiConfig.load().pipe(
|
||||
Effect.provide(Global.layerWith({ config: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -809,7 +809,6 @@ export type Endpoint21_4Input = {
|
||||
readonly location?: Endpoint21_4Request["query"]["location"]
|
||||
readonly cursor?: Endpoint21_4Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint21_4Request["query"]["limit"]
|
||||
readonly keep?: Endpoint21_4Request["query"]["keep"]
|
||||
}
|
||||
export type Endpoint21_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
|
||||
|
||||
@@ -971,12 +971,11 @@ type Endpoint21_4Input = {
|
||||
readonly location?: Endpoint21_4Request["query"]["location"]
|
||||
readonly cursor?: Endpoint21_4Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint21_4Request["query"]["limit"]
|
||||
readonly keep?: Endpoint21_4Request["query"]["keep"]
|
||||
}
|
||||
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
|
||||
raw["shell.output"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"], keep: input["keep"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint21_5Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
|
||||
@@ -59,11 +59,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
||||
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
||||
const compatible = yield* discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
yield* Effect.sync(() =>
|
||||
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
|
||||
)
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
const existing = yield* find(options)
|
||||
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
|
||||
return existing.endpoint
|
||||
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info))
|
||||
if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
@@ -138,6 +138,7 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||
@@ -161,7 +162,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (version !== undefined && health.value.version !== version) return undefined
|
||||
return { info, endpoint } satisfies LocalService
|
||||
return { info, endpoint, version: health.value.version } satisfies LocalService
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
|
||||
@@ -1411,7 +1411,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/shell/${encodeURIComponent(input.id)}/output`,
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"], keep: input["keep"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
|
||||
@@ -4623,26 +4623,17 @@ export type ShellOutputInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly cursor?: number | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly keep?: "head" | "tail" | undefined
|
||||
}["location"]
|
||||
readonly cursor?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly cursor?: number | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly keep?: "head" | "tail" | undefined
|
||||
}["cursor"]
|
||||
readonly limit?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly cursor?: number | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly keep?: "head" | "tail" | undefined
|
||||
}["limit"]
|
||||
readonly keep?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly cursor?: number | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly keep?: "head" | "tail" | undefined
|
||||
}["keep"]
|
||||
}
|
||||
|
||||
export type ShellOutputOutput = {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { rename, writeFile } from "node:fs/promises"
|
||||
|
||||
const [registration, mode] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
|
||||
let requests = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "modern" && requests === 1) {
|
||||
await writeFile(registration + ".first-request", "")
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
return new Response(null, { status: 503 })
|
||||
}
|
||||
if (mode === "legacy") return Response.json({ healthy: true })
|
||||
return Response.json({ healthy: true, version: "test", pid: process.pid })
|
||||
},
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
version: mode === "legacy" ? undefined : "test",
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
|
||||
const shutdown = () => {
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service } from "../src/effect/index"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
spawn(registration, "modern")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const first = run(
|
||||
Service.start({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
await waitForFile(registration + ".first-request")
|
||||
|
||||
const resolved = await run(Service.start({ file: registration, version: "test" }))
|
||||
expect(resolved.url).toBe(original.url)
|
||||
|
||||
await writeFile(registration + ".release", "")
|
||||
await first
|
||||
|
||||
expect(starts).toEqual([])
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "legacy")
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
await existing.exited
|
||||
})
|
||||
|
||||
function run<A, E>(effect: Effect.Effect<A, E, never>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
}
|
||||
|
||||
function spawn(registration: string, mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@ No limit has a default, on purpose: execution budgets are host policy. A host wi
|
||||
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
|
||||
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
|
||||
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
|
||||
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. CodeMode does not limit
|
||||
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
|
||||
|
||||
## Boundaries and Non-Goals
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# CodeMode Design and Status
|
||||
|
||||
This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
|
||||
It records current behavior, intentional boundaries, durable rationale, and material remaining work.
|
||||
|
||||
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
|
||||
completed work instead of preserving checked-off chronology.
|
||||
|
||||
Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives
|
||||
in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in
|
||||
[src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
|
||||
## How CodeMode Works
|
||||
|
||||
### Purpose
|
||||
|
||||
CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
|
||||
can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
|
||||
and filter or aggregate results before returning them to the agent loop.
|
||||
|
||||
The goals are:
|
||||
|
||||
- Reduce model context consumed by large tool catalogs.
|
||||
- Avoid an agent round-trip between every dependent tool call.
|
||||
- Keep large intermediate results inside the program instead of sending them through model context.
|
||||
- Give generated code only the authority explicitly supplied by the host.
|
||||
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
|
||||
|
||||
### Runtime
|
||||
|
||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
||||
|
||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
|
||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
||||
executes it without `eval`.
|
||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
||||
5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
|
||||
remains Effect interruption.
|
||||
|
||||
Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
|
||||
validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
|
||||
advertised as `Promise<unknown>`.
|
||||
|
||||
### Discovery and model workflow
|
||||
|
||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
||||
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
|
||||
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
|
||||
partial.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
|
||||
next execution.
|
||||
2. Call the exact returned path without guessing or normalizing segments.
|
||||
3. Narrow `Promise<unknown>` results before reading fields.
|
||||
4. Start independent calls together and await them with `Promise.all`.
|
||||
5. Filter and aggregate inside the program, then return only the data needed by the model.
|
||||
|
||||
Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
|
||||
path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||
|
||||
### Tool execution
|
||||
|
||||
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
||||
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
|
||||
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
|
||||
of work they started.
|
||||
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
|
||||
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
|
||||
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
|
||||
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
|
||||
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
|
||||
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
|
||||
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
||||
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
||||
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
||||
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
|
||||
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
|
||||
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
|
||||
discarded. At most eight tool calls execute concurrently.
|
||||
|
||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
|
||||
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
|
||||
host-added framing are intentionally outside the budgets.
|
||||
|
||||
### Data, files, and failures
|
||||
|
||||
Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
|
||||
Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
|
||||
boundary.
|
||||
|
||||
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
|
||||
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
|
||||
data, tool failures, limits, timeouts, execution failures, and warning truncation.
|
||||
|
||||
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
|
||||
attach them to the outer result, but the program receives only the structured tool output.
|
||||
|
||||
### V2 OpenCode adapter
|
||||
|
||||
CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
|
||||
`packages/core/src/tool/execute.ts`:
|
||||
|
||||
- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
|
||||
`Tools.Service`.
|
||||
- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
|
||||
normally.
|
||||
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
||||
CodeMode namespaces instead of flattened model-facing names.
|
||||
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
|
||||
requests.
|
||||
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
||||
authorization.
|
||||
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
||||
- Nested call statuses are returned as final `execute` metadata for the TUI.
|
||||
- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
|
||||
run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
|
||||
in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
|
||||
- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
|
||||
supervised children; the outer settlement applies Core's normal output-retention policy.
|
||||
|
||||
MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
|
||||
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
|
||||
inside CodeMode.
|
||||
|
||||
## Intentionally Unsupported
|
||||
|
||||
These are product boundaries rather than DSL backlog:
|
||||
|
||||
- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
|
||||
supplied tools.
|
||||
- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
|
||||
- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
|
||||
side effects. Hosts and tools own those concerns.
|
||||
- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
|
||||
|
||||
The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
|
||||
represent accurately rather than guessing semantics.
|
||||
|
||||
## Decisions and Rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
|
||||
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
|
||||
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
|
||||
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
|
||||
| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. |
|
||||
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
|
||||
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
|
||||
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
|
||||
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
|
||||
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
|
||||
|
||||
## Remaining Work
|
||||
|
||||
The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness,
|
||||
diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
@@ -24,7 +24,7 @@ ultimate source of truth.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
||||
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
|
||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||
|
||||
## Values and literals
|
||||
@@ -291,7 +291,6 @@ ultimate source of truth.
|
||||
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
||||
|
||||
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
||||
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
||||
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
||||
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
||||
`null` in render-only or OpenAPI tool calls.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Semaphore } from "effect"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
type AstNode,
|
||||
@@ -50,7 +50,7 @@ import { dateMethods } from "../stdlib/date.js"
|
||||
import { mathConstants } from "../stdlib/math.js"
|
||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
|
||||
import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js"
|
||||
import { promiseStatics } from "../stdlib/promise.js"
|
||||
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
||||
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
||||
import {
|
||||
@@ -150,7 +150,6 @@ export class Interpreter<R> {
|
||||
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
private readonly logs: Array<string>
|
||||
private readonly callPermits: Semaphore.Semaphore
|
||||
private readonly promises: PromiseRuntime<R>
|
||||
private readonly runner: CallbackRunner<R> = {
|
||||
invokeFunction: (fn, args) => this.invokeFunction(fn, args),
|
||||
@@ -163,7 +162,6 @@ export class Interpreter<R> {
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
promises: PromiseRuntime<R>,
|
||||
logs: Array<string> = [],
|
||||
callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY),
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = new ScopeStack([globalScope])
|
||||
@@ -171,7 +169,6 @@ export class Interpreter<R> {
|
||||
this.invokeSearch = invokeSearch
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
this.callPermits = callPermits
|
||||
this.promises = promises
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("search", { mutable: false, value: new SearchFunction() })
|
||||
@@ -239,7 +236,7 @@ export class Interpreter<R> {
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
return this.createPromise(this.callPermits.withPermit(Effect.suspend(() => this.invokeTool(path, args))))
|
||||
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
@@ -1496,14 +1493,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const invocation = new Interpreter(
|
||||
this.invokeTool,
|
||||
this.invokeSearch,
|
||||
this.toolKeys,
|
||||
this.promises,
|
||||
this.logs,
|
||||
this.callPermits,
|
||||
)
|
||||
const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
|
||||
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { PromiseMethodName } from "../interpreter/model.js"
|
||||
|
||||
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
|
||||
|
||||
export const TOOL_CALL_CONCURRENCY = 8
|
||||
|
||||
@@ -560,7 +560,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
|
||||
test("does not cap live tool-call concurrency", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await value(
|
||||
`
|
||||
@@ -572,8 +572,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
{ trace },
|
||||
)
|
||||
expect(result).toBe(20)
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
expect(trace.maxActive).toBeLessThanOrEqual(8)
|
||||
expect(trace.maxActive).toBe(20)
|
||||
})
|
||||
|
||||
test("resolves the empty array", async () => {
|
||||
|
||||
@@ -200,7 +200,6 @@ const layer = Layer.effect(
|
||||
.up({
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ export const Flag = {
|
||||
get OPENCODE_EXPERIMENTAL_REFERENCES() {
|
||||
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
|
||||
},
|
||||
get CODEMODE_ENABLED() {
|
||||
return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED")
|
||||
},
|
||||
get OPENCODE_TUI_CONFIG() {
|
||||
return process.env["OPENCODE_TUI_CONFIG"]
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as McpGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -18,11 +17,7 @@ type Summary = typeof Summary.Type
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...(Flag.CODEMODE_ENABLED
|
||||
? [
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
]
|
||||
: []),
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
@@ -81,7 +76,7 @@ export const layer = Layer.effect(
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
},
|
||||
})
|
||||
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
return source(Instructions.removed)
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
@@ -90,12 +85,9 @@ export const layer = Layer.effect(
|
||||
const visible = instructions
|
||||
.filter((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
return (
|
||||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
|
||||
owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
return owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
|
||||
@@ -139,9 +139,8 @@ export const layer = Layer.effect(
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const available = session.size - Math.max(0, cursor)
|
||||
const start = input?.keep === "tail" ? Math.max(cursor, session.size - limit) : Math.max(0, cursor)
|
||||
const length = Math.min(limit, available)
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
|
||||
@@ -29,7 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
|
||||
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||
`codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -58,16 +58,16 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
if (registration.group === undefined) {
|
||||
const path = registration.name
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
|
||||
tools[path] = value
|
||||
continue
|
||||
}
|
||||
const path = registration.name
|
||||
const namespace = registration.group
|
||||
const group = tools[namespace]
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
|
||||
if (group) {
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
|
||||
group[path] = value
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -137,6 +137,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
import { MCP } from "../mcp"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
@@ -107,7 +107,7 @@ export const layer = Layer.effectDiscard(
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
|
||||
([group, record]) => tools.register(record, { group }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
|
||||
@@ -191,6 +191,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -139,6 +139,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
@@ -56,7 +55,7 @@ const registryLayer = Layer.effect(
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly deferred: boolean
|
||||
readonly codemode: boolean
|
||||
}
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
@@ -132,7 +131,8 @@ const registryLayer = Layer.effect(
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.group)
|
||||
if (entries.length === 0) return
|
||||
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
const codemode = options?.codemode ?? true
|
||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
||||
@@ -149,7 +149,7 @@ const registryLayer = Layer.effect(
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
deferred: options?.deferred ?? false,
|
||||
codemode,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -168,18 +168,17 @@ const registryLayer = Layer.effect(
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
|
||||
const direct = new Map<string, Registration>()
|
||||
const deferred = new Map<string, Registration>()
|
||||
const codemode = new Map<string, Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
if (registration.deferred && !Flag.CODEMODE_ENABLED) continue
|
||||
if (whollyDisabled(permission(registration.tool, name), rules)) continue
|
||||
if (registration.deferred) deferred.set(name, registration)
|
||||
if (registration.codemode) codemode.set(name, registration)
|
||||
else direct.set(name, registration)
|
||||
}
|
||||
const execute =
|
||||
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
|
||||
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
|
||||
return {
|
||||
definitions: [
|
||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||
|
||||
@@ -132,6 +132,8 @@ export const Plugin = {
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", state },
|
||||
})
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -201,7 +203,7 @@ export const Plugin = {
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES, keep: "tail" })
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
@@ -213,7 +215,7 @@ export const Plugin = {
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = page.size > MAX_CAPTURE_BYTES
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return {
|
||||
@@ -276,6 +278,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -97,6 +97,7 @@ export const Plugin = {
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -208,6 +208,7 @@ export const Plugin = {
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -174,6 +174,7 @@ export const Plugin = {
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -252,6 +252,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -91,6 +91,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -876,7 +876,7 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -936,6 +936,7 @@ describe("Config", () => {
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
"outside",
|
||||
"root",
|
||||
"parent",
|
||||
"directory",
|
||||
@@ -951,6 +952,8 @@ describe("Config", () => {
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
AbsolutePath.make(path.join(tmp.path, "opencode.json")),
|
||||
"root",
|
||||
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||
"parent",
|
||||
|
||||
@@ -260,6 +260,7 @@ describe("PluginV2", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
@@ -273,7 +274,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
||||
it.effect("groups tool names and routes codemode registrations through execute", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
@@ -289,9 +290,9 @@ describe("PluginV2", () => {
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.add("plain", tool("Plain"))
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7" })
|
||||
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
|
||||
draft.add("plain", tool("Plain"), { codemode: false })
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
|
||||
draft.add("search", tool("Search"), { group: "context 7" })
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
@@ -330,6 +331,7 @@ describe("PluginV2", () => {
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -135,6 +135,7 @@ describe("fromPromise", () => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "hello",
|
||||
options: { codemode: false },
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("ToolRegistry", () => {
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
})
|
||||
}, { codemode: false })
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
@@ -95,8 +95,8 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const shared = make()
|
||||
yield* service.register({ first: shared })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") })
|
||||
yield* service.register({ first: shared }, { codemode: false })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
|
||||
Tool.withPermission(shared, "question")
|
||||
|
||||
expect(
|
||||
@@ -110,7 +110,7 @@ describe("ToolRegistry", () => {
|
||||
it.effect("reuses model definitions across requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const first = yield* toolDefinitions(service)
|
||||
const second = yield* toolDefinitions(service)
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
@@ -135,7 +135,7 @@ describe("ToolRegistry", () => {
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* service
|
||||
.register({ echo: make() })
|
||||
.register({ echo: make() }, { codemode: false })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
@@ -161,7 +161,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -184,7 +184,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* service.materialize().pipe(
|
||||
Effect.flatMap((materialized) =>
|
||||
@@ -203,7 +203,7 @@ describe("ToolRegistry", () => {
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const materialized = yield* service.materialize()
|
||||
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
@@ -248,7 +248,7 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ bounded: make() })
|
||||
yield* service.register({ bounded: make() }, { codemode: false })
|
||||
expect(
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
@@ -282,7 +282,7 @@ describe("ToolRegistry", () => {
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
@@ -319,7 +319,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -334,10 +334,10 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
|
||||
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
const request = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({ echo: constant("replacement") })
|
||||
yield* service.register({ echo: constant("replacement") }, { codemode: false })
|
||||
|
||||
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
|
||||
@@ -347,9 +347,9 @@ describe("ToolRegistry", () => {
|
||||
it.effect("reveals the previous registration after an overlay closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: constant("base") })
|
||||
yield* service.register({ echo: constant("base") }, { codemode: false })
|
||||
const overlay = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
|
||||
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
|
||||
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
|
||||
yield* Scope.close(overlay, Exit.void)
|
||||
@@ -357,37 +357,31 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes deferred tools advertised in a model request", () =>
|
||||
it.effect("executes codemode tools advertised in a model request", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* service
|
||||
.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register(
|
||||
{
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
})
|
||||
|
||||
const settlement = yield* materialized.settle({
|
||||
...call("execute"),
|
||||
|
||||
@@ -261,7 +261,7 @@ const echo = Layer.effectDiscard(
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
}),
|
||||
}),
|
||||
}, { codemode: false }),
|
||||
),
|
||||
)
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
@@ -782,7 +782,7 @@ describe("SessionRunnerLLM", () => {
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Use application context")
|
||||
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
||||
|
||||
@@ -827,7 +827,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
.pipe(Scope.provide(scope))
|
||||
yield* admit(session, "Use the reloaded tool")
|
||||
responses = [
|
||||
@@ -852,7 +852,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
@@ -3189,7 +3189,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call blocked")
|
||||
|
||||
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
||||
@@ -3221,7 +3221,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call declined")
|
||||
|
||||
response = reply.tool("call-declined", "declined", {})
|
||||
@@ -3261,7 +3261,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call corrected")
|
||||
|
||||
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
||||
@@ -3321,7 +3321,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({ permissionfail: permissionFail })
|
||||
yield* registry.register({ permissionfail: permissionFail }, { codemode: false })
|
||||
yield* admit(session, "Reject permission")
|
||||
responses = [
|
||||
reply.tool("call-permission", "permissionfail", {}),
|
||||
@@ -3366,7 +3366,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Ask then stop")
|
||||
|
||||
responses = [reply.tool("call-question", "question", {}), []]
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -162,10 +162,10 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
|
||||
const bodyExitCommand = isWindows
|
||||
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
|
||||
: "printf body && exit 7"
|
||||
const overflowMarkersCommand = (bytes: number) =>
|
||||
const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write('head-marker'); [Console]::Out.Write(('x' * ${bytes})); [Console]::Out.Write('tail-marker'); Start-Sleep -Milliseconds 100`
|
||||
: `printf head-marker; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf tail-marker`
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
|
||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -396,18 +396,15 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
return withSession(tmp.path, (registry) =>
|
||||
settleTool(registry, call({ command: overflowMarkersCommand(bytes) }, "call-overflow")),
|
||||
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/tail-marker[\s\S]*output truncated; full output saved to:/),
|
||||
text: expect.stringContaining("output truncated; full output saved to:"),
|
||||
})
|
||||
const content = settled.output?.content[0]
|
||||
if (content?.type === "text" && typeof content.text === "string")
|
||||
expect(content.text.includes("head-marker")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -446,6 +443,12 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
@@ -457,6 +460,13 @@ describe("ShellTool", () => {
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
|
||||
description: idleCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
state: "completed",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
Vendored
+4
-3
@@ -315,11 +315,12 @@ export default Plugin.define({
|
||||
Unsupported characters in tool and group names are normalized to underscores.
|
||||
The resulting exposed key must begin with a letter and contain at most 64
|
||||
letters, digits, underscores, or hyphens. Set `options` on the declaration to
|
||||
configure registration with `{ group, deferred }`:
|
||||
configure registration with `{ group, codemode }`:
|
||||
|
||||
- `group` prefixes and groups the exposed tool name.
|
||||
- `deferred: true` makes the tool available through the deferred `execute`
|
||||
tool instead of exposing it directly.
|
||||
- `codemode` defaults to `true` and makes the tool available through the
|
||||
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
|
||||
provider.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `assistantMessageID`, and `toolCallID`.
|
||||
|
||||
@@ -3,14 +3,19 @@ title: "Migrate from V1"
|
||||
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
|
||||
---
|
||||
|
||||
<Note>
|
||||
The only intentional breaking changes in V2 are the server API and the plugin API. All other functionality is intended
|
||||
to remain compatible with V1.
|
||||
</Note>
|
||||
## Breaking changes
|
||||
|
||||
Existing config files, agent definitions, command definitions, skills, and other files in `.opencode/` should continue to
|
||||
work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than an expected
|
||||
migration requirement.
|
||||
V2 has three intentional breaking changes:
|
||||
|
||||
- [Plugins](#plugins) use a new plugin API.
|
||||
- The [server API and clients](#server-api-and-clients) have new contracts.
|
||||
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
|
||||
|
||||
All other functionality is intended to remain compatible with V1.
|
||||
|
||||
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
|
||||
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
|
||||
an expected migration requirement.
|
||||
|
||||
<Tip>
|
||||
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
|
||||
@@ -466,6 +471,58 @@ If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the appl
|
||||
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected
|
||||
project details. See [Instructions](/instructions).
|
||||
|
||||
## TUI configuration
|
||||
|
||||
V1 loaded `tui.json(c)` from the global config directory and from project directories discovered while walking up from
|
||||
the current directory. V2 instead stores CLI and TUI settings in one global file:
|
||||
|
||||
```text
|
||||
~/.config/opencode/cli.json
|
||||
```
|
||||
|
||||
The CLI owns this file. The background service does not load it, and V2 does not discover or merge project-local
|
||||
`tui.json(c)` or `cli.json` files.
|
||||
|
||||
The native V2 format groups related settings. For example:
|
||||
|
||||
```jsonc
|
||||
// V1: ~/.config/opencode/tui.json
|
||||
{
|
||||
"theme": "tokyonight",
|
||||
"scroll_speed": 2,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
// V2: ~/.config/opencode/cli.json
|
||||
{
|
||||
"theme": {
|
||||
"name": "tokyonight"
|
||||
},
|
||||
"scroll": {
|
||||
"speed": 2,
|
||||
"acceleration": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V2 migrates the global TUI configuration automatically. On the first CLI or TUI startup, when `cli.json` does not already
|
||||
exist, it:
|
||||
|
||||
- Reads `~/.config/opencode/tui.json`.
|
||||
- Reads persisted TUI preferences from the legacy `kv.json` state file.
|
||||
- Converts supported settings to the native grouped format and writes `~/.config/opencode/cli.json`.
|
||||
- Leaves the V1 files unchanged so V1 can continue using them.
|
||||
|
||||
Migration runs only while `cli.json` is absent. Once that file exists, V2 treats it as the source of truth and does not
|
||||
continually synchronize later changes from `tui.json` or `kv.json`. If you created `cli.json` before starting V2, merge any
|
||||
V1 settings you still need into it manually.
|
||||
|
||||
Project-local V1 TUI configuration is not migrated because V2 has no project-local CLI configuration. Move settings you
|
||||
still want into the global `cli.json`; when multiple projects used different values for the same setting, choose the
|
||||
global behavior you want V2 to use.
|
||||
|
||||
## Plugins
|
||||
|
||||
Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
|
||||
|
||||
@@ -303,7 +303,13 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
input: 272_000,
|
||||
output: 128_000,
|
||||
}
|
||||
: model.limit,
|
||||
: model.id.includes("gpt-5.6")
|
||||
? {
|
||||
context: 500_000,
|
||||
input: 372_000,
|
||||
output: 128_000,
|
||||
}
|
||||
: model.limit,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -149,6 +149,30 @@ describe("plugin.codex", () => {
|
||||
await enabled.dispose?.()
|
||||
})
|
||||
|
||||
test("uses Codex context limits for OAuth GPT models", async () => {
|
||||
const hooks = await CodexAuthPlugin({} as never)
|
||||
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
const provider = {
|
||||
models: Object.fromEntries(
|
||||
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [
|
||||
id,
|
||||
{ id, api: { id }, limit, cost: {} },
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
|
||||
|
||||
expect(models["gpt-5.4"]?.limit).toEqual(limit)
|
||||
expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
|
||||
provider.models as never,
|
||||
)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||
let auth = {
|
||||
type: "oauth" as const,
|
||||
|
||||
@@ -161,10 +161,10 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).toContain("claude-sonnet-4-6")
|
||||
expect(models.length).toBe(1)
|
||||
}),
|
||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-20250514"] } } } },
|
||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -303,10 +303,10 @@ it.instance("getModel returns model for valid provider/model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-6")
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(language).toBeDefined()
|
||||
}),
|
||||
@@ -437,7 +437,7 @@ it.instance(
|
||||
"model options are merged from existing model",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
}),
|
||||
{
|
||||
@@ -445,7 +445,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: { apiKey: "test-api-key" },
|
||||
models: { "claude-sonnet-4-20250514": { options: { customOption: "custom-value" } } },
|
||||
models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -551,7 +551,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.name).toBe("Custom Name for Sonnet")
|
||||
expect(model.capabilities.toolcall).toBe(true)
|
||||
expect(model.capabilities.attachment).toBe(true)
|
||||
@@ -559,7 +559,7 @@ it.instance(
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { name: "Custom Name for Sonnet" } } } },
|
||||
provider: { anthropic: { models: { "claude-sonnet-4-6": { name: "Custom Name for Sonnet" } } } },
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -592,16 +592,16 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).not.toContain("claude-opus-4-20250514")
|
||||
expect(models).toContain("claude-sonnet-4-6")
|
||||
expect(models).not.toContain("claude-opus-4-6")
|
||||
expect(models.length).toBe(1)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
|
||||
blacklist: ["claude-opus-4-20250514"],
|
||||
whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"],
|
||||
blacklist: ["claude-opus-4-6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -775,9 +775,9 @@ it.instance(
|
||||
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model?.providerID)).toBe("anthropic")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-6")
|
||||
}),
|
||||
{ config: { small_model: "anthropic/claude-sonnet-4-20250514" } },
|
||||
{ config: { small_model: "anthropic/claude-sonnet-4-6" } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1096,8 +1096,8 @@ it.instance(
|
||||
it.instance("getModel returns consistent results", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
expect(model1.providerID).toEqual(model2.providerID)
|
||||
expect(model1.id).toEqual(model2.id)
|
||||
expect(model1).toEqual(model2)
|
||||
@@ -1459,7 +1459,7 @@ it.instance("model variants are generated for reasoning models", () =>
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
// Claude sonnet 4 has reasoning capability
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.capabilities.reasoning).toBe(true)
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
|
||||
@@ -1471,7 +1471,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
// max variant should still exist
|
||||
@@ -1481,7 +1481,7 @@ it.instance(
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: { "claude-sonnet-4-20250514": { variants: { high: { disabled: true } } } },
|
||||
models: { "claude-sonnet-4-6": { variants: { high: { disabled: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1493,7 +1493,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
||||
}),
|
||||
@@ -1502,7 +1502,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
|
||||
},
|
||||
},
|
||||
@@ -1517,7 +1517,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["max"]).toBeDefined()
|
||||
expect(model.variants!["max"].disabled).toBeUndefined()
|
||||
expect(model.variants!["max"].customField).toBe("test")
|
||||
@@ -1527,7 +1527,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: { max: { disabled: false, customField: "test" } },
|
||||
},
|
||||
},
|
||||
@@ -1542,7 +1542,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBe(0)
|
||||
}),
|
||||
@@ -1551,8 +1551,13 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: { high: { disabled: true }, max: { disabled: true } },
|
||||
"claude-sonnet-4-6": {
|
||||
variants: {
|
||||
low: { disabled: true },
|
||||
medium: { disabled: true },
|
||||
high: { disabled: true },
|
||||
max: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1566,7 +1571,7 @@ it.instance(
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
// Should have both the generated thinking config and the custom option
|
||||
expect(model.variants!["high"].thinking).toBeDefined()
|
||||
@@ -1577,7 +1582,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
|
||||
"claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+165932
-110539
File diff suppressed because it is too large
Load Diff
@@ -407,13 +407,40 @@ type TuiAttentionConfigView = {
|
||||
sounds: Partial<Record<TuiAttentionSoundName, string>>
|
||||
}
|
||||
|
||||
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
|
||||
NonNullable<PluginConfig["tui"]> & {
|
||||
leader_timeout: number
|
||||
attention: TuiAttentionConfigView
|
||||
plugin_enabled?: Record<string, boolean>
|
||||
keybinds: TuiBindingLookupView
|
||||
type TuiConfigView = {
|
||||
$schema?: string
|
||||
theme?: string | { name?: string; mode?: "system" | "dark" | "light" }
|
||||
plugin?: PluginConfig["plugin"]
|
||||
plugins?: ReadonlyArray<string | { package: string; options?: Record<string, any> }>
|
||||
plugin_enabled?: Record<string, boolean>
|
||||
leader?: { timeout: number }
|
||||
leader_timeout?: number
|
||||
scroll?: { speed?: number; acceleration?: boolean }
|
||||
scroll_speed?: number
|
||||
scroll_acceleration?: { enabled: boolean }
|
||||
attention: TuiAttentionConfigView
|
||||
diffs?: {
|
||||
wrap?: "word" | "none"
|
||||
tree?: boolean
|
||||
single?: boolean
|
||||
view?: "auto" | "split" | "unified"
|
||||
}
|
||||
diff_style?: "auto" | "stacked"
|
||||
terminal?: { title?: boolean }
|
||||
prompt?:
|
||||
| { editor?: boolean; paste?: "compact" | "full" }
|
||||
| { max_height?: number; max_width?: number | "auto" }
|
||||
session?: {
|
||||
sidebar?: "auto" | "hide"
|
||||
scrollbar?: boolean
|
||||
thinking?: "show" | "hide"
|
||||
grouping?: "auto" | "none"
|
||||
}
|
||||
hints?: { tips?: boolean; onboarding?: boolean }
|
||||
animations?: boolean
|
||||
mouse: boolean
|
||||
keybinds: TuiBindingLookupView
|
||||
}
|
||||
|
||||
export type TuiApp = {
|
||||
readonly version: string
|
||||
|
||||
@@ -272,7 +272,8 @@ export interface ToolExecuteAfterEvent {
|
||||
|
||||
export interface RegisterOptions {
|
||||
readonly group?: string
|
||||
readonly deferred?: boolean
|
||||
/** Defaults to true. False exposes the tool directly to the provider. */
|
||||
readonly codemode?: boolean
|
||||
}
|
||||
|
||||
export interface ToolDraft {
|
||||
|
||||
@@ -69,7 +69,8 @@ export interface ToolExecuteAfterEvent {
|
||||
|
||||
export interface RegisterOptions {
|
||||
readonly group?: string
|
||||
readonly deferred?: boolean
|
||||
/** Defaults to true. False exposes the tool directly to the provider. */
|
||||
readonly codemode?: boolean
|
||||
}
|
||||
|
||||
export interface ToolDraft {
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
export const OutputInput = Schema.Struct({
|
||||
cursor: optional(NonNegativeInt),
|
||||
limit: optional(NonNegativeInt),
|
||||
keep: optional(Schema.Literals(["head", "tail"])),
|
||||
})
|
||||
export interface OutputInput extends Schema.Schema.Type<typeof OutputInput> {}
|
||||
|
||||
|
||||
@@ -57,11 +57,7 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
|
||||
Effect.fn(function* (ctx) {
|
||||
const shell = yield* Shell.Service
|
||||
return yield* response(
|
||||
shell.output(ctx.params.id, {
|
||||
cursor: ctx.query.cursor,
|
||||
limit: ctx.query.limit,
|
||||
keep: ctx.query.keep,
|
||||
}).pipe(
|
||||
shell.output(ctx.params.id, { cursor: ctx.query.cursor, limit: ctx.query.limit }).pipe(
|
||||
Effect.catchTag(
|
||||
"Shell.NotFoundError",
|
||||
() => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||
"./config": "./src/config/index.tsx",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
"./config/v1": "./src/config/v1/index.tsx",
|
||||
"./config/v1/keybind": "./src/config/v1/keybind.ts",
|
||||
"./config/v2": "./src/config/v2/index.ts",
|
||||
"./config/v2/keybind": "./src/config/v2/keybind.ts",
|
||||
"./context/args": "./src/context/args.tsx",
|
||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||
"./context/exit": "./src/context/exit.tsx",
|
||||
|
||||
+96
-47
@@ -53,6 +53,7 @@ import { DialogModel } from "./component/dialog-model"
|
||||
import { useConnected } from "./component/use-connected"
|
||||
import { DialogMcp } from "./component/dialog-mcp"
|
||||
import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -75,7 +76,8 @@ import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { TuiConfigV1 } from "./config/v1"
|
||||
import { createTuiApiAdapters } from "./plugin/adapters"
|
||||
import { createTuiApi } from "./plugin/api"
|
||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
||||
@@ -144,7 +146,6 @@ const appBindingCommands = [
|
||||
"app.toggle.file_context",
|
||||
"app.toggle.diffwrap",
|
||||
"app.toggle.paste_summary",
|
||||
"app.toggle.session_directory_filter",
|
||||
] as const
|
||||
|
||||
export type TuiInput = {
|
||||
@@ -154,7 +155,7 @@ export type TuiInput = {
|
||||
reload?: () => Promise<void>
|
||||
}
|
||||
args: Args
|
||||
config: TuiConfig.Resolved
|
||||
config: Config.Interface | TuiConfigV1.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
pluginHost: TuiPluginHost
|
||||
terminalHandoff?: () => Promise<
|
||||
@@ -200,9 +201,45 @@ function isVersionGreater(left: string, right: string) {
|
||||
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
|
||||
}
|
||||
|
||||
function fromV1(config: TuiConfigV1.Resolved): Config.Info {
|
||||
return {
|
||||
theme: config.theme ? { name: config.theme } : undefined,
|
||||
plugins: config.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
leader: { timeout: config.leader_timeout },
|
||||
scroll:
|
||||
config.scroll_speed === undefined && config.scroll_acceleration === undefined
|
||||
? undefined
|
||||
: { speed: config.scroll_speed, acceleration: config.scroll_acceleration?.enabled },
|
||||
attention: config.attention,
|
||||
diffs: config.diff_style === undefined ? undefined : { view: config.diff_style === "stacked" ? "unified" : "auto" },
|
||||
mouse: config.mouse,
|
||||
}
|
||||
}
|
||||
|
||||
function isConfigInterface(config: Config.Interface | TuiConfigV1.Resolved): config is Config.Interface {
|
||||
return (
|
||||
"get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
|
||||
)
|
||||
}
|
||||
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const log = input.log ?? (() => {})
|
||||
const global = yield* Global.Service
|
||||
const configInput = input.config
|
||||
const loaded = yield* Effect.gen(function* () {
|
||||
if (isConfigInterface(configInput)) {
|
||||
return {
|
||||
service: configInput,
|
||||
info: yield* Effect.tryPromise(() => configInput.get()),
|
||||
legacy: undefined,
|
||||
}
|
||||
}
|
||||
return { service: undefined, info: fromV1(configInput), legacy: configInput }
|
||||
})
|
||||
const config = Config.resolve(loaded.info, { terminalSuspend: process.platform !== "win32" })
|
||||
if (loaded.legacy) config.keybinds = loaded.legacy.keybinds
|
||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
@@ -235,7 +272,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
|
||||
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
@@ -263,7 +300,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
win32DisableProcessedInput()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
|
||||
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)),
|
||||
(unregister) => Effect.sync(unregister),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -337,19 +374,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<ClipboardProvider>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={loaded.service}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
client={createOpencodeClient({ ...options, directory })}
|
||||
@@ -373,6 +414,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
pluginConfig={loaded.legacy ?? config}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
@@ -397,10 +439,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
</PermissionProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ClipboardProvider>
|
||||
@@ -430,10 +472,16 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; pair?: DialogPairCredentials }) {
|
||||
function App(props: {
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
pluginHost: TuiPluginHost
|
||||
pluginConfig: any
|
||||
pair?: DialogPairCredentials
|
||||
}) {
|
||||
const log = useLog({ component: "app" })
|
||||
const startup = useTuiStartup()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const configContext = useConfig()
|
||||
const config = configContext.data
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -452,7 +500,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
|
||||
const attention = createTuiAttention({ renderer, config, kv })
|
||||
const clipboard = useClipboard()
|
||||
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
@@ -478,7 +526,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `MCP server failed: ${server.name}`,
|
||||
message: "Open MCPs to view details.",
|
||||
message: "Open MCP servers to view details.",
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -486,7 +534,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
const api = createTuiApi(
|
||||
createTuiApiAdapters({
|
||||
version: InstallationVersion,
|
||||
tuiConfig,
|
||||
tuiConfig: config,
|
||||
dialog,
|
||||
keymap,
|
||||
kv,
|
||||
@@ -507,7 +555,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
props.pluginHost
|
||||
.start({
|
||||
api,
|
||||
config: tuiConfig,
|
||||
config: props.pluginConfig,
|
||||
runtime: pluginRuntime,
|
||||
dispose: () => attention.dispose(),
|
||||
})
|
||||
@@ -543,10 +591,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", true),
|
||||
)
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
|
||||
})
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
@@ -741,7 +791,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
},
|
||||
{
|
||||
name: "mcp.list",
|
||||
title: "MCP Servers",
|
||||
title: "MCP servers",
|
||||
category: "Agent",
|
||||
slashName: "mcps",
|
||||
run: () => {
|
||||
@@ -805,6 +855,16 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
},
|
||||
category: "Integration",
|
||||
},
|
||||
{
|
||||
name: "opencode.settings",
|
||||
title: "Open settings",
|
||||
slashName: "settings",
|
||||
enabled: configContext.writable,
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogConfig />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
@@ -1004,17 +1064,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.session_directory_filter",
|
||||
title: kv.get("session_directory_filter_enabled", true)
|
||||
? "Disable session directory filtering"
|
||||
: "Enable session directory filtering",
|
||||
category: "System",
|
||||
run: async () => {
|
||||
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission.mode",
|
||||
title:
|
||||
@@ -1037,11 +1086,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
bindings: config.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
|
||||
bindings: config.keybinds.gather("app.global", appGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
@@ -1051,7 +1100,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
if (!current?.focused) return true
|
||||
return current.current.text === ""
|
||||
},
|
||||
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
||||
bindings: config.keybinds.gather("app_exit", ["app.exit"]),
|
||||
}))
|
||||
|
||||
event.on("tui.command.execute", (evt, { workspace }) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { AttentionSoundName, type TuiConfig } from "./config/v1"
|
||||
import { AttentionSoundName, type Config } from "./config"
|
||||
import { Schema } from "effect"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import * as TuiAudio from "./audio"
|
||||
@@ -80,7 +80,7 @@ function clampVolume(volume: number) {
|
||||
return Math.min(1, Math.max(0, volume))
|
||||
}
|
||||
|
||||
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
|
||||
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<Config.Resolved, "attention">) {
|
||||
if (!config.attention.sound) return
|
||||
if (input.sound === false) return
|
||||
if (input.sound === undefined) return clampVolume(config.attention.volume)
|
||||
@@ -113,7 +113,7 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<TuiConfig.Resolved, "attention">
|
||||
config: Pick<Config.Resolved, "attention">
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useKeymapSelector,
|
||||
useOpencodeKeymap,
|
||||
} from "../keymap"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||
|
||||
@@ -24,7 +24,7 @@ function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
|
||||
}
|
||||
|
||||
export function CommandPaletteDialog() {
|
||||
const config = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const keymap = useOpencodeKeymap()
|
||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
|
||||
const query = {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Setting = {
|
||||
title: string
|
||||
category: string
|
||||
description: string
|
||||
detail?: string
|
||||
path: string[]
|
||||
default: unknown
|
||||
values?: readonly unknown[]
|
||||
labels?: readonly string[]
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
format?: (value: unknown) => string
|
||||
}
|
||||
|
||||
const settings: Setting[] = [
|
||||
{
|
||||
title: "Theme",
|
||||
category: "Appearance",
|
||||
description: "Interface color theme",
|
||||
detail:
|
||||
"Choose the color theme used throughout OpenCode. Custom themes discovered from your config directory appear here alongside the built-in themes.",
|
||||
path: ["theme", "name"],
|
||||
default: "opencode",
|
||||
},
|
||||
{
|
||||
title: "Color mode",
|
||||
category: "Appearance",
|
||||
description: "Terminal color preference",
|
||||
detail:
|
||||
"Choose how OpenCode selects its colors. System follows your terminal preference, while dark and light keep the interface in a fixed mode.",
|
||||
path: ["theme", "mode"],
|
||||
default: "system",
|
||||
values: ["system", "dark", "light"],
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
category: "Appearance",
|
||||
description: "Interface motion",
|
||||
path: ["animations"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Tips",
|
||||
category: "Appearance",
|
||||
description: "Home screen hints",
|
||||
path: ["hints", "tips"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
description: "Getting-started guidance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
description: "Session sidebar visibility",
|
||||
path: ["session", "sidebar"],
|
||||
default: "auto",
|
||||
values: ["hide", "auto"],
|
||||
},
|
||||
{
|
||||
title: "Scrollbar",
|
||||
category: "Session",
|
||||
description: "Transcript scrollbar",
|
||||
path: ["session", "scrollbar"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Thinking",
|
||||
category: "Session",
|
||||
description: "Model reasoning by default",
|
||||
path: ["session", "thinking"],
|
||||
default: "hide",
|
||||
values: ["hide", "show"],
|
||||
},
|
||||
{
|
||||
title: "Grouping",
|
||||
category: "Session",
|
||||
description: "Related transcript items",
|
||||
path: ["session", "grouping"],
|
||||
default: "auto",
|
||||
values: ["none", "auto"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
description: "Diff presentation",
|
||||
path: ["diffs", "view"],
|
||||
default: "auto",
|
||||
values: ["auto", "split", "unified"],
|
||||
},
|
||||
{
|
||||
title: "Wrapping",
|
||||
category: "Diffs",
|
||||
description: "Long diff lines",
|
||||
path: ["diffs", "wrap"],
|
||||
default: "word",
|
||||
values: ["none", "word"],
|
||||
},
|
||||
{
|
||||
title: "File tree",
|
||||
category: "Diffs",
|
||||
description: "Diff file navigation",
|
||||
path: ["diffs", "tree"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Single patch",
|
||||
category: "Diffs",
|
||||
description: "Only the selected patch",
|
||||
path: ["diffs", "single"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Scroll speed",
|
||||
category: "Input",
|
||||
description: "Distance per input tick",
|
||||
path: ["scroll", "speed"],
|
||||
default: 3,
|
||||
step: 0.25,
|
||||
min: 0.25,
|
||||
max: 10,
|
||||
format: (value) => Number(value).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: "Acceleration",
|
||||
category: "Input",
|
||||
description: "Repeated scrolling",
|
||||
path: ["scroll", "acceleration"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Mouse",
|
||||
category: "Input",
|
||||
description: "Terminal mouse capture",
|
||||
path: ["mouse"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Editor context",
|
||||
category: "Input",
|
||||
description: "Active selection in prompts",
|
||||
path: ["prompt", "editor"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Large pastes",
|
||||
category: "Input",
|
||||
description: "Paste display style",
|
||||
path: ["prompt", "paste"],
|
||||
default: "compact",
|
||||
values: ["compact", "full"],
|
||||
},
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
description: "Wait after leader key",
|
||||
path: ["leader", "timeout"],
|
||||
default: 2000,
|
||||
step: 250,
|
||||
min: 250,
|
||||
max: 10000,
|
||||
format: (value) => `${value} ms`,
|
||||
},
|
||||
{
|
||||
title: "Attention",
|
||||
category: "Alerts",
|
||||
description: "Alerts when input is needed",
|
||||
path: ["attention", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
category: "Alerts",
|
||||
description: "System notifications",
|
||||
path: ["attention", "notifications"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Sounds",
|
||||
category: "Alerts",
|
||||
description: "Attention sounds",
|
||||
path: ["attention", "sound"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "Volume",
|
||||
category: "Alerts",
|
||||
description: "Attention sound level",
|
||||
path: ["attention", "volume"],
|
||||
default: 0.4,
|
||||
step: 0.1,
|
||||
min: 0,
|
||||
max: 1,
|
||||
format: (value) => `${Math.round(Number(value) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: "Window title",
|
||||
category: "Terminal",
|
||||
description: "Update terminal title",
|
||||
path: ["terminal", "title"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogConfig() {
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme } = themeState
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [selected, setSelected] = createSignal(settings[0])
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
onMount(() => {
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
const value = (setting: Setting) => {
|
||||
const current = setting.path.reduce<unknown>((result, key) => {
|
||||
if (!result || typeof result !== "object") return undefined
|
||||
return (result as Record<string, unknown>)[key]
|
||||
}, config.data)
|
||||
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
|
||||
return current ?? setting.default
|
||||
}
|
||||
const values = (setting: Setting) =>
|
||||
setting.path.join(".") === "theme.name"
|
||||
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
: setting.values
|
||||
const display = (setting: Setting) => {
|
||||
const current = value(setting)
|
||||
if (setting.format) return setting.format(current)
|
||||
const index = setting.values?.indexOf(current)
|
||||
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
|
||||
}
|
||||
const options = createMemo(() =>
|
||||
settings.map((setting) => ({
|
||||
title: setting.title,
|
||||
category: setting.category,
|
||||
value: setting,
|
||||
footer: selected() === setting ? `‹ ${display(setting)} ›` : ` ${display(setting)} `,
|
||||
})),
|
||||
)
|
||||
const split = createMemo(() => dimensions().width >= 110)
|
||||
const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
|
||||
|
||||
async function change(setting: Setting, direction: number) {
|
||||
if (saving()) return
|
||||
const current = value(setting)
|
||||
const choices = values(setting)
|
||||
const next = choices
|
||||
? choices[(choices.indexOf(current) + direction + choices.length) % choices.length]
|
||||
: Math.min(setting.max!, Math.max(setting.min!, Number(current) + direction * setting.step!))
|
||||
if (next === current) return
|
||||
setSaving(true)
|
||||
await config
|
||||
.update((draft) => {
|
||||
const parent = setting.path.slice(0, -1).reduce<Record<string, unknown>>((result, key) => {
|
||||
if (!result[key] || typeof result[key] !== "object") result[key] = {}
|
||||
return result[key] as Record<string, unknown>
|
||||
}, draft)
|
||||
parent[setting.path.at(-1)!] = next
|
||||
})
|
||||
.catch(toast.error)
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="row" height={height() + 1}>
|
||||
<box width={split() ? "54%" : "100%"}>
|
||||
<DialogSelect
|
||||
title="Settings"
|
||||
options={options()}
|
||||
renderFilter={false}
|
||||
hideClose={split()}
|
||||
maxHeight={height() - 2}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value, 1)}
|
||||
bindings={[
|
||||
{ key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(selected(), -1) },
|
||||
{ key: "right", desc: "Next value", group: "Settings", cmd: () => void change(selected(), 1) },
|
||||
]}
|
||||
/>
|
||||
</box>
|
||||
<Show when={split()}>
|
||||
<box
|
||||
position="relative"
|
||||
top={-1}
|
||||
width="46%"
|
||||
height={height() + 2}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{selected().title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{selected().detail ?? selected().description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
@@ -92,7 +92,7 @@ export function DialogMcp() {
|
||||
when={detail()}
|
||||
fallback={
|
||||
<DialogSelect
|
||||
title="MCPs"
|
||||
title="MCP servers"
|
||||
options={options()}
|
||||
current={focused()}
|
||||
preserveSelection
|
||||
@@ -118,7 +118,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error"
|
||||
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
@@ -152,7 +152,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
MCP / {props.server.name}
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
@@ -164,7 +164,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(tuiConfig)}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{error()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings, useCommandShortcut } from "../keymap"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
@@ -9,7 +9,7 @@ import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
let input: InputRenderable
|
||||
@@ -35,7 +35,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
|
||||
run: generate,
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"),
|
||||
bindings: config.keybinds.get("dialog.project_copy.generate"),
|
||||
}))
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -46,7 +46,6 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
title: skill.name.padEnd(maxWidth),
|
||||
description: skill.description?.replace(/\s+/g, " ").trim(),
|
||||
value: skill.id,
|
||||
category: "Skills",
|
||||
onSelect: () => {
|
||||
props.onSelect(skill.id)
|
||||
dialog.clear()
|
||||
@@ -57,7 +56,6 @@ export function DialogSkill(props: DialogSkillProps) {
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Skills"
|
||||
placeholder="Search skills..."
|
||||
options={options()}
|
||||
renderFilter={!showError()}
|
||||
locked={showError()}
|
||||
|
||||
@@ -22,9 +22,11 @@ export function DialogStatus() {
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
|
||||
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP servers</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{mcp().length} MCP Servers</text>
|
||||
<text fg={theme.text}>
|
||||
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
|
||||
</text>
|
||||
<For each={mcp()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
@@ -32,9 +32,9 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||
const fileNameWidth = createMemo(
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useSDK } from "../../context/sdk"
|
||||
import { useData } from "../../context/data"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useConfig } from "../../config"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
@@ -92,7 +92,7 @@ export function Autocomplete(props: {
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const [store, setStore] = createStore({
|
||||
@@ -628,7 +628,7 @@ export function Autocomplete(props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
|
||||
bindings: config.keybinds.gather("prompt.autocomplete", [
|
||||
"prompt.autocomplete.prev",
|
||||
"prompt.autocomplete.next",
|
||||
"prompt.autocomplete.hide",
|
||||
@@ -714,7 +714,7 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -48,7 +48,7 @@ import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
@@ -154,7 +154,7 @@ export function Prompt(props: PromptProps) {
|
||||
const project = useProject()
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
@@ -538,7 +538,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("prompt.palette", [
|
||||
bindings: config.keybinds.gather("prompt.palette", [
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
@@ -779,7 +779,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled,
|
||||
bindings: tuiConfig.keybinds.get("prompt.paste"),
|
||||
bindings: config.keybinds.get("prompt.paste"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -787,7 +787,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||
bindings: tuiConfig.keybinds.get("prompt.clear"),
|
||||
bindings: config.keybinds.get("prompt.clear"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -870,7 +870,7 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.get("prompt.history.previous"),
|
||||
bindings: config.keybinds.get("prompt.history.previous"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -910,7 +910,7 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.get("prompt.history.next"),
|
||||
bindings: config.keybinds.get("prompt.history.next"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1321,7 +1321,7 @@ export function Prompt(props: PromptProps) {
|
||||
}),
|
||||
}
|
||||
})
|
||||
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
|
||||
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
export * as Config from "."
|
||||
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { Schema } from "effect"
|
||||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Promise<Info>
|
||||
readonly update: (update: (draft: any) => void) => Promise<Info>
|
||||
}
|
||||
|
||||
export const AttentionSoundName = Schema.Literals([
|
||||
"default",
|
||||
"question",
|
||||
"permission",
|
||||
"error",
|
||||
"done",
|
||||
"subagent_done",
|
||||
])
|
||||
export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
|
||||
export type AttentionSoundPaths = Partial<Record<AttentionSoundName, string>>
|
||||
|
||||
export const Plugin = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
package: Schema.String.annotate({ description: "Plugin package name or path" }),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)).annotate({
|
||||
description: "Options passed to the plugin",
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
theme: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String).annotate({ description: "Theme name" }),
|
||||
mode: Schema.optional(Schema.Literals(["system", "dark", "light"])).annotate({
|
||||
description: "Color mode; 'system' follows the terminal",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Color theme settings" }),
|
||||
keybinds: Schema.optional(TuiKeybind.KeybindOverrides).annotate({ description: "Custom key bindings" }),
|
||||
plugins: Schema.optional(Schema.Array(Plugin)).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
leader: Schema.optional(
|
||||
Schema.Struct({
|
||||
timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
|
||||
description: "Time in milliseconds to wait for a key after the leader key",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Leader key behavior" }),
|
||||
scroll: Schema.optional(
|
||||
Schema.Struct({
|
||||
speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
|
||||
description: "Distance scrolled per input tick",
|
||||
}),
|
||||
acceleration: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Accelerate scrolling from repeated input",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Scrolling behavior" }),
|
||||
attention: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
|
||||
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
|
||||
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
|
||||
volume: Schema.optional(
|
||||
Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)),
|
||||
).annotate({ description: "Attention sound volume from 0 to 1" }),
|
||||
sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }),
|
||||
sounds: Schema.optional(
|
||||
Schema.Record(
|
||||
AttentionSoundName,
|
||||
Schema.optionalKey(Schema.String),
|
||||
),
|
||||
).annotate({ description: "Sound file overrides by attention event" }),
|
||||
}),
|
||||
).annotate({ description: "System notification and sound settings" }),
|
||||
diffs: Schema.optional(
|
||||
Schema.Struct({
|
||||
wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({
|
||||
description: "Line wrapping behavior in diff output",
|
||||
}),
|
||||
tree: Schema.optional(Schema.Boolean).annotate({ description: "Show the diff file tree" }),
|
||||
single: Schema.optional(Schema.Boolean).annotate({ description: "Show only the selected file patch" }),
|
||||
view: Schema.optional(Schema.Literals(["auto", "split", "unified"])).annotate({
|
||||
description: "Diff layout; 'auto' selects a layout from the available width",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Diff presentation settings" }),
|
||||
terminal: Schema.optional(
|
||||
Schema.Struct({
|
||||
title: Schema.optional(Schema.Boolean).annotate({ description: "Update the terminal window title" }),
|
||||
}),
|
||||
).annotate({ description: "Terminal integration settings" }),
|
||||
prompt: Schema.optional(
|
||||
Schema.Struct({
|
||||
editor: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Include the active editor file or selection as prompt context",
|
||||
}),
|
||||
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
||||
description: "Display large pastes as compact placeholders or full text",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Prompt input behavior" }),
|
||||
session: Schema.optional(
|
||||
Schema.Struct({
|
||||
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])).annotate({
|
||||
description: "Session sidebar visibility; 'auto' shows it when space permits",
|
||||
}),
|
||||
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide model reasoning by default",
|
||||
}),
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
Schema.Struct({
|
||||
tips: Schema.optional(Schema.Boolean).annotate({ description: "Show usage tips on the home screen" }),
|
||||
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
|
||||
}),
|
||||
).annotate({ description: "In-product guidance settings" }),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
|
||||
attention: {
|
||||
enabled: boolean
|
||||
notifications: boolean
|
||||
sound: boolean
|
||||
volume: number
|
||||
sound_pack: string
|
||||
sounds: AttentionSoundPaths
|
||||
}
|
||||
keybinds: TuiKeybind.BindingLookupView
|
||||
leader: { timeout: number }
|
||||
mouse: boolean
|
||||
}
|
||||
|
||||
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
|
||||
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
|
||||
if (!options.terminalSuspend) {
|
||||
keybinds.terminal_suspend = "none"
|
||||
if (keybinds.input_undo === undefined) {
|
||||
const inputUndo = TuiKeybind.defaultValue("input_undo")
|
||||
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
attention: {
|
||||
enabled: input.attention?.enabled ?? false,
|
||||
notifications: input.attention?.notifications ?? true,
|
||||
sound: input.attention?.sound ?? true,
|
||||
volume: input.attention?.volume ?? 0.4,
|
||||
sound_pack: input.attention?.sound_pack ?? "opencode.default",
|
||||
sounds: input.attention?.sounds ?? {},
|
||||
},
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader: { timeout: input.leader?.timeout ?? 2000 },
|
||||
mouse: input.mouse ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<{
|
||||
data: Resolved
|
||||
update: Interface["update"]
|
||||
writable: boolean
|
||||
}>()
|
||||
|
||||
export function ConfigProvider(props: {
|
||||
config: Resolved
|
||||
service?: Interface
|
||||
options?: { terminalSuspend: boolean }
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const [config, setConfig] = createStore(props.config)
|
||||
const host = props.service
|
||||
const update = async (update: (draft: any) => void) => {
|
||||
if (!host) throw new Error("Config updates are not available")
|
||||
const info = await host.update(update)
|
||||
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
|
||||
return info
|
||||
}
|
||||
return (
|
||||
<ConfigContext.Provider value={{ data: config, update, writable: !!host }}>{props.children}</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConfig() {
|
||||
const value = useContext(ConfigContext)
|
||||
if (!value) throw new Error("ConfigProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useConfigOptional() {
|
||||
return useContext(ConfigContext)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./v1/keybind"
|
||||
export * as TuiKeybind from "./v1/keybind"
|
||||
@@ -1,4 +1,5 @@
|
||||
export * as TuiConfig from "."
|
||||
export * as TuiConfigV1 from "."
|
||||
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -53,7 +53,6 @@ export const Definitions = {
|
||||
app_toggle_file_context: keybind("none", "Toggle file context"),
|
||||
app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
|
||||
app_toggle_paste_summary: keybind("none", "Toggle paste summary"),
|
||||
app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"),
|
||||
command_list: keybind("ctrl+p", "List available commands"),
|
||||
help_show: keybind("none", "Open help dialog"),
|
||||
docs_open: keybind("none", "Open documentation"),
|
||||
@@ -143,7 +142,6 @@ export const Definitions = {
|
||||
messages_copy: keybind("<leader>y", "Copy message"),
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
messages_redo: keybind("<leader>r", "Redo message"),
|
||||
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
@@ -257,7 +255,6 @@ export const CommandMap = {
|
||||
app_toggle_file_context: "app.toggle.file_context",
|
||||
app_toggle_diffwrap: "app.toggle.diffwrap",
|
||||
app_toggle_paste_summary: "app.toggle.paste_summary",
|
||||
app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
|
||||
command_list: "command.palette.show",
|
||||
help_show: "help.show",
|
||||
docs_open: "docs.open",
|
||||
@@ -343,7 +340,6 @@ export const CommandMap = {
|
||||
messages_copy: "messages.copy",
|
||||
messages_undo: "session.undo",
|
||||
messages_redo: "session.redo",
|
||||
messages_toggle_conceal: "session.toggle.conceal",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
export * as TuiConfigV2 from "."
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
export const Plugin = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
package: Schema.String,
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
theme: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
mode: Schema.optional(Schema.Literals(["system", "dark", "light"])),
|
||||
}),
|
||||
),
|
||||
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
|
||||
plugins: Schema.optional(Schema.Array(Plugin)),
|
||||
leader: Schema.optional(
|
||||
Schema.Struct({
|
||||
timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
),
|
||||
scroll: Schema.optional(
|
||||
Schema.Struct({
|
||||
speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))),
|
||||
acceleration: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
attention: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean),
|
||||
notifications: Schema.optional(Schema.Boolean),
|
||||
sound: Schema.optional(Schema.Boolean),
|
||||
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
|
||||
sound_pack: Schema.optional(Schema.String),
|
||||
sounds: Schema.optional(
|
||||
Schema.Record(
|
||||
Schema.Literals(["default", "question", "permission", "error", "done", "subagent_done"]),
|
||||
Schema.optionalKey(Schema.String),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
diffs: Schema.optional(
|
||||
Schema.Struct({
|
||||
wrap: Schema.optional(Schema.Literals(["word", "none"])),
|
||||
tree: Schema.optional(Schema.Boolean),
|
||||
single: Schema.optional(Schema.Boolean),
|
||||
view: Schema.optional(Schema.Literals(["auto", "split", "unified"])),
|
||||
}),
|
||||
),
|
||||
terminal: Schema.optional(
|
||||
Schema.Struct({
|
||||
title: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
composer: Schema.optional(
|
||||
Schema.Struct({
|
||||
file_context: Schema.optional(Schema.Boolean),
|
||||
paste_summary: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
session: Schema.optional(
|
||||
Schema.Struct({
|
||||
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])),
|
||||
scrollbar: Schema.optional(Schema.Boolean),
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])),
|
||||
group_exploration: Schema.optional(Schema.Boolean),
|
||||
directory_filter: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
which_key: Schema.optional(
|
||||
Schema.Struct({
|
||||
layout: Schema.optional(Schema.Literals(["dock", "overlay"])),
|
||||
pending_preview: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
hints: Schema.optional(
|
||||
Schema.Struct({
|
||||
tips: Schema.optional(Schema.Boolean),
|
||||
getting_started: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
updates: Schema.optional(
|
||||
Schema.Struct({
|
||||
skipped: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
animations: Schema.optional(Schema.Boolean),
|
||||
mouse: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -1,6 +0,0 @@
|
||||
export * as TuiKeybind from "./keybind"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const KeybindOverrides = Schema.Struct({})
|
||||
export type KeybindOverrides = Schema.Schema.Type<typeof KeybindOverrides>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, type Setter } from "solid-js"
|
||||
import { createEffect, createSignal, type Setter } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
@@ -6,10 +6,12 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import path from "path"
|
||||
import { useConfigOptional, type Config } from "../config"
|
||||
|
||||
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
name: "KV",
|
||||
init: () => {
|
||||
init: (props: { config?: Config.Info }) => {
|
||||
const config = props.config ?? useConfigOptional()?.data
|
||||
const paths = useTuiPaths()
|
||||
void Global.Path.state
|
||||
const file = path.join(paths.state, "kv.json")
|
||||
@@ -21,7 +23,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
|
||||
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
|
||||
.then((x) => {
|
||||
setStore(x)
|
||||
const values: Record<string, any> = { ...x }
|
||||
Object.entries(configValues(config ?? {})).forEach(([key, value]) => {
|
||||
if (value === undefined) delete values[key]
|
||||
else values[key] = value
|
||||
})
|
||||
setStore(values)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to read KV state", { error })
|
||||
@@ -30,6 +37,14 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !config) return
|
||||
Object.entries(configValues(config)).forEach(([key, value]) => {
|
||||
if (value === undefined) setStore(key, undefined)
|
||||
else setStore(key, value)
|
||||
})
|
||||
})
|
||||
|
||||
const result = {
|
||||
get ready() {
|
||||
return ready()
|
||||
@@ -64,3 +79,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
function configValues(config: Config.Info) {
|
||||
const values: Record<string, any> = {}
|
||||
if (config.theme?.name !== undefined) values.theme = config.theme.name
|
||||
if (config.theme?.mode !== undefined) {
|
||||
values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode
|
||||
values.theme_mode = undefined
|
||||
}
|
||||
if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack
|
||||
if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap
|
||||
if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree
|
||||
if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single
|
||||
if (config.diffs?.view !== undefined)
|
||||
values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view
|
||||
if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title
|
||||
if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor
|
||||
if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact"
|
||||
if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar
|
||||
if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar
|
||||
if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking
|
||||
if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto"
|
||||
if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips
|
||||
if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding
|
||||
if (config.animations !== undefined) values.animations_enabled = config.animations
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useKV } from "./kv"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { readFile } from "node:fs/promises"
|
||||
@@ -103,7 +103,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
|
||||
const renderer = useRenderer()
|
||||
const config = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const kv = useKV()
|
||||
const themes = props.source ?? themeSource
|
||||
const pick = (value: unknown) => {
|
||||
@@ -118,17 +118,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
|
||||
draft.mode = mode
|
||||
draft.lock = lock
|
||||
const active = config.theme ?? kv.get("theme", "opencode")
|
||||
const active = config.theme?.name ?? kv.get("theme", "opencode")
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme
|
||||
const theme = config.theme?.name
|
||||
if (theme) setStore("active", theme)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = config.theme?.mode
|
||||
if (mode === "dark" || mode === "light") {
|
||||
pin(mode)
|
||||
return
|
||||
}
|
||||
if (mode === "system" && store.lock !== undefined) free()
|
||||
})
|
||||
|
||||
function syncCustomThemes() {
|
||||
return themes
|
||||
.discover()
|
||||
|
||||
@@ -25,7 +25,6 @@ type Shortcuts = {
|
||||
messagesLast: TipShortcut
|
||||
messagesPageDown: TipShortcut
|
||||
messagesPageUp: TipShortcut
|
||||
messagesToggleConceal: TipShortcut
|
||||
modelCycleRecent: TipShortcut
|
||||
modelList: TipShortcut
|
||||
sessionExport: TipShortcut
|
||||
@@ -115,7 +114,6 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
messagesLast: configShortcut(props.api, "session.last"),
|
||||
messagesPageDown: configShortcut(props.api, "session.page.down"),
|
||||
messagesPageUp: configShortcut(props.api, "session.page.up"),
|
||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||
modelList: useCommandShortcut("model.list"),
|
||||
sessionExport: configShortcut(props.api, "session.export"),
|
||||
@@ -267,9 +265,8 @@ const TIPS: Tip[] = [
|
||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
|
||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
|
||||
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
|
||||
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
|
||||
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
|
||||
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth scrolling",
|
||||
"Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
|
||||
(shortcuts) =>
|
||||
shortcuts.commandList()
|
||||
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
|
||||
|
||||
@@ -144,7 +144,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
const defaultView = createMemo(() => {
|
||||
if (props.api.tuiConfig.diff_style === "stacked") return "unified"
|
||||
if (props.api.tuiConfig.diffs?.view === "unified") return "unified"
|
||||
if (props.api.tuiConfig.diffs?.view === "split") return "split"
|
||||
return splitAvailable() ? "split" : "unified"
|
||||
})
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@opentui/keymap/extras"
|
||||
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useTuiConfig } from "./config/v1"
|
||||
import { TuiKeybind } from "./config/v1/keybind"
|
||||
import { useConfig } from "./config"
|
||||
import { TuiKeybind } from "./config/keybind"
|
||||
|
||||
export const LEADER_TOKEN = "leader"
|
||||
export const OPENCODE_BASE_MODE = "base"
|
||||
@@ -42,7 +42,7 @@ type BindingLookup = {
|
||||
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
|
||||
}
|
||||
type FormatConfig = { keybinds: BindingLookup }
|
||||
type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
|
||||
type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
|
||||
|
||||
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
||||
|
||||
@@ -225,7 +225,7 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
|
||||
? registerTimedLeader(keymap, {
|
||||
trigger: leader,
|
||||
name: LEADER_TOKEN,
|
||||
timeoutMs: config.leader_timeout,
|
||||
timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout,
|
||||
})
|
||||
: () => {}
|
||||
const offEscape = registerEscapeClearsPendingSequence(keymap)
|
||||
@@ -252,7 +252,7 @@ export function useLeaderActive(): Accessor<boolean> {
|
||||
}
|
||||
|
||||
export function useCommandShortcut(command: string): Accessor<string> {
|
||||
const config = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
return useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiConfig } from "../config/v1"
|
||||
import type { Config } from "../config"
|
||||
import type { useEvent } from "../context/event"
|
||||
import type { useRoute } from "../context/route"
|
||||
import type { useSDK } from "../context/sdk"
|
||||
@@ -23,7 +23,7 @@ export { createPluginRoutes, createTuiApi } from "./api"
|
||||
|
||||
type Input = {
|
||||
version: string
|
||||
tuiConfig: TuiConfig.Resolved
|
||||
tuiConfig: Config.Resolved
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
keymap: ReturnType<typeof useOpencodeKeymap>
|
||||
kv: ReturnType<typeof useKV>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { TuiKeybind } from "../config/v1/keybind"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import type { DialogContext } from "../ui/dialog"
|
||||
|
||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
TuiPluginInstallResult,
|
||||
TuiPluginStatus,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import type { TuiConfig } from "../config/v1"
|
||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { createPluginRoutes } from "./api"
|
||||
import { createSlots, type HostSlots } from "./slots"
|
||||
@@ -61,7 +60,7 @@ export type PluginRuntime = ReturnType<typeof createPluginRuntime>
|
||||
export type TuiPluginHost = {
|
||||
start(input: {
|
||||
api: TuiPluginApi
|
||||
config: TuiConfig.Resolved
|
||||
config: any
|
||||
runtime: PluginRuntime
|
||||
dispose?: () => void
|
||||
}): Promise<void>
|
||||
|
||||
@@ -8,8 +8,6 @@ import { usePromptRef } from "../context/prompt"
|
||||
import { useLocal } from "../context/local"
|
||||
import { usePluginRuntime } from "../plugin/runtime"
|
||||
import { useEditorContext } from "../context/editor"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
||||
import { useData } from "../context/data"
|
||||
import { LocationProvider } from "../context/location"
|
||||
@@ -29,16 +27,9 @@ export function Home() {
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
const editor = useEditorContext()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const data = useData()
|
||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
||||
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
|
||||
const promptMaxWidth = createMemo(() => {
|
||||
const configured = tuiConfig.prompt?.max_width
|
||||
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
|
||||
return configured ?? 75
|
||||
})
|
||||
let sent = false
|
||||
|
||||
onMount(() => {
|
||||
@@ -83,7 +74,7 @@ export function Home() {
|
||||
</pluginRuntime.Slot>
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
|
||||
<Prompt
|
||||
ref={bind}
|
||||
|
||||
@@ -6,8 +6,13 @@ import { useToast } from "../../ui/toast"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
|
||||
export function DialogMessage(props: { messageID: string; sessionID: string }) {
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
sessionID: string
|
||||
setPrompt?: (prompt: PromptInfo) => void
|
||||
}) {
|
||||
const data = useData()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
@@ -22,9 +27,26 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) {
|
||||
title: "Revert",
|
||||
value: "session.revert",
|
||||
description: "undo messages and file changes",
|
||||
onSelect: async (dialog) => {
|
||||
await sdk.api.session
|
||||
.revert.stage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
onSelect: (dialog) => {
|
||||
const value = message()
|
||||
if (value?.type === "user") {
|
||||
props.setPrompt?.({
|
||||
text: value.text,
|
||||
files: value.files?.map((file) => ({
|
||||
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention ? { ...file.mention } : undefined,
|
||||
})),
|
||||
agents: value.agents?.map((agent) => ({
|
||||
name: agent.name,
|
||||
mention: agent.mention ? { ...agent.mention } : undefined,
|
||||
})),
|
||||
pasted: [],
|
||||
})
|
||||
}
|
||||
void sdk.api.session.revert
|
||||
.stage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSDK } from "../../context/sdk"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useConfig } from "../../config"
|
||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
@@ -149,7 +149,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const modeStack = useOpencodeModeStack()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
@@ -590,7 +590,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("editing", false)
|
||||
},
|
||||
},
|
||||
...tuiConfig.keybinds.get("prompt.clear"),
|
||||
...config.keybinds.get("prompt.clear"),
|
||||
{
|
||||
key: "tab",
|
||||
desc: "Next field",
|
||||
@@ -691,7 +691,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
},
|
||||
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
|
||||
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
...config.keybinds.get("app.exit"),
|
||||
]
|
||||
: confirm()
|
||||
? [
|
||||
@@ -711,7 +711,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
{ key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
||||
{ key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
|
||||
{ key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
...config.keybinds.get("app.exit"),
|
||||
]
|
||||
: [
|
||||
...Array.from({ length: max }, (_, index) => ({
|
||||
@@ -754,7 +754,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
group: "Form",
|
||||
cmd: cancel,
|
||||
},
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
...config.keybinds.get("app.exit"),
|
||||
]),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ import { FormPrompt } from "./form"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { DialogExportResult } from "../../ui/dialog-export-result"
|
||||
import { sessionEpilogue } from "../../util/presentation"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
@@ -88,7 +88,6 @@ const sessionBindingCommands = [
|
||||
"session.undo",
|
||||
"session.redo",
|
||||
"session.sidebar.toggle",
|
||||
"session.toggle.conceal",
|
||||
"session.toggle.thinking",
|
||||
"session.toggle.scrollbar",
|
||||
"session.toggle.exploration_grouping",
|
||||
@@ -121,13 +120,12 @@ const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"]
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
conceal: () => boolean
|
||||
thinkingMode: () => ThinkingMode
|
||||
showThinking: () => boolean
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
tui: ReturnType<typeof useTuiConfig>
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -149,7 +147,7 @@ export function Session() {
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const paths = useTuiPaths()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
@@ -198,7 +196,6 @@ export function Session() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => true)
|
||||
@@ -217,7 +214,7 @@ export function Session() {
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const models = createMemo(() => data.location.model.list(location()) ?? [])
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
const editor = useEditorContext()
|
||||
@@ -471,15 +468,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
category: "Session",
|
||||
run: () => {
|
||||
setConceal((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
@@ -507,7 +495,7 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools",
|
||||
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
|
||||
value: "session.toggle.exploration_grouping",
|
||||
category: "Session",
|
||||
run: () => {
|
||||
@@ -835,17 +823,17 @@ export function Session() {
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
|
||||
bindings: config.keybinds.gather("session.global", sessionGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => renderer.currentFocusedEditor === null,
|
||||
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
|
||||
bindings: config.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||
bindings: config.keybinds.gather("session", sessionBindingCommands),
|
||||
}))
|
||||
|
||||
// snap to bottom when session changes
|
||||
@@ -865,13 +853,12 @@ export function Session() {
|
||||
return contentWidth()
|
||||
},
|
||||
sessionID: route.sessionID,
|
||||
conceal,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
groupExploration,
|
||||
diffWrapMode,
|
||||
models,
|
||||
tui: tuiConfig,
|
||||
config,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
@@ -1244,21 +1231,27 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
}
|
||||
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const { theme } = useTheme()
|
||||
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
||||
const completion = () => metadata()?.source === "subagent"
|
||||
const source = () => stringValue(metadata()?.source)
|
||||
const completion = () => source() === "subagent" || source() === "shell"
|
||||
const state = () => stringValue(metadata()?.state)
|
||||
const agent = () => Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent")
|
||||
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||
const text = () => {
|
||||
if (props.message.type === "system") return props.message.text
|
||||
if (props.message.type === "synthetic") return props.message.description ?? ""
|
||||
return ""
|
||||
}
|
||||
const description = () => (source() === "shell" ? text().replace(/\s+/g, " ").trim() : text())
|
||||
const status = () => {
|
||||
if (state() === "completed") return "finished"
|
||||
if (state() === "error") return "failed"
|
||||
return state() ?? "finished"
|
||||
}
|
||||
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||
const suffix = () =>
|
||||
Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - Bun.stringWidth(heading())))
|
||||
const color = () => {
|
||||
if (state() === "error") return theme.error
|
||||
if (state() === "cancelled") return theme.warning
|
||||
@@ -1274,11 +1267,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
}
|
||||
>
|
||||
<box marginLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: color() }}>
|
||||
{state() === "completed" ? "↳" : "!"} {agent()} {status()}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {text()}</span>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: theme.textMuted }}>{suffix()}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -1329,7 +1320,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
internalBlockMode="top-level"
|
||||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -1477,6 +1468,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const promptRef = usePromptRef()
|
||||
|
||||
return (
|
||||
<Show when={props.message.text.trim() || files().length}>
|
||||
@@ -1495,7 +1487,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -1747,7 +1745,7 @@ function ReasoningPart(props: {
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={summary().body}
|
||||
conceal={ctx.conceal()}
|
||||
conceal={false}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
@@ -1813,7 +1811,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -2465,8 +2463,9 @@ function Edit(props: ToolProps) {
|
||||
const pathFormatter = usePathFormatter()
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.tui.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
const diffView = ctx.config.diffs?.view
|
||||
if (diffView === "unified") return "unified"
|
||||
if (diffView === "split") return "split"
|
||||
// Default to "auto" behavior
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
@@ -2541,7 +2540,8 @@ function ApplyPatch(props: ToolProps) {
|
||||
})
|
||||
})
|
||||
const view = createMemo(() => {
|
||||
if (ctx.tui.diff_style === "stacked") return "unified"
|
||||
if (ctx.config.diffs?.view === "unified") return "unified"
|
||||
if (ctx.config.diffs?.view === "split") return "split"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { filetype } from "../../util/filetype"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useConfig } from "../../config"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
|
||||
@@ -22,7 +22,7 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
||||
const themeState = useTheme()
|
||||
const theme = themeState.theme
|
||||
const syntax = themeState.syntax
|
||||
const config = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const filepath = createMemo(() => {
|
||||
@@ -34,8 +34,9 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
|
||||
})
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = config.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
const diffView = config.diffs?.view
|
||||
if (diffView === "unified") return "unified"
|
||||
if (diffView === "split") return "split"
|
||||
return dimensions().width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -469,7 +470,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
||||
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
|
||||
let input: TextareaRenderable
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
useBindings(() => ({
|
||||
@@ -486,7 +487,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
],
|
||||
bindings: [
|
||||
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
...config.keybinds.get("app.exit"),
|
||||
{
|
||||
key: "return",
|
||||
desc: "Confirm permission rejection",
|
||||
@@ -557,7 +558,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
@@ -646,8 +647,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []),
|
||||
...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []),
|
||||
...(props.escapeKey ? config.keybinds.get("app.exit") : []),
|
||||
...(props.fullscreen ? config.keybinds.get("permission.prompt.fullscreen") : []),
|
||||
],
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useConfig } from "../../config"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
|
||||
@@ -11,9 +11,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const data = useData()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
return (
|
||||
<Show when={session()}>
|
||||
|
||||
@@ -914,12 +914,6 @@ function getSyntaxRules(theme: Theme) {
|
||||
foreground: theme.text,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["conceal"],
|
||||
style: {
|
||||
foreground: theme.textMuted,
|
||||
},
|
||||
},
|
||||
// Additional common highlight groups
|
||||
{
|
||||
scope: ["string.special", "string.special.url"],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Spinner } from "../component/spinner"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
import { useBindings, useCommandShortcut } from "../keymap"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
@@ -20,7 +20,7 @@ export type DialogPromptProps = {
|
||||
export function DialogPrompt(props: DialogPromptProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const config = useConfig().data
|
||||
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
|
||||
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||
let textarea: TextareaRenderable
|
||||
@@ -43,7 +43,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
run: confirm,
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
|
||||
bindings: config.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
|
||||
}))
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isDeepEqual } from "remeda"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { Locale } from "../util/locale"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useConfig } from "../config"
|
||||
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
||||
|
||||
export interface DialogSelectProps<T> {
|
||||
@@ -51,6 +51,8 @@ export interface DialogSelectProps<T> {
|
||||
}[]
|
||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||
current?: T
|
||||
hideClose?: boolean
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export interface DialogSelectOption<T = any> {
|
||||
@@ -86,8 +88,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const config = useConfig().data
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
@@ -130,7 +132,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const labels = new Map<string, string>()
|
||||
|
||||
for (const action of shownActions()) {
|
||||
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
|
||||
const label = formatKeyBindings(actionBindings().get(action.command), config)
|
||||
if (label) labels.set(action.command, label)
|
||||
}
|
||||
|
||||
@@ -212,7 +214,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
|
||||
const height = createMemo(() => Math.min(rows(), props.maxHeight ?? Math.floor(dimensions().height / 2) - 6))
|
||||
|
||||
const selected = createMemo(() => flat()[store.selected])
|
||||
|
||||
@@ -450,7 +452,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
})),
|
||||
],
|
||||
bindings: [
|
||||
...tuiConfig.keybinds.gather("dialog.select", [
|
||||
...config.keybinds.gather("dialog.select", [
|
||||
"dialog.select.prev",
|
||||
"dialog.select.next",
|
||||
"dialog.select.page_up",
|
||||
@@ -459,7 +461,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
"dialog.select.end",
|
||||
"dialog.select.submit",
|
||||
]),
|
||||
...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
|
||||
...visible.flatMap((item) => config.keybinds.get(item.command)),
|
||||
...(visible.length
|
||||
? [
|
||||
{
|
||||
@@ -565,9 +567,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
{props.title}
|
||||
</text>
|
||||
)}
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
<Show when={!props.hideClose}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={props.renderFilter !== false}>
|
||||
<box paddingTop={1}>
|
||||
@@ -789,7 +793,11 @@ function Option(props: {
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
|
||||
{typeof props.footer === "string" ? (
|
||||
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
|
||||
) : (
|
||||
props.footer
|
||||
)}
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user