Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline d8b7a0a391 fix(core): preserve shell output tail
Shell truncation read from cursor zero, so large command output kept the head instead of the useful tail. Read the final capture window and cover the direction with head/tail markers.
2026-07-12 20:03:52 +00:00
112 changed files with 113470 additions and 169956 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@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.
-1
View File
@@ -115,7 +115,6 @@
"@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 -4
View File
@@ -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": "Manage MCP servers",
"command.mcp.toggle.description": "Enable or disable MCP servers",
"command.mcp.toggle": "Toggle MCPs",
"command.mcp.toggle.description": "Toggle MCPs",
"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": "MCP servers",
"dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCP servers configured",
"dialog.mcp.empty": "No MCPs configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
-1
View File
@@ -45,7 +45,6 @@
"@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 { Config } from "../../config"
import { TuiConfig } from "../../tui-config"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Updater } from "../../services/updater"
@@ -35,18 +35,13 @@ export default Runtime.handler(Commands, (input) =>
),
)
preflight.loading()
const config = yield* Config.Service
const config = yield* TuiConfig.load()
let disposeSlots: (() => void) | undefined
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
const runFork = Effect.runForkWith(yield* Effect.context())
yield* run({
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config: {
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
config,
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =
-109
View File
@@ -1,109 +0,0 @@
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 }]
}
-1
View File
@@ -1 +0,0 @@
export * as Config from "./config"
-142
View File
@@ -1,142 +0,0 @@
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))
})
-5
View File
@@ -1,5 +0,0 @@
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 -12
View File
@@ -3,7 +3,6 @@ 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>
@@ -14,26 +13,18 @@ export type Input<Value> =
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<
void,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (
input: Input<Node>,
) => Effect.Effect<
void,
any,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
}>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
-2
View File
@@ -11,7 +11,6 @@ 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"),
@@ -52,7 +51,6 @@ 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),
+24
View File
@@ -0,0 +1,24 @@
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" },
)
})
-125
View File
@@ -1,125 +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 { 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}`
}
})
+25
View File
@@ -0,0 +1,25 @@
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}`
}
})
+1
View File
@@ -809,6 +809,7 @@ 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,11 +971,12 @@ 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"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"], keep: input["keep"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint21_5Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
+6 -7
View File
@@ -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 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 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 [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
@@ -138,7 +138,6 @@ 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) {
@@ -162,7 +161,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, version: health.value.version } satisfies LocalService
return { info, endpoint } 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"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"], keep: input["keep"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
@@ -4623,17 +4623,26 @@ 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 = {
-39
View File
@@ -1,39 +0,0 @@
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)
-91
View File
@@ -1,91 +0,0 @@
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())
}
+1 -1
View File
@@ -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.
- 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.
## OpenAPI
+2 -2
View File
@@ -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. CodeMode does not limit
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
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.
## Boundaries and Non-Goals
+164
View File
@@ -0,0 +1,164 @@
# 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).
+2 -1
View File
@@ -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, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
## Values and literals
@@ -291,6 +291,7 @@ 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.
+14 -4
View File
@@ -1,4 +1,4 @@
import { Cause, Effect } from "effect"
import { Cause, Effect, Semaphore } 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 } from "../stdlib/promise.js"
import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js"
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
import { stringMethods, stringStatics } from "../stdlib/string.js"
import {
@@ -150,6 +150,7 @@ 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),
@@ -162,6 +163,7 @@ 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])
@@ -169,6 +171,7 @@ 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() })
@@ -236,7 +239,7 @@ export class Interpreter<R> {
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<SandboxPromise, never, R> {
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
return this.createPromise(this.callPermits.withPermit(Effect.suspend(() => this.invokeTool(path, args))))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
@@ -1493,7 +1496,14 @@ 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)
const invocation = new Interpreter(
this.invokeTool,
this.invokeSearch,
this.toolKeys,
this.promises,
this.logs,
this.callPermits,
)
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.
+2
View File
@@ -1,3 +1,5 @@
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
+3 -2
View File
@@ -560,7 +560,7 @@ describe("Promise.all over arbitrary arrays", () => {
expect(trace.maxActive).toBeGreaterThan(1)
})
test("does not cap live tool-call concurrency", async () => {
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
const trace = makeTrace()
const result = await value(
`
@@ -572,7 +572,8 @@ describe("Promise.all over arbitrary arrays", () => {
{ trace },
)
expect(result).toBe(20)
expect(trace.maxActive).toBe(20)
expect(trace.maxActive).toBeGreaterThan(1)
expect(trace.maxActive).toBeLessThanOrEqual(8)
})
test("resolves the empty array", async () => {
+1
View File
@@ -200,6 +200,7 @@ const layer = Layer.effect(
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
stop: location.project.directory,
})
.pipe(Effect.orDie)
+3
View File
@@ -52,6 +52,9 @@ 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"]
},
+13 -5
View File
@@ -1,6 +1,7 @@
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"
@@ -17,7 +18,11 @@ type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
...(Flag.CODEMODE_ENABLED
? [
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
]
: []),
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
@@ -76,7 +81,7 @@ export const layer = Layer.effect(
removed: () => "MCP server instructions are no longer available.",
},
})
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return source(Instructions.removed)
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
@@ -85,9 +90,12 @@ export const layer = Layer.effect(
const visible = instructions
.filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
return (
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
)
)
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
+3 -2
View File
@@ -139,8 +139,9 @@ 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 start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
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 buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
+1 -2
View File
@@ -29,8 +29,7 @@ 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 default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
Registrations are scoped:
-1
View File
@@ -212,7 +212,6 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+3 -3
View File
@@ -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(`CodeMode tool namespace conflict: ${path}`)
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred 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(`CodeMode tool namespace conflict: ${namespace}`)
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
-1
View File
@@ -106,7 +106,6 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -137,7 +137,6 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+2 -2
View File
@@ -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 }),
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
{
discard: true,
},
-1
View File
@@ -191,7 +191,6 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -106,7 +106,6 @@ export const Plugin = {
}),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -139,7 +139,6 @@ export const Plugin = {
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+8 -7
View File
@@ -3,6 +3,7 @@ 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"
@@ -55,7 +56,7 @@ const registryLayer = Layer.effect(
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly codemode: boolean
readonly deferred: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -131,8 +132,7 @@ const registryLayer = Layer.effect(
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
if (entries.length === 0) return
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
const reserved = options?.deferred ? 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,
codemode,
deferred: options?.deferred ?? false,
},
},
])
@@ -168,17 +168,18 @@ const registryLayer = Layer.effect(
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
const direct = new Map<string, Registration>()
const codemode = new Map<string, Registration>()
const deferred = 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.codemode) codemode.set(name, registration)
if (registration.deferred) deferred.set(name, registration)
else direct.set(name, registration)
}
const execute =
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
+2 -5
View File
@@ -132,8 +132,6 @@ 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 }),
@@ -203,7 +201,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 })
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES, keep: "tail" })
if (final.status === "timeout") {
return {
@@ -215,7 +213,7 @@ export const Plugin = {
}
}
const truncated = page.size > page.cursor
const truncated = page.size > MAX_CAPTURE_BYTES
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
@@ -278,7 +276,6 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -97,7 +97,6 @@ export const Plugin = {
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -208,7 +208,6 @@ export const Plugin = {
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -174,7 +174,6 @@ export const Plugin = {
}
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -252,7 +252,6 @@ export const Plugin = {
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
-1
View File
@@ -91,7 +91,6 @@ export const Plugin = {
}),
"edit",
),
{ codemode: false },
),
)
.pipe(Effect.orDie)
+1 -4
View File
@@ -876,7 +876,7 @@ describe("Config", () => {
),
)
it.live("loads global and ancestor configuration across the project boundary", () =>
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -936,7 +936,6 @@ describe("Config", () => {
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
"outside",
"root",
"parent",
"directory",
@@ -952,8 +951,6 @@ 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",
+4 -6
View File
@@ -260,7 +260,6 @@ describe("PluginV2", () => {
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie),
@@ -274,7 +273,7 @@ describe("PluginV2", () => {
}),
)
it.effect("groups tool names and routes codemode registrations through execute", () =>
it.effect("groups tool names and defers registrations from direct exposure", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
@@ -290,9 +289,9 @@ describe("PluginV2", () => {
effect: (ctx) =>
ctx.tool
.transform((draft) => {
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" })
draft.add("plain", tool("Plain"))
draft.add("look/up", tool("Lookup"), { group: "context 7" })
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
})
.pipe(Effect.orDie),
})
@@ -331,7 +330,6 @@ 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,7 +135,6 @@ 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 }, { codemode: false })
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
yield* service.register({ first: shared })
yield* service.register({ second: Tool.withPermission(shared, "edit") })
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() }, { codemode: false })
yield* service.register({ echo: make() })
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() }, { codemode: false }).pipe(Scope.provide(scope))
yield* service.register({ echo: make() }).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() }, { codemode: false })
.register({ echo: make() })
.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() }, { codemode: false })
yield* service.register({ echo: make() })
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() }, { codemode: false })
yield* service.register({ bounded: make() })
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") }, { codemode: false }).pipe(Scope.provide(scope))
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
const request = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: constant("replacement") }, { codemode: false })
yield* service.register({ echo: constant("replacement") })
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") }, { codemode: false })
yield* service.register({ echo: constant("base") })
const overlay = yield* Scope.make()
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
yield* Scope.close(overlay, Exit.void)
@@ -357,31 +357,37 @@ describe("ToolRegistry", () => {
}),
)
it.effect("executes codemode tools advertised in a model request", () =>
it.effect("executes deferred 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({
.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(
{
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 })),
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
}),
})
.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 })),
}),
})
},
{ deferred: true },
)
const settlement = yield* materialized.settle({
...call("execute"),
+9 -9
View File
@@ -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 }, { codemode: false })
yield* registry.register({ permissionfail: permissionFail })
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", {}), []]
+9 -19
View File
@@ -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, Stream } from "effect"
import { DateTime, Duration, Effect, Fiber, Layer, Scope } 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 overflowCommand = (bytes: number) =>
const overflowMarkersCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
? `[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`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
@@ -396,15 +396,18 @@ describe("ShellTool", () => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
settleTool(registry, call({ command: overflowMarkersCommand(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.stringContaining("output truncated; full output saved to:"),
text: expect.stringMatching(/tail-marker[\s\S]*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)
}),
),
)
@@ -443,12 +446,6 @@ 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
@@ -460,13 +457,6 @@ 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",
},
})
}),
)
},
+3 -4
View File
@@ -315,12 +315,11 @@ 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, codemode }`:
configure registration with `{ group, deferred }`:
- `group` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
- `deferred: true` makes the tool available through the deferred `execute`
tool instead of exposing it directly.
The executor receives a second context argument containing `sessionID`,
`agent`, `assistantMessageID`, and `toolCallID`.
+7 -64
View File
@@ -3,19 +3,14 @@ title: "Migrate from V1"
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
---
## Breaking changes
<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>
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.
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.
<Tip>
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
@@ -471,58 +466,6 @@ 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:
+1 -7
View File
@@ -303,13 +303,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
input: 272_000,
output: 128_000,
}
: model.id.includes("gpt-5.6")
? {
context: 500_000,
input: 372_000,
output: 128_000,
}
: model.limit,
: model.limit,
},
]),
)
@@ -149,30 +149,6 @@ 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-6")
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
}),
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } },
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-20250514"] } } } },
)
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-6"))
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-6")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
}),
{
@@ -445,7 +445,7 @@ it.instance(
provider: {
anthropic: {
options: { apiKey: "test-api-key" },
models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } },
models: { "claude-sonnet-4-20250514": { 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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
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-6": { name: "Custom Name for Sonnet" } } } },
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { 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-6")
expect(models).not.toContain("claude-opus-4-6")
expect(models).toContain("claude-sonnet-4-20250514")
expect(models).not.toContain("claude-opus-4-20250514")
expect(models.length).toBe(1)
}),
{
config: {
provider: {
anthropic: {
whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"],
blacklist: ["claude-opus-4-6"],
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
blacklist: ["claude-opus-4-20250514"],
},
},
},
@@ -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-6")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
}),
{ config: { small_model: "anthropic/claude-sonnet-4-6" } },
{ config: { small_model: "anthropic/claude-sonnet-4-20250514" } },
)
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-6"))
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
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"))
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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
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-6": { variants: { high: { disabled: true } } } },
models: { "claude-sonnet-4-20250514": { 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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
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-6": {
"claude-sonnet-4-20250514": {
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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
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-6": {
"claude-sonnet-4-20250514": {
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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
}),
@@ -1551,13 +1551,8 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-6": {
variants: {
low: { disabled: true },
medium: { disabled: true },
high: { disabled: true },
max: { disabled: true },
},
"claude-sonnet-4-20250514": {
variants: { high: { disabled: true }, max: { disabled: true } },
},
},
},
@@ -1571,7 +1566,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-6"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
@@ -1582,7 +1577,7 @@ it.instance(
provider: {
anthropic: {
models: {
"claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } },
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
},
},
},
File diff suppressed because it is too large Load Diff
+6 -33
View File
@@ -407,40 +407,13 @@ type TuiAttentionConfigView = {
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
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"
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
NonNullable<PluginConfig["tui"]> & {
leader_timeout: number
attention: TuiAttentionConfigView
plugin_enabled?: Record<string, boolean>
keybinds: TuiBindingLookupView
}
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
+1 -2
View File
@@ -272,8 +272,7 @@ export interface ToolExecuteAfterEvent {
export interface RegisterOptions {
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
readonly deferred?: boolean
}
export interface ToolDraft {
+1 -2
View File
@@ -69,8 +69,7 @@ export interface ToolExecuteAfterEvent {
export interface RegisterOptions {
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
readonly deferred?: boolean
}
export interface ToolDraft {
+1
View File
@@ -65,6 +65,7 @@ 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> {}
+5 -1
View File
@@ -57,7 +57,11 @@ 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 }).pipe(
shell.output(ctx.params.id, {
cursor: ctx.query.cursor,
limit: ctx.query.limit,
keep: ctx.query.keep,
}).pipe(
Effect.catchTag(
"Shell.NotFoundError",
() => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),
+2 -2
View File
@@ -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",
+47 -96
View File
@@ -53,7 +53,6 @@ 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"
@@ -76,8 +75,7 @@ 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 { Config, ConfigProvider, useConfig } from "./config"
import { TuiConfigV1 } from "./config/v1"
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
import { createTuiApiAdapters } from "./plugin/adapters"
import { createTuiApi } from "./plugin/api"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
@@ -146,6 +144,7 @@ const appBindingCommands = [
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
"app.toggle.session_directory_filter",
] as const
export type TuiInput = {
@@ -155,7 +154,7 @@ export type TuiInput = {
reload?: () => Promise<void>
}
args: Args
config: Config.Interface | TuiConfigV1.Resolved
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
terminalHandoff?: () => Promise<
@@ -201,45 +200,9 @@ 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(
@@ -272,7 +235,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
@@ -300,7 +263,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)),
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
(unregister) => Effect.sync(unregister),
)
yield* Effect.addFinalizer(() =>
@@ -374,23 +337,19 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={loaded.service}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={createOpencodeClient({ ...options, directory })}
@@ -414,7 +373,6 @@ 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
@@ -439,10 +397,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</PermissionProvider>
</SDKProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ConfigProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
@@ -472,16 +430,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: {
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
pluginConfig: any
pair?: DialogPairCredentials
}) {
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const configContext = useConfig()
const config = configContext.data
const tuiConfig = useTuiConfig()
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -500,7 +452,7 @@ function App(props: {
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config, kv })
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
const clipboard = useClipboard()
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
@@ -526,7 +478,7 @@ function App(props: {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCP servers to view details.",
message: "Open MCPs to view details.",
})
}
})
@@ -534,7 +486,7 @@ function App(props: {
const api = createTuiApi(
createTuiApiAdapters({
version: InstallationVersion,
tuiConfig: config,
tuiConfig,
dialog,
keymap,
kv,
@@ -555,7 +507,7 @@ function App(props: {
props.pluginHost
.start({
api,
config: props.pluginConfig,
config: tuiConfig,
runtime: pluginRuntime,
dispose: () => attention.dispose(),
})
@@ -591,12 +543,10 @@ function App(props: {
renderer.clearSelection()
}
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
})
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", true),
)
// Update terminal window title based on current route and session
createEffect(() => {
@@ -791,7 +741,7 @@ function App(props: {
},
{
name: "mcp.list",
title: "MCP servers",
title: "MCP Servers",
category: "Agent",
slashName: "mcps",
run: () => {
@@ -855,16 +805,6 @@ function App(props: {
},
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",
@@ -1064,6 +1004,17 @@ function App(props: {
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:
@@ -1086,11 +1037,11 @@ function App(props: {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: config.keybinds.gather("app", appBindingCommands),
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
bindings: config.keybinds.gather("app.global", appGlobalBindingCommands),
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
}))
useBindings(() => ({
@@ -1100,7 +1051,7 @@ function App(props: {
if (!current?.focused) return true
return current.current.text === ""
},
bindings: config.keybinds.gather("app_exit", ["app.exit"]),
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
}))
event.on("tui.command.execute", (evt, { workspace }) => {
+3 -3
View File
@@ -10,7 +10,7 @@ import type {
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui"
import { AttentionSoundName, type Config } from "./config"
import { AttentionSoundName, type TuiConfig } from "./config/v1"
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<Config.Resolved, "attention">) {
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.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<Config.Resolved, "attention">
config: Pick<TuiConfig.Resolved, "attention">
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
@@ -8,7 +8,7 @@ import {
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
@@ -24,7 +24,7 @@ function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
}
export function CommandPaletteDialog() {
const config = useConfig().data
const config = useTuiConfig()
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
@@ -1,354 +0,0 @@
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>
)
}
+5 -5
View File
@@ -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 { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
import { getScrollAcceleration } from "../util/scroll"
import { useBindings } from "../keymap"
@@ -92,7 +92,7 @@ export function DialogMcp() {
when={detail()}
fallback={
<DialogSelect
title="MCP servers"
title="MCPs"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
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 server: {props.server.name}
MCP / {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(config)}
scrollAcceleration={getScrollAcceleration(tuiConfig)}
>
<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 { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
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: config.keybinds.get("dialog.project_copy.generate"),
bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"),
}))
onMount(() => {
@@ -46,6 +46,7 @@ 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()
@@ -56,6 +57,7 @@ export function DialogSkill(props: DialogSkillProps) {
return (
<DialogSelect
title="Skills"
placeholder="Search skills..."
options={options()}
renderFilter={!showError()}
locked={showError()}
+2 -4
View File
@@ -22,11 +22,9 @@ 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 server{mcp().length === 1 ? "" : "s"}
</text>
<text fg={theme.text}>{mcp().length} MCP Servers</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 { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
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 { useConfig } from "../../config"
import { useTuiConfig } from "../../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
const paths = useTuiPaths()
const location = useLocation()
const [store, setStore] = createStore({
@@ -628,7 +628,7 @@ export function Autocomplete(props: {
},
},
],
bindings: config.keybinds.gather("prompt.autocomplete", [
bindings: tuiConfig.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(config))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<box
+8 -8
View File
@@ -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 { useConfig } from "../../config"
import { useTuiConfig } from "../../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
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: config.keybinds.gather("prompt.palette", [
bindings: tuiConfig.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: config.keybinds.get("prompt.paste"),
bindings: tuiConfig.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: config.keybinds.get("prompt.clear"),
bindings: tuiConfig.keybinds.get("prompt.clear"),
}
})
@@ -870,7 +870,7 @@ export function Prompt(props: PromptProps) {
},
},
],
bindings: config.keybinds.get("prompt.history.previous"),
bindings: tuiConfig.keybinds.get("prompt.history.previous"),
}
})
@@ -910,7 +910,7 @@ export function Prompt(props: PromptProps) {
},
},
],
bindings: config.keybinds.get("prompt.history.next"),
bindings: tuiConfig.keybinds.get("prompt.history.next"),
}
})
@@ -1321,7 +1321,7 @@ export function Prompt(props: PromptProps) {
}),
}
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
return (
-212
View File
@@ -1,212 +0,0 @@
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)
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./v1/keybind"
export * as TuiKeybind from "./v1/keybind"
-1
View File
@@ -1,5 +1,4 @@
export * as TuiConfig from "."
export * as TuiConfigV1 from "."
import { createBindingLookup } from "@opentui/keymap/extras"
import { Schema } from "effect"
+4
View File
@@ -53,6 +53,7 @@ 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"),
@@ -142,6 +143,7 @@ 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"),
@@ -255,6 +257,7 @@ 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",
@@ -340,6 +343,7 @@ 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",
+97
View File
@@ -0,0 +1,97 @@
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>
+6
View File
@@ -0,0 +1,6 @@
export * as TuiKeybind from "./keybind"
import { Schema } from "effect"
export const KeybindOverrides = Schema.Struct({})
export type KeybindOverrides = Schema.Schema.Type<typeof KeybindOverrides>
+3 -44
View File
@@ -1,4 +1,4 @@
import { createEffect, createSignal, type Setter } from "solid-js"
import { 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,12 +6,10 @@ 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: (props: { config?: Config.Info }) => {
const config = props.config ?? useConfigOptional()?.data
init: () => {
const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
@@ -23,12 +21,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((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)
setStore(x)
})
.catch((error) => {
console.error("Failed to read KV state", { error })
@@ -37,14 +30,6 @@ 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()
@@ -79,29 +64,3 @@ 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
}
+4 -13
View File
@@ -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 { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
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 = useConfig().data
const config = useTuiConfig()
const kv = useKV()
const themes = props.source ?? themeSource
const pick = (value: unknown) => {
@@ -118,26 +118,17 @@ 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?.name ?? kv.get("theme", "opencode")
const active = config.theme ?? kv.get("theme", "opencode")
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme?.name
const theme = config.theme
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,6 +25,7 @@ type Shortcuts = {
messagesLast: TipShortcut
messagesPageDown: TipShortcut
messagesPageUp: TipShortcut
messagesToggleConceal: TipShortcut
modelCycleRecent: TipShortcut
modelList: TipShortcut
sessionExport: TipShortcut
@@ -114,6 +115,7 @@ 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"),
@@ -265,8 +267,9 @@ 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}cli.json{/highlight} for smooth scrolling",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth scrolling",
(shortcuts) =>
shortcuts.commandList()
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
@@ -144,8 +144,7 @@ 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.diffs?.view === "unified") return "unified"
if (props.api.tuiConfig.diffs?.view === "split") return "split"
if (props.api.tuiConfig.diff_style === "stacked") return "unified"
return splitAvailable() ? "split" : "unified"
})
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
+5 -5
View File
@@ -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 { useConfig } from "./config"
import { TuiKeybind } from "./config/keybind"
import { useTuiConfig } from "./config/v1"
import { TuiKeybind } from "./config/v1/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 } } | { leader_timeout: number })
type ResolvedKeymapConfig = FormatConfig & { 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: "leader" in config ? config.leader.timeout : config.leader_timeout,
timeoutMs: 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 = useConfig().data
const config = useTuiConfig()
return useKeymapSelector((keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { Config } from "../config"
import type { TuiConfig } from "../config/v1"
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: Config.Resolved
tuiConfig: TuiConfig.Resolved
dialog: ReturnType<typeof useDialog>
keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV>
+1 -1
View File
@@ -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/keybind"
import { TuiKeybind } from "../config/v1/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"
+2 -1
View File
@@ -4,6 +4,7 @@ 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"
@@ -60,7 +61,7 @@ export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
config: any
config: TuiConfig.Resolved
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>
+10 -1
View File
@@ -8,6 +8,8 @@ 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"
@@ -27,9 +29,16 @@ 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(() => {
@@ -74,7 +83,7 @@ export function Home() {
</pluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}
@@ -6,13 +6,8 @@ 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
setPrompt?: (prompt: PromptInfo) => void
}) {
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
@@ -27,26 +22,9 @@ export function DialogMessage(props: {
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
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 })
onSelect: async (dialog) => {
await sdk.api.session
.revert.stage({ sessionID: props.sessionID, messageID: props.messageID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
},
+6 -6
View File
@@ -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 { useConfig } from "../../config"
import { useTuiConfig } from "../../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
const modeStack = useOpencodeModeStack()
const clipboard = useClipboard()
const toast = useToast()
@@ -590,7 +590,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("editing", false)
},
},
...config.keybinds.get("prompt.clear"),
...tuiConfig.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 },
...config.keybinds.get("app.exit"),
...tuiConfig.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) },
...config.keybinds.get("app.exit"),
...tuiConfig.keybinds.get("app.exit"),
]
: [
...Array.from({ length: max }, (_, index) => ({
@@ -754,7 +754,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
group: "Form",
cmd: cancel,
},
...config.keybinds.get("app.exit"),
...tuiConfig.keybinds.get("app.exit"),
]),
],
}
+36 -36
View File
@@ -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 { useConfig } from "../../config"
import { useTuiConfig } from "../../config/v1"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
@@ -88,6 +88,7 @@ const sessionBindingCommands = [
"session.undo",
"session.redo",
"session.sidebar.toggle",
"session.toggle.conceal",
"session.toggle.thinking",
"session.toggle.scrollbar",
"session.toggle.exploration_grouping",
@@ -120,12 +121,13 @@ 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[]
config: ReturnType<typeof useConfig>["data"]
tui: ReturnType<typeof useTuiConfig>
}>()
function use() {
@@ -147,7 +149,7 @@ export function Session() {
const data = useData()
const project = useProject()
const paths = useTuiPaths()
const config = useConfig().data
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -196,6 +198,7 @@ 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)
@@ -214,7 +217,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(config))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
@@ -468,6 +471,15 @@ 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())
@@ -495,7 +507,7 @@ export function Session() {
},
},
{
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools",
value: "session.toggle.exploration_grouping",
category: "Session",
run: () => {
@@ -823,17 +835,17 @@ export function Session() {
}))
useBindings(() => ({
bindings: config.keybinds.gather("session.global", sessionGlobalBindingCommands),
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
}))
useBindings(() => ({
enabled: () => renderer.currentFocusedEditor === null,
bindings: config.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: config.keybinds.gather("session", sessionBindingCommands),
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
}))
// snap to bottom when session changes
@@ -853,12 +865,13 @@ export function Session() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
thinkingMode,
showThinking,
groupExploration,
diffWrapMode,
models,
config,
tui: tuiConfig,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
@@ -1231,27 +1244,21 @@ 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 source = () => stringValue(metadata()?.source)
const completion = () => source() === "subagent" || source() === "shell"
const completion = () => metadata()?.source === "subagent"
const state = () => stringValue(metadata()?.state)
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
const agent = () => 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
@@ -1267,9 +1274,11 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
}
>
<box marginLeft={3}>
<text wrapMode="none">
<span style={{ fg: color() }}>{heading()}</span>
<span style={{ fg: theme.textMuted }}>{suffix()}</span>
<text>
<span style={{ fg: color() }}>
{state() === "completed" ? "↳" : "!"} {agent()} {status()}
</span>
<span style={{ fg: theme.textMuted }}> · {text()}</span>
</text>
</box>
</Show>
@@ -1320,7 +1329,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
internalBlockMode="top-level"
content={content()}
tableOptions={{ style: "grid" }}
conceal={false}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
@@ -1468,7 +1477,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
return (
<Show when={props.message.text.trim() || files().length}>
@@ -1487,13 +1495,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
}}
paddingTop={1}
paddingBottom={1}
@@ -1745,7 +1747,7 @@ function ReasoningPart(props: {
streaming={true}
syntaxStyle={syntax()}
content={summary().body}
conceal={false}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
@@ -1811,7 +1813,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={false}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
@@ -2463,9 +2465,8 @@ function Edit(props: ToolProps) {
const pathFormatter = usePathFormatter()
const view = createMemo(() => {
const diffView = ctx.config.diffs?.view
if (diffView === "unified") return "unified"
if (diffView === "split") return "split"
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
})
@@ -2540,8 +2541,7 @@ function ApplyPatch(props: ToolProps) {
})
})
const view = createMemo(() => {
if (ctx.config.diffs?.view === "unified") return "unified"
if (ctx.config.diffs?.view === "split") return "split"
if (ctx.tui.diff_style === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})
+9 -10
View File
@@ -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 { useConfig } from "../../config"
import { useTuiConfig } from "../../config/v1"
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 = useConfig().data
const config = useTuiConfig()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => {
@@ -34,9 +34,8 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
})
const view = createMemo(() => {
const diffView = config.diffs?.view
if (diffView === "unified") return "unified"
if (diffView === "split") return "split"
const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
@@ -470,7 +469,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const config = useConfig().data
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
useBindings(() => ({
@@ -487,7 +486,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
],
bindings: [
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
...config.keybinds.get("app.exit"),
...tuiConfig.keybinds.get("app.exit"),
{
key: "return",
desc: "Confirm permission rejection",
@@ -558,7 +557,7 @@ function Prompt<const T extends Record<string, string>>(props: {
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const config = useConfig().data
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
@@ -647,8 +646,8 @@ function Prompt<const T extends Record<string, string>>(props: {
},
]
: []),
...(props.escapeKey ? config.keybinds.get("app.exit") : []),
...(props.fullscreen ? config.keybinds.get("permission.prompt.fullscreen") : []),
...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []),
...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []),
],
}))
+3 -3
View File
@@ -1,7 +1,7 @@
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { useTuiConfig } from "../../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<Show when={session()}>
+6
View File
@@ -914,6 +914,12 @@ function getSyntaxRules(theme: Theme) {
foreground: theme.text,
},
},
{
scope: ["conceal"],
style: {
foreground: theme.textMuted,
},
},
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
+3 -3
View File
@@ -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 { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
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 config = useConfig().data
const tuiConfig = useTuiConfig()
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: config.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
}))
onMount(() => {
+11 -19
View File
@@ -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 { useConfig } from "../config"
import { useTuiConfig } from "../config/v1"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
@@ -51,8 +51,6 @@ export interface DialogSelectProps<T> {
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
hideClose?: boolean
maxHeight?: number
}
export interface DialogSelectOption<T = any> {
@@ -88,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({
selected: 0,
@@ -132,7 +130,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), config)
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
if (label) labels.set(action.command, label)
}
@@ -214,7 +212,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
const dimensions = useTerminalDimensions()
const height = createMemo(() => Math.min(rows(), props.maxHeight ?? Math.floor(dimensions().height / 2) - 6))
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
const selected = createMemo(() => flat()[store.selected])
@@ -452,7 +450,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})),
],
bindings: [
...config.keybinds.gather("dialog.select", [
...tuiConfig.keybinds.gather("dialog.select", [
"dialog.select.prev",
"dialog.select.next",
"dialog.select.page_up",
@@ -461,7 +459,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
"dialog.select.end",
"dialog.select.submit",
]),
...visible.flatMap((item) => config.keybinds.get(item.command)),
...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
...(visible.length
? [
{
@@ -567,11 +565,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{props.title}
</text>
)}
<Show when={!props.hideClose}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</Show>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
@@ -793,11 +789,7 @@ function Option(props: {
</text>
<Show when={props.footer}>
<box flexShrink={0}>
{typeof props.footer === "string" ? (
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
) : (
props.footer
)}
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
</box>
</Show>
</>

Some files were not shown because too many files have changed in this diff Show More