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
} }
/** /**
+20 -14
View File
@@ -17,6 +17,7 @@ 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"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo export const Info = TuiInfo
@@ -48,10 +49,6 @@ function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Sco
return "global" return "global"
} }
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) { function normalize(raw: Record<string, unknown>) {
const data = { ...raw } const data = { ...raw }
if (!("tui" in data)) return data if (!("tui" in data)) return data
@@ -91,30 +88,40 @@ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
} }
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) {
@@ -209,5 +216,4 @@ async function load(text: string, configFilepath: string): Promise<Info> {
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"),