summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-04-16 20:28:08 -0400
committerDax Raad <[email protected]>2026-04-16 20:37:58 -0400
commit39342b0e759a265045a2b49f34af8ae5da773a8c (patch)
treeee80afe85ec9c5d9a9558d1f379d377502f075b3
parent54078c4caea1adadea25ca1c4ec1479f3ab4e423 (diff)
downloadopencode-39342b0e759a265045a2b49f34af8ae5da773a8c.tar.gz
opencode-39342b0e759a265045a2b49f34af8ae5da773a8c.zip
tui: fix Windows terminal suspend and input undo keybindings
On Windows, native terminals don't support POSIX suspend (ctrl+z), so we now assign ctrl+z to input undo instead of terminal suspend. Terminal suspend is disabled on Windows to avoid conflicts with the undo functionality.
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts1
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui.ts328
-rw-r--r--packages/opencode/src/config/config.ts1
-rw-r--r--packages/opencode/src/config/keybinds.ts14
4 files changed, 179 insertions, 165 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
index 3ce5c4b73..9323dd979 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
@@ -26,7 +26,6 @@ const TuiLegacy = z
interface MigrateInput {
cwd: string
directories: string[]
- custom?: string
}
/**
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
index d264273bc..6e5296db8 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts
@@ -17,197 +17,203 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { Filesystem, Log } from "@/util"
-const log = Log.create({ service: "tui.config" })
+export namespace TuiConfig {
+ const log = Log.create({ service: "tui.config" })
-export const Info = TuiInfo
+ export const Info = TuiInfo
-type Acc = {
- result: Info
-}
-
-type State = {
- config: Info
- deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
-}
-
-export type Info = z.output<typeof Info> & {
- // Internal resolved plugin list used by runtime loading.
- plugin_origins?: ConfigPlugin.Origin[]
-}
-
-export interface Interface {
- readonly get: () => Effect.Effect<Info>
- readonly waitForDependencies: () => Effect.Effect<void>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
-
-function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
- if (Filesystem.contains(ctx.directory, file)) return "local"
- // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
- return "global"
-}
-
-function customPath() {
- return Flag.OPENCODE_TUI_CONFIG
-}
-
-function normalize(raw: Record<string, unknown>) {
- const data = { ...raw }
- if (!("tui" in data)) return data
- if (!isRecord(data.tui)) {
- delete data.tui
- return data
+ type Acc = {
+ result: Info
}
- const tui = data.tui
- delete data.tui
- return {
- ...tui,
- ...data,
+ type State = {
+ config: Info
+ deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
-}
-async function resolvePlugins(config: Info, configFilepath: string) {
- if (!config.plugin) return config
- for (let i = 0; i < config.plugin.length; i++) {
- config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
+ export type Info = z.output<typeof Info> & {
+ // Internal resolved plugin list used by runtime loading.
+ plugin_origins?: ConfigPlugin.Origin[]
}
- return config
-}
-async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
- const data = await loadFile(file)
- acc.result = mergeDeep(acc.result, data)
- if (!data.plugin?.length) return
-
- const scope = pluginScope(file, ctx)
- const plugins = ConfigPlugin.deduplicatePluginOrigins([
- ...(acc.result.plugin_origins ?? []),
- ...data.plugin.map((spec) => ({ spec, scope, source: file })),
- ])
- acc.result.plugin = plugins.map((item) => item.spec)
- acc.result.plugin_origins = plugins
-}
+ export interface Interface {
+ readonly get: () => Effect.Effect<Info>
+ readonly waitForDependencies: () => Effect.Effect<void>
+ }
-async function loadState(ctx: { directory: string }) {
- let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
- const directories = await ConfigPaths.directories(ctx.directory)
- const custom = customPath()
- await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
- // Re-compute after migration since migrateTuiConfig may have created new tui.json files
- projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+ export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
- const acc: Acc = {
- result: {},
+ function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
+ if (Filesystem.contains(ctx.directory, file)) return "local"
+ // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
+ return "global"
}
- for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
- await mergeFile(acc, file, ctx)
+ function normalize(raw: Record<string, unknown>) {
+ const data = { ...raw }
+ if (!("tui" in data)) return data
+ if (!isRecord(data.tui)) {
+ delete data.tui
+ return data
+ }
+
+ const tui = data.tui
+ delete data.tui
+ return {
+ ...tui,
+ ...data,
+ }
}
- if (custom) {
- await mergeFile(acc, custom, ctx)
- log.debug("loaded custom tui config", { path: custom })
+ async function resolvePlugins(config: Info, configFilepath: string) {
+ if (!config.plugin) return config
+ for (let i = 0; i < config.plugin.length; i++) {
+ config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
+ }
+ return config
}
- for (const file of projectFiles) {
- await mergeFile(acc, file, ctx)
+ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
+ const data = await loadFile(file)
+ acc.result = mergeDeep(acc.result, data)
+ if (!data.plugin?.length) return
+
+ const scope = pluginScope(file, ctx)
+ const plugins = ConfigPlugin.deduplicatePluginOrigins([
+ ...(acc.result.plugin_origins ?? []),
+ ...data.plugin.map((spec) => ({ spec, scope, source: file })),
+ ])
+ acc.result.plugin = plugins.map((item) => item.spec)
+ acc.result.plugin_origins = plugins
}
- const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
+ async function loadState(ctx: { directory: string }) {
+ // Every config dir we may read from: global config dir, any `.opencode`
+ // folders between cwd and home, and OPENCODE_CONFIG_DIR.
+ const directories = await ConfigPaths.directories(ctx.directory)
+ // One-time migration: extract tui keys (theme/keybinds/tui) from existing
+ // opencode.json files into sibling tui.json files.
+ await migrateTuiConfig({ directories, cwd: ctx.directory })
- for (const dir of dirs) {
- if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
- for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
- await mergeFile(acc, file, ctx)
- }
- }
+ const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? []
+ : await ConfigPaths.projectFiles("tui", ctx.directory)
- const keybinds = { ...(acc.result.keybinds ?? {}) }
- if (process.platform === "win32") {
- // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
- keybinds.terminal_suspend = "none"
- keybinds.input_undo ??= unique([
- "ctrl+z",
- ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
- ]).join(",")
- }
- acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
+ const acc: Acc = {
+ result: {},
+ }
- return {
- config: acc.result,
- dirs: acc.result.plugin?.length ? dirs : [],
- }
-}
+ // 1. Global tui config (lowest precedence).
+ for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+ await mergeFile(acc, file, ctx)
+ }
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const directory = yield* CurrentWorkingDirectory
- const npm = yield* Npm.Service
- const data = yield* Effect.promise(() => loadState({ directory }))
- const deps = yield* Effect.forEach(
- data.dirs,
- (dir) =>
- npm
- .install(dir, {
- add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
- })
- .pipe(Effect.forkScoped),
- {
- concurrency: "unbounded",
- },
- )
+ // 2. Explicit OPENCODE_TUI_CONFIG override, if set.
+ if (Flag.OPENCODE_TUI_CONFIG) {
+ await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
+ log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
+ }
- const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
+ // 3. Project tui files, applied root-first so the closest file wins.
+ for (const file of projectFiles) {
+ await mergeFile(acc, file, ctx)
+ }
- const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
- Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
- )
- return Service.of({ get, waitForDependencies })
- }).pipe(Effect.withSpan("TuiConfig.layer")),
-)
+ // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
+ // walking up the tree. Also returned below so callers can install plugin
+ // dependencies from each location.
+ const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
-export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
+ for (const dir of dirs) {
+ if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+ for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+ await mergeFile(acc, file, ctx)
+ }
+ }
-const { runPromise } = makeRuntime(Service, defaultLayer)
+ const keybinds = { ...(acc.result.keybinds ?? {}) }
+ if (process.platform === "win32") {
+ // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
+ keybinds.terminal_suspend = "none"
+ keybinds.input_undo ??= unique([
+ "ctrl+z",
+ ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
+ ]).join(",")
+ }
+ acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
-export async function waitForDependencies() {
- await runPromise((svc) => svc.waitForDependencies())
-}
+ return {
+ config: acc.result,
+ dirs: acc.result.plugin?.length ? dirs : [],
+ }
+ }
-export async function get() {
- return runPromise((svc) => svc.get())
-}
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const directory = yield* CurrentWorkingDirectory
+ const npm = yield* Npm.Service
+ const data = yield* Effect.promise(() => loadState({ directory }))
+ const deps = yield* Effect.forEach(
+ data.dirs,
+ (dir) =>
+ npm
+ .install(dir, {
+ add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+ })
+ .pipe(Effect.forkScoped),
+ {
+ concurrency: "unbounded",
+ },
+ )
+
+ const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
+
+ const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
+ Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
+ )
+ return Service.of({ get, waitForDependencies })
+ }).pipe(Effect.withSpan("TuiConfig.layer")),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
+
+ const { runPromise } = makeRuntime(Service, defaultLayer)
+
+ export async function waitForDependencies() {
+ await runPromise((svc) => svc.waitForDependencies())
+ }
-async function loadFile(filepath: string): Promise<Info> {
- const text = await ConfigPaths.readFile(filepath)
- if (!text) return {}
- return load(text, filepath).catch((error) => {
- log.warn("failed to load tui config", { path: filepath, error })
- return {}
- })
-}
+ export async function get() {
+ return runPromise((svc) => svc.get())
+ }
-async function load(text: string, configFilepath: string): Promise<Info> {
- return ConfigParse.load(Info, text, {
- type: "path",
- path: configFilepath,
- missing: "empty",
- normalize: (data) => {
- if (!isRecord(data)) return {}
-
- // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
- // (mirroring the old opencode.json shape) still get their settings applied.
- return normalize(data)
- },
- })
- .then((data) => resolvePlugins(data, configFilepath))
- .catch((error) => {
- log.warn("invalid tui config", { path: configFilepath, error })
+ async function loadFile(filepath: string): Promise<Info> {
+ const text = await ConfigPaths.readFile(filepath)
+ if (!text) return {}
+ return load(text, filepath).catch((error) => {
+ log.warn("failed to load tui config", { path: filepath, error })
return {}
})
-}
+ }
-export * as TuiConfig from "./tui"
+ async function load(text: string, configFilepath: string): Promise<Info> {
+ return ConfigParse.load(Info, text, {
+ type: "path",
+ path: configFilepath,
+ missing: "empty",
+ normalize: (data) => {
+ if (!isRecord(data)) return {}
+
+ // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+ // (mirroring the old opencode.json shape) still get their settings applied.
+ return normalize(data)
+ },
+ })
+ .then((data) => resolvePlugins(data, configFilepath))
+ .catch((error) => {
+ log.warn("invalid tui config", { path: configFilepath, error })
+ return {}
+ })
+ }
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 3cbc53960..adccb6353 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -19,7 +19,6 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
-import { InvalidError, JsonError } from "./error"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts
index cb146b7ca..8a22289d2 100644
--- a/packages/opencode/src/config/keybinds.ts
+++ b/packages/opencode/src/config/keybinds.ts
@@ -106,7 +106,12 @@ export const Keybinds = z
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
- input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
+ input_undo: z
+ .string()
+ .optional()
+ // On Windows prepend ctrl+z since terminal_suspend releases the binding.
+ .default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
+ .describe("Undo in input"),
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z
.string()
@@ -144,7 +149,12 @@ export const Keybinds = z
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
session_parent: z.string().optional().default("up").describe("Go to parent session"),
- terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
+ terminal_suspend: z
+ .string()
+ .optional()
+ .default("ctrl+z")
+ .transform((v) => (process.platform === "win32" ? "none" : v))
+ .describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),