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
} }
/** /**
+52 -46
View File
@@ -17,42 +17,39 @@ 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
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw } const data = { ...raw }
if (!("tui" in data)) return data if (!("tui" in data)) return data
if (!isRecord(data.tui)) { if (!isRecord(data.tui)) {
@@ -66,17 +63,17 @@ function normalize(raw: Record<string, unknown>) {
...tui, ...tui,
...data, ...data,
} }
} }
async function resolvePlugins(config: Info, configFilepath: string) { async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) { for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
} }
return config return config
} }
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file) const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data) acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return if (!data.plugin?.length) return
@@ -88,33 +85,43 @@ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
]) ])
acc.result.plugin = plugins.map((item) => item.spec) acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins acc.result.plugin_origins = plugins
} }
async function loadState(ctx: { directory: string }) { async function loadState(ctx: { directory: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) // 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) const directories = await ConfigPaths.directories(ctx.directory)
const custom = customPath() // One-time migration: extract tui keys (theme/keybinds/tui) from existing
await migrateTuiConfig({ directories, custom, cwd: ctx.directory }) // opencode.json files into sibling tui.json files.
// Re-compute after migration since migrateTuiConfig may have created new tui.json files await migrateTuiConfig({ directories, cwd: ctx.directory })
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = { const acc: Acc = {
result: {}, result: {},
} }
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx) await mergeFile(acc, file, ctx)
} }
if (custom) { // 2. Explicit OPENCODE_TUI_CONFIG override, if set.
await mergeFile(acc, custom, ctx) if (Flag.OPENCODE_TUI_CONFIG) {
log.debug("loaded custom tui config", { path: custom }) await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
} }
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) { for (const file of projectFiles) {
await mergeFile(acc, file, ctx) await mergeFile(acc, file, ctx)
} }
// 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) const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) { for (const dir of dirs) {
@@ -139,9 +146,9 @@ async function loadState(ctx: { directory: string }) {
config: acc.result, config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [], dirs: acc.result.plugin?.length ? dirs : [],
} }
} }
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory const directory = yield* CurrentWorkingDirectory
@@ -167,30 +174,30 @@ export const layer = Layer.effect(
) )
return Service.of({ get, waitForDependencies }) return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")), }).pipe(Effect.withSpan("TuiConfig.layer")),
) )
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() { export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies()) await runPromise((svc) => svc.waitForDependencies())
} }
export async function get() { export async function get() {
return runPromise((svc) => svc.get()) return runPromise((svc) => svc.get())
} }
async function loadFile(filepath: string): Promise<Info> { async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath) const text = await ConfigPaths.readFile(filepath)
if (!text) return {} if (!text) return {}
return load(text, filepath).catch((error) => { return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error }) log.warn("failed to load tui config", { path: filepath, error })
return {} return {}
}) })
} }
async function load(text: string, configFilepath: string): Promise<Info> { async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigParse.load(Info, text, { return ConfigParse.load(Info, text, {
type: "path", type: "path",
path: configFilepath, path: configFilepath,
@@ -208,6 +215,5 @@ async function load(text: string, configFilepath: string): Promise<Info> {
log.warn("invalid tui config", { path: configFilepath, error }) log.warn("invalid tui config", { path: configFilepath, error })
return {} return {}
}) })
}
} }
export * as TuiConfig from "./tui"
-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"),