Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton c5ac6ba4f3 refactor(tui): prototype settings side panel 2026-08-12 00:17:07 +00:00
Dax Raad 20af1f5e79 refactor(tui): compact settings values 2026-07-13 03:57:14 +00:00
Kit Langton 8daf912fbf feat(tui): add settings dialog 2026-07-13 03:57:14 +00:00
opencode-agent[bot] 3d545f960b fix(tui): restore clicked reverted prompt (#36567)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 21:51:50 -05:00
Kit Langton 58201a32c1 fix: standardize MCP server copy (#36598) 2026-07-12 22:23:59 -04:00
24 changed files with 539 additions and 187 deletions
+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": "Toggle MCPs",
"command.mcp.toggle.description": "Toggle MCPs",
"command.mcp.toggle": "Manage MCP servers",
"command.mcp.toggle.description": "Enable or disable MCP servers",
"command.agent.cycle": "Cycle agent",
"command.agent.cycle.description": "Switch to the next agent",
"command.agent.cycle.reverse": "Cycle agent backwards",
@@ -307,9 +307,9 @@ export const dict = {
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
"dialog.mcp.title": "MCPs",
"dialog.mcp.title": "MCP servers",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.mcp.empty": "No MCP servers configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
+1 -1
View File
@@ -17,7 +17,7 @@ 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.namespace(server.server))}]\`.`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
+20 -6
View File
@@ -305,13 +305,27 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
tool: {
// The tool domain is scoped registration, not State, so the host adapts the draft to register calls directly.
transform: (callback) =>
Effect.forEach(
Tool.fromDraft(callback),
({ name, tool, options }) => tools.register({ [name]: tool }, options),
{ discard: true },
).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
}),
)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
hook: (name, callback) => {
if (name === "execute.before") {
return toolHooks.hook.before((event) => {
+1 -5
View File
@@ -154,11 +154,7 @@ export function fromPromise(plugin: Plugin) {
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) =>
draft.add(tool.name, fromPromiseTool(tool), {
...(tool.namespace !== undefined ? { namespace: tool.namespace } : {}),
...(tool.codemode !== undefined ? { codemode: tool.codemode } : {}),
}),
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
}),
),
),
+2 -2
View File
@@ -29,8 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
`namespace`, which flattens native model names to `<namespace>_<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 default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
+8 -8
View File
@@ -39,7 +39,7 @@ type CollectedFiles = {
interface Registration {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
readonly group?: string
}
export const create = (registrations: ReadonlyMap<string, Registration>) => {
@@ -56,19 +56,19 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
if (registration.namespace === undefined) {
if (registration.group === undefined) {
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.namespace
const branch = tools[namespace]
if (branch && Tool.isDefinition(branch)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (branch) {
if (Object.hasOwn(branch, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
branch[path] = value
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
+14 -10
View File
@@ -13,10 +13,10 @@ import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
/**
* Registry namespace and permission action names for MCP tools.
* Registry group and permission action names for MCP tools.
*/
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -32,11 +32,11 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const byServer = new Map<string, Record<string, Tool.AnyTool>>()
const groups = new Map<string, Record<string, Tool.AnyTool>>()
for (const tool of yield* mcp.tools()) {
const record = byServer.get(tool.server) ?? {}
const group = groups.get(tool.server) ?? {}
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
record[tool.name] = Tool.withPermission(
group[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
@@ -102,12 +102,16 @@ export const layer = Layer.effectDiscard(
}),
name(tool.server, tool.name),
)
byServer.set(tool.server, record)
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* Effect.forEach(byServer, ([server, record]) => tools.register(record, { namespace: server }), {
discard: true,
}).pipe(Scope.provide(next), Effect.orDie)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
+3 -3
View File
@@ -54,7 +54,7 @@ const registryLayer = Layer.effect(
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
readonly group?: string
readonly codemode: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -129,7 +129,7 @@ const registryLayer = Layer.effect(
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.namespace)
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")
@@ -148,7 +148,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
group: entry.group,
codemode,
},
},
+18 -5
View File
@@ -46,11 +46,24 @@ export const registerToolPlugin = <R>(plugin: {
const context = host({
tool: {
transform: (callback) =>
Effect.forEach(
Tool.fromDraft(callback),
({ name, tool, options }) => tools.register({ [name]: tool }, options),
{ discard: true },
).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
})
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+4 -39
View File
@@ -274,7 +274,7 @@ describe("PluginV2", () => {
}),
)
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
it.effect("groups tool names and routes codemode registrations through execute", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
@@ -286,13 +286,13 @@ describe("PluginV2", () => {
execute: () => Effect.succeed({ ok: true }),
})
const plugin = EffectPlugin.define({
id: "namespaced-tools",
id: "grouped-tools",
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"), { codemode: false })
draft.add("look/up", tool("Lookup"), { namespace: "context 7", codemode: false })
draft.add("search", tool("Search"), { namespace: "context 7" })
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
draft.add("search", tool("Search"), { group: "context 7" })
})
.pipe(Effect.orDie),
})
@@ -307,41 +307,6 @@ describe("PluginV2", () => {
}),
)
it.effect("accepts flat Effect draft declarations with namespace", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugin = EffectPlugin.define({
id: "flat-tools",
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add({
name: "send",
namespace: "slack",
description: "Send a Slack message",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
execute: () => Effect.succeed({ sent: true }),
})
draft.add({
name: "edit",
codemode: false,
description: "Edit a file",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
})
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual(["edit", "execute"])
}),
)
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
+1 -1
View File
@@ -135,7 +135,7 @@ describe("fromPromise", () => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
codemode: false,
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
+4 -5
View File
@@ -312,13 +312,12 @@ export default Plugin.define({
})
```
Unsupported characters in tool and namespace names are normalized to underscores.
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 registration fields on the
declaration or options with `{ namespace, codemode }`:
letters, digits, underscores, or hyphens. Set `options` on the declaration to
configure registration with `{ group, codemode }`:
- `namespace` prefixes the exposed tool name (and becomes the CodeMode path
segment).
- `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.
+4 -68
View File
@@ -123,17 +123,6 @@ export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
return makeTyped(config)
}
/**
* Split a flat tool declaration into its registration name, opaque Tool value,
* and registration options. Narrowing on `jsonSchema` selects the matching
* constructor, so no cast is needed to build the Tool from the flat union.
*/
export function fromFlat(flat: FlatDefinition<any, any, any> | FlatDynamicDefinition) {
const { name, namespace, codemode, ...config } = flat
const tool = "jsonSchema" in config ? makeDynamic(config) : makeTyped(config)
return { name, tool, options: { namespace, codemode } satisfies RegisterOptions }
}
function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
@@ -223,14 +212,14 @@ export const validateName = (name: string) =>
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
const parent = namespace?.replace(/[^a-zA-Z0-9_-]/g, "_")
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: parent === undefined ? normalized : `${parent}_${normalized}`,
name: normalized,
namespace: parent,
group: parent,
tool,
}
})
@@ -282,68 +271,15 @@ export interface ToolExecuteAfterEvent {
}
export interface RegisterOptions {
/** Dotted CodeMode path prefix, e.g. "slack.admin". */
readonly namespace?: string
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export type FlatDefinition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: Config<Input, Output, Structured>["toStructuredOutput"]
readonly execute: Config<Input, Output, Structured>["execute"]
readonly toModelOutput?: Config<Input, Output, Structured>["toModelOutput"]
}
export type FlatDynamicDefinition = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: DynamicConfig["execute"]
}
export interface ToolDraft {
add<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(tool: FlatDefinition<Input, Output, Structured>): void
add(tool: FlatDynamicDefinition): void
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export type Registration = {
readonly name: string
readonly tool: AnyTool
readonly options?: RegisterOptions
}
/** Run a draft callback and collect the tools it declared, in registration order. */
export function fromDraft(callback: (draft: ToolDraft) => void) {
const registrations: Array<Registration> = []
const add = (
nameOrTool: string | FlatDefinition<any, any, any> | FlatDynamicDefinition,
tool?: AnyTool,
options?: RegisterOptions,
) =>
registrations.push(typeof nameOrTool === "string" ? { name: nameOrTool, tool: tool!, options } : fromFlat(nameOrTool))
callback({ add })
return registrations
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
+8 -4
View File
@@ -16,8 +16,7 @@ export type Definition<
Structured extends SchemaType<any> = Output,
> = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly options?: RegisterOptions
readonly description: string
readonly input: Input
readonly output: Output
@@ -38,8 +37,7 @@ export type Definition<
export type DynamicDefinition = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly options?: RegisterOptions
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
@@ -69,6 +67,12 @@ export interface ToolExecuteAfterEvent {
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export interface ToolDraft {
add<
Input extends SchemaType<any>,
+24 -8
View File
@@ -53,6 +53,7 @@ import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
@@ -218,7 +219,9 @@ function fromV1(config: TuiConfigV1.Resolved): Config.Info {
}
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"
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) {
@@ -477,7 +480,8 @@ function App(props: {
}) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const config = useConfig().data
const configContext = useConfig()
const config = configContext.data
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -522,7 +526,7 @@ function App(props: {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCPs to view details.",
message: "Open MCP servers to view details.",
})
}
})
@@ -587,10 +591,12 @@ function App(props: {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", true),
)
const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
createEffect(() => {
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
})
// Update terminal window title based on current route and session
createEffect(() => {
@@ -785,7 +791,7 @@ function App(props: {
},
{
name: "mcp.list",
title: "MCP Servers",
title: "MCP servers",
category: "Agent",
slashName: "mcps",
run: () => {
@@ -849,6 +855,16 @@ 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",
@@ -0,0 +1,354 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useConfig } from "../config"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
type Setting = {
title: string
category: string
description: string
detail?: string
path: string[]
default: unknown
values?: readonly unknown[]
labels?: readonly string[]
step?: number
min?: number
max?: number
format?: (value: unknown) => string
}
const settings: Setting[] = [
{
title: "Theme",
category: "Appearance",
description: "Interface color theme",
detail:
"Choose the color theme used throughout OpenCode. Custom themes discovered from your config directory appear here alongside the built-in themes.",
path: ["theme", "name"],
default: "opencode",
},
{
title: "Color mode",
category: "Appearance",
description: "Terminal color preference",
detail:
"Choose how OpenCode selects its colors. System follows your terminal preference, while dark and light keep the interface in a fixed mode.",
path: ["theme", "mode"],
default: "system",
values: ["system", "dark", "light"],
},
{
title: "Animations",
category: "Appearance",
description: "Interface motion",
path: ["animations"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Tips",
category: "Appearance",
description: "Home screen hints",
path: ["hints", "tips"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Onboarding",
category: "Appearance",
description: "Getting-started guidance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Sidebar",
category: "Session",
description: "Session sidebar visibility",
path: ["session", "sidebar"],
default: "auto",
values: ["hide", "auto"],
},
{
title: "Scrollbar",
category: "Session",
description: "Transcript scrollbar",
path: ["session", "scrollbar"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Thinking",
category: "Session",
description: "Model reasoning by default",
path: ["session", "thinking"],
default: "hide",
values: ["hide", "show"],
},
{
title: "Grouping",
category: "Session",
description: "Related transcript items",
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
},
{
title: "Layout",
category: "Diffs",
description: "Diff presentation",
path: ["diffs", "view"],
default: "auto",
values: ["auto", "split", "unified"],
},
{
title: "Wrapping",
category: "Diffs",
description: "Long diff lines",
path: ["diffs", "wrap"],
default: "word",
values: ["none", "word"],
},
{
title: "File tree",
category: "Diffs",
description: "Diff file navigation",
path: ["diffs", "tree"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Single patch",
category: "Diffs",
description: "Only the selected patch",
path: ["diffs", "single"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Scroll speed",
category: "Input",
description: "Distance per input tick",
path: ["scroll", "speed"],
default: 3,
step: 0.25,
min: 0.25,
max: 10,
format: (value) => Number(value).toFixed(2),
},
{
title: "Acceleration",
category: "Input",
description: "Repeated scrolling",
path: ["scroll", "acceleration"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Mouse",
category: "Input",
description: "Terminal mouse capture",
path: ["mouse"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Editor context",
category: "Input",
description: "Active selection in prompts",
path: ["prompt", "editor"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Large pastes",
category: "Input",
description: "Paste display style",
path: ["prompt", "paste"],
default: "compact",
values: ["compact", "full"],
},
{
title: "Leader timeout",
category: "Input",
description: "Wait after leader key",
path: ["leader", "timeout"],
default: 2000,
step: 250,
min: 250,
max: 10000,
format: (value) => `${value} ms`,
},
{
title: "Attention",
category: "Alerts",
description: "Alerts when input is needed",
path: ["attention", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Notifications",
category: "Alerts",
description: "System notifications",
path: ["attention", "notifications"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Sounds",
category: "Alerts",
description: "Attention sounds",
path: ["attention", "sound"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Volume",
category: "Alerts",
description: "Attention sound level",
path: ["attention", "volume"],
default: 0.4,
step: 0.1,
min: 0,
max: 1,
format: (value) => `${Math.round(Number(value) * 100)}%`,
},
{
title: "Window title",
category: "Terminal",
description: "Update terminal title",
path: ["terminal", "title"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
]
export function DialogConfig() {
const config = useConfig()
const dialog = useDialog()
const toast = useToast()
const themeState = useTheme()
const { theme } = themeState
const dimensions = useTerminalDimensions()
const [selected, setSelected] = createSignal(settings[0])
const [saving, setSaving] = createSignal(false)
onMount(() => {
dialog.setSize("xlarge")
dialog.setCentered(true)
})
const value = (setting: Setting) => {
const current = setting.path.reduce<unknown>((result, key) => {
if (!result || typeof result !== "object") return undefined
return (result as Record<string, unknown>)[key]
}, config.data)
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
return current ?? setting.default
}
const values = (setting: Setting) =>
setting.path.join(".") === "theme.name"
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
: setting.values
const display = (setting: Setting) => {
const current = value(setting)
if (setting.format) return setting.format(current)
const index = setting.values?.indexOf(current)
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
}
const options = createMemo(() =>
settings.map((setting) => ({
title: setting.title,
category: setting.category,
value: setting,
footer: selected() === setting ? ` ${display(setting)} ` : ` ${display(setting)} `,
})),
)
const split = createMemo(() => dimensions().width >= 110)
const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
async function change(setting: Setting, direction: number) {
if (saving()) return
const current = value(setting)
const choices = values(setting)
const next = choices
? choices[(choices.indexOf(current) + direction + choices.length) % choices.length]
: Math.min(setting.max!, Math.max(setting.min!, Number(current) + direction * setting.step!))
if (next === current) return
setSaving(true)
await config
.update((draft) => {
const parent = setting.path.slice(0, -1).reduce<Record<string, unknown>>((result, key) => {
if (!result[key] || typeof result[key] !== "object") result[key] = {}
return result[key] as Record<string, unknown>
}, draft)
parent[setting.path.at(-1)!] = next
})
.catch(toast.error)
.finally(() => setSaving(false))
}
return (
<box flexDirection="row" height={height() + 1}>
<box width={split() ? "54%" : "100%"}>
<DialogSelect
title="Settings"
options={options()}
renderFilter={false}
hideClose={split()}
maxHeight={height() - 2}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value, 1)}
bindings={[
{ key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(selected(), -1) },
{ key: "right", desc: "Next value", group: "Settings", cmd: () => void change(selected(), 1) },
]}
/>
</box>
<Show when={split()}>
<box
position="relative"
top={-1}
width="46%"
height={height() + 2}
paddingTop={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.backgroundElement}
>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
{selected().title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingTop={1}>
<text fg={theme.text} wrapMode="word">
{selected().detail ?? selected().description}
</text>
</box>
</box>
</Show>
</box>
)
}
+2 -2
View File
@@ -92,7 +92,7 @@ export function DialogMcp() {
when={detail()}
fallback={
<DialogSelect
title="MCPs"
title="MCP servers"
options={options()}
current={focused()}
preserveSelection
@@ -152,7 +152,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
MCP / {props.server.name}
MCP server: {props.server.name}
</text>
<text fg={theme.textMuted} onMouseUp={props.onBack}>
esc back
+4 -2
View File
@@ -22,9 +22,11 @@ export function DialogStatus() {
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP servers</text>}>
<box>
<text fg={theme.text}>{mcp().length} MCP Servers</text>
<text fg={theme.text}>
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
</text>
<For each={mcp()}>
{(item) => (
<box flexDirection="row" gap={1}>
+4 -1
View File
@@ -179,6 +179,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
const ConfigContext = createContext<{
data: Resolved
update: Interface["update"]
writable: boolean
}>()
export function ConfigProvider(props: {
@@ -195,7 +196,9 @@ export function ConfigProvider(props: {
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
return info
}
return <ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
return (
<ConfigContext.Provider value={{ data: config, update, writable: !!host }}>{props.children}</ConfigContext.Provider>
)
}
export function useConfig() {
+9
View File
@@ -129,6 +129,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
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()
@@ -6,8 +6,13 @@ import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
import type { PromptInfo } from "../../prompt/history"
export function DialogMessage(props: { messageID: string; sessionID: string }) {
export function DialogMessage(props: {
messageID: string
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
@@ -22,9 +27,26 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) {
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: async (dialog) => {
await sdk.api.session
.revert.stage({ sessionID: props.sessionID, messageID: props.messageID })
onSelect: (dialog) => {
const value = message()
if (value?.type === "user") {
props.setPrompt?.({
text: value.text,
files: value.files?.map((file) => ({
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention ? { ...file.mention } : undefined,
})),
agents: value.agents?.map((agent) => ({
name: agent.name,
mention: agent.mention ? { ...agent.mention } : undefined,
})),
pasted: [],
})
}
void sdk.api.session.revert
.stage({ sessionID: props.sessionID, messageID: props.messageID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
},
+8 -1
View File
@@ -1468,6 +1468,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
return (
<Show when={props.message.text.trim() || files().length}>
@@ -1486,7 +1487,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
paddingTop={1}
paddingBottom={1}
+13 -5
View File
@@ -51,6 +51,8 @@ export interface DialogSelectProps<T> {
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
hideClose?: boolean
maxHeight?: number
}
export interface DialogSelectOption<T = any> {
@@ -212,7 +214,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
const dimensions = useTerminalDimensions()
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
const height = createMemo(() => Math.min(rows(), props.maxHeight ?? Math.floor(dimensions().height / 2) - 6))
const selected = createMemo(() => flat()[store.selected])
@@ -565,9 +567,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
{props.title}
</text>
)}
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<Show when={!props.hideClose}>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</Show>
</box>
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
@@ -789,7 +793,11 @@ function Option(props: {
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
{typeof props.footer === "string" ? (
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
) : (
props.footer
)}
</box>
</Show>
</>
@@ -292,7 +292,7 @@ The `mcp debug` command shows the current auth status, tests HTTP connectivity,
## Manage
Your MCPs are available as tools in OpenCode, alongside built-in tools. So you can manage them through the OpenCode config like any other tool.
Tools from your MCP servers are available in OpenCode alongside built-in tools. You can manage them through the OpenCode config like any other tool.
---
@@ -319,7 +319,7 @@ This means that you can enable or disable them globally.
}
```
We can also use a glob pattern to disable all matching MCPs.
We can also use a glob pattern to disable all matching MCP tools.
```json title="opencode.json" {14}
{
@@ -340,7 +340,7 @@ We can also use a glob pattern to disable all matching MCPs.
}
```
Here we are using the glob pattern `my-mcp*` to disable all MCPs.
Here we are using the glob pattern `my-mcp*` to disable all matching MCP tools.
---