mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 13:21:17 +08:00
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:
@@ -26,7 +26,6 @@ const TuiLegacy = z
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
directories: string[]
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 Acc = {
|
||||
result: Info
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
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 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 interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
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 {
|
||||
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
const tui = data.tui
|
||||
delete data.tui
|
||||
return 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)
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
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(",")
|
||||
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
|
||||
}
|
||||
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
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 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",
|
||||
},
|
||||
)
|
||||
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 })
|
||||
|
||||
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")(() =>
|
||||
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
||||
)
|
||||
return Service.of({ get, waitForDependencies })
|
||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||
)
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
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() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
// 3. Project tui files, applied root-first so the closest file wins.
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
// 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)
|
||||
|
||||
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 {}
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
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)
|
||||
|
||||
// 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 {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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 {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user