Browse Source

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.
Dax Raad 1 ngày trước cách đây
mục cha
commit
39342b0e75

+ 0 - 1
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
 }
 
 /**

+ 167 - 161
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 {}
+      })
+  }
+}

+ 0 - 1
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"

+ 12 - 2
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"),