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.
This commit is contained in:
Dax Raad
2026-04-16 20:37:58 -04:00
parent 54078c4cae
commit 39342b0e75
4 changed files with 183 additions and 169 deletions
@@ -26,7 +26,6 @@ const TuiLegacy = z
interface MigrateInput { interface MigrateInput {
cwd: string cwd: string
directories: string[] directories: string[]
custom?: string
} }
/** /**
+171 -165
View File
@@ -17,197 +17,203 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime" import { makeRuntime } from "@/cli/effect/runtime"
import { Filesystem, Log } from "@/util" 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 = { type Acc = {
result: Info result: Info
} }
type State = { type State = {
config: Info config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>> deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
} }
export type Info = z.output<typeof Info> & { export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading. // Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[] plugin_origins?: ConfigPlugin.Origin[]
} }
export interface Interface { export interface Interface {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void> readonly waitForDependencies: () => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {} export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local" if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global" return "global"
} }
function customPath() { function normalize(raw: Record<string, unknown>) {
return Flag.OPENCODE_TUI_CONFIG const data = { ...raw }
} if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
function normalize(raw: Record<string, unknown>) { const tui = data.tui
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui delete data.tui
return data return {
} ...tui,
...data,
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
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
}
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
}
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)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
} }
} }
const keybinds = { ...(acc.result.keybinds ?? {}) } async function resolvePlugins(config: Info, configFilepath: string) {
if (process.platform === "win32") { if (!config.plugin) return config
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo. for (let i = 0; i < config.plugin.length; i++) {
keybinds.terminal_suspend = "none" config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
keybinds.input_undo ??= unique([ }
"ctrl+z", return config
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
} }
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return { async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
config: acc.result, const data = await loadFile(file)
dirs: acc.result.plugin?.length ? dirs : [], 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 const layer = Layer.effect( async function loadState(ctx: { directory: string }) {
Service, // Every config dir we may read from: global config dir, any `.opencode`
Effect.gen(function* () { // folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directory = yield* CurrentWorkingDirectory const directories = await ConfigPaths.directories(ctx.directory)
const npm = yield* Npm.Service // One-time migration: extract tui keys (theme/keybinds/tui) from existing
const data = yield* Effect.promise(() => loadState({ directory })) // opencode.json files into sibling tui.json files.
const deps = yield* Effect.forEach( await migrateTuiConfig({ directories, cwd: ctx.directory })
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 projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory)
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => const acc: Acc = {
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), result: {},
) }
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) // 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
const { runPromise } = makeRuntime(Service, defaultLayer) // 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 })
}
export async function waitForDependencies() { // 3. Project tui files, applied root-first so the closest file wins.
await runPromise((svc) => svc.waitForDependencies()) for (const file of projectFiles) {
} await mergeFile(acc, file, ctx)
}
export async function get() { // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
return runPromise((svc) => svc.get()) // 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)
async function loadFile(filepath: string): Promise<Info> { for (const dir of dirs) {
const text = await ConfigPaths.readFile(filepath) if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
if (!text) return {} for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
return load(text, filepath).catch((error) => { await mergeFile(acc, file, ctx)
log.warn("failed to load tui config", { path: filepath, error }) }
return {} }
})
}
async function load(text: string, configFilepath: string): Promise<Info> { const keybinds = { ...(acc.result.keybinds ?? {}) }
return ConfigParse.load(Info, text, { if (process.platform === "win32") {
type: "path", // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
path: configFilepath, keybinds.terminal_suspend = "none"
missing: "empty", keybinds.input_undo ??= unique([
normalize: (data) => { "ctrl+z",
if (!isRecord(data)) return {} ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json return {
// (mirroring the old opencode.json shape) still get their settings applied. config: acc.result,
return normalize(data) dirs: acc.result.plugin?.length ? dirs : [],
}, }
}) }
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => { export const layer = Layer.effect(
log.warn("invalid tui config", { path: configFilepath, error }) 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())
}
export async function get() {
return runPromise((svc) => svc.get())
}
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 {} 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 {}
})
}
}
-1
View File
@@ -19,7 +19,6 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Account } from "@/account" import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { InvalidError, JsonError } from "./error"
import type { ConsoleState } from "./console-state" import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
+12 -2
View File
@@ -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_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_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_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_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z input_word_forward: z
.string() .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: 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_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"), 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"), 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"), 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"), plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),