diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index e655802b7bf..6ee62f2d5a6 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -1,7 +1,8 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/util/global"
-import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
+import { Flock } from "@opencode-ai/util/flock"
+import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
@@ -28,7 +29,6 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
- const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -49,38 +49,60 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
+ const withLock = (effect: Effect.Effect) =>
+ Effect.scoped(
+ Effect.uninterruptibleMask((restore) =>
+ Effect.gen(function* () {
+ const lock = yield* restore(
+ Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
+ )
+ yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
+ return yield* restore(effect)
+ }),
+ ),
+ )
- const get = Effect.fn("cli.config.get")(function* () {
- yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
- return Option.getOrElse(decode(yield* readJson()), () => empty)
- })
+ const get = Effect.fn("cli.config.get")(() =>
+ withLock(
+ Effect.gen(function* () {
+ const migration = yield* migrate.pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
+ ),
+ )
+ if (migration?.cause)
+ yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
+ if (migration?.info) return migration.info
+ return Option.getOrElse(decode(yield* readJson()), () => empty)
+ }),
+ ),
+ )
const update = Effect.fn("cli.config.update")((update: (draft: Draft) => void) =>
- lock
- .withPermits(1)(
- Effect.gen(function* () {
- yield* migrate
- const current = Option.getOrElse(decode(yield* readJson()), () => empty)
- const next = produce(current, update)
- const edits = changes(current, next)
- if (!edits.length) return current
- const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
- const updated = edits.reduce(
- (text, edit) =>
- applyEdits(
- text,
- modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
- ),
- text,
- )
- const errors: ParseError[] = []
- const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
- if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
- yield* write(updated.endsWith("\n") ? updated : updated + "\n")
- return config
- }),
- )
- .pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
+ withLock(
+ Effect.gen(function* () {
+ const migration = yield* migrate
+ if (migration?.cause) return yield* Effect.failCause(migration.cause)
+ const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
+ const next = produce(current, update)
+ const edits = changes(current, next)
+ if (!edits.length) return current
+ const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
+ const updated = edits.reduce(
+ (text, edit) =>
+ applyEdits(
+ text,
+ modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
+ ),
+ text,
+ )
+ const errors: ParseError[] = []
+ const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
+ if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
+ yield* write(updated.endsWith("\n") ? updated : updated + "\n")
+ return config
+ }),
+ ).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts
index f9677225b1b..44cb7b6e98a 100644
--- a/packages/cli/src/config/migrate.ts
+++ b/packages/cli/src/config/migrate.ts
@@ -1,13 +1,18 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
+import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
+import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
-import { parse, type ParseError } from "jsonc-parser"
+import { randomUUID } from "crypto"
+import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
-import type { Info } from "./schema"
+import { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
+const decodeInfo = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
+const LegacyKeybindTargets = new Set(Object.values(TuiKeybind.CommandMap))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
@@ -15,7 +20,60 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
- if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
+ const persist = Effect.fnUntraced(function* (text: string, info: Info) {
+ const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
+ const cause = yield* Effect.gen(function* () {
+ yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
+ yield* fs.writeFileString(temp, text, { mode: 0o600 })
+ yield* fs.rename(temp, input.file)
+ }).pipe(
+ Effect.as(undefined),
+ Effect.catchCause((cause) => Effect.succeed(cause)),
+ Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
+ )
+ return cause === undefined ? { info } : { info, cause }
+ })
+
+ if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
+ const text = yield* fs.readFileString(input.file)
+ const errors: ParseError[] = []
+ const value: any = parse(text, errors, { allowTrailingComma: true })
+ if (errors.length) return
+ const config = Option.getOrUndefined(decodeRecord(value))
+ if (config === undefined) return
+ const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
+ if (keybinds === undefined) return
+ const deduped = findKeybindObjects(text)
+ .slice(0, -1)
+ .reduce((text) => {
+ const property = findKeybindObjects(text)[0]
+ return property === undefined ? text : removeProperty(text, property)
+ }, text)
+ const updated = Object.keys(keybinds).reduce((text, name) => {
+ const target =
+ TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
+ (name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
+ if (target === undefined) return text
+ const properties = findKeybindProperties(text, name)
+ if (!properties.length) return text
+ const remove = !(target in Definitions) || (target !== name && target in keybinds)
+ // The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
+ const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
+ const property = findKeybindProperties(text, name)[0]
+ return property === undefined ? text : removeProperty(text, property)
+ }, text)
+ if (remove) return updated
+ if (target === name) return updated
+ const key = findKeybindProperties(updated, name)[0]?.children?.[0]
+ if (key === undefined) return text
+ return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
+ }, deduped)
+ if (updated === text) return
+ const updatedErrors: ParseError[] = []
+ const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
+ if (updatedErrors.length || info === undefined) return
+ return yield* persist(updated, info)
+ }
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
@@ -23,19 +81,59 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
- const temp = input.file + ".tmp"
- yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
- yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
- yield* fs.rename(temp, input.file)
- yield* Effect.logInfo("migrated cli config", {
- from: [
- legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
- kv === undefined ? undefined : path.join(input.state, "kv.json"),
- ].filter(Boolean),
- to: input.file,
- })
+ const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
+ if (result.cause === undefined)
+ yield* Effect.logInfo("migrated cli config", {
+ from: [
+ legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
+ kv === undefined ? undefined : path.join(input.state, "kv.json"),
+ ].filter(Boolean),
+ to: input.file,
+ })
+ return result
})
+function findKeybindProperties(text: string, name: string) {
+ const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
+ return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
+}
+
+function findKeybindObjects(text: string) {
+ const tree = parseTree(text)
+ if (tree === undefined) return []
+ return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
+}
+
+function removeProperty(text: string, property: Node) {
+ const properties = property.parent?.children ?? []
+ const index = properties.indexOf(property)
+ const end = property.offset + property.length
+ const next = properties[index + 1]
+ if (next) {
+ const comma = findComma(text, end, next.offset)
+ if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
+ }
+ const previous = properties[index - 1]
+ if (previous) {
+ const comma = findComma(text, previous.offset + previous.length, property.offset)
+ if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
+ }
+ const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
+ if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
+ return text.slice(0, property.offset) + text.slice(end)
+}
+
+function findComma(text: string, start: number, end: number) {
+ const scanner = createScanner(text, false)
+ scanner.setPosition(start)
+ while (true) {
+ scanner.scan()
+ const offset = scanner.getTokenOffset()
+ if (scanner.getTokenLength() === 0 || offset >= end) return
+ if (text[offset] === ",") return offset
+ }
+}
+
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
@@ -49,6 +147,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record {
+ const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
+ if (!(target in Definitions)) return []
+ return [[target, value]]
+ }),
+ )
return {
...(themeName !== undefined || themeMode !== undefined
@@ -59,7 +167,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record {
path.join(directory, "tui.json"),
JSON.stringify({
theme: "legacy",
- keybinds: { leader: "ctrl+o" },
+ keybinds: {
+ leader: "ctrl+o",
+ app_exit: "ctrl+q",
+ app_heap_snapshot: "ctrl+h",
+ input_paste: { key: "ctrl+v", preventDefault: false },
+ session_delete: false,
+ "dialog.select.next": "ctrl+n",
+ },
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
@@ -65,7 +74,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" },
- keybinds: { leader: "ctrl+o" },
+ keybinds: {
+ leader: "ctrl+o",
+ "app.exit": "ctrl+q",
+ "prompt.paste": { key: "ctrl+v", preventDefault: false },
+ "session.delete": false,
+ "dialog.select.next": "ctrl+n",
+ },
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
@@ -80,7 +95,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
- expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
+ expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
+ leader: "ctrl+o",
+ "app.exit": "ctrl+q",
+ "prompt.paste": { key: "ctrl+v", preventDefault: false },
+ "session.delete": false,
+ "dialog.select.next": "ctrl+n",
+ })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
@@ -141,6 +162,257 @@ test("preserves legacy cursor settings", async () => {
}
})
+test("migrates legacy keybind names in an existing cli.json", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ await Bun.write(
+ file,
+ `{
+ // Preserve this comment
+ "keybinds": {
+ // Session list shortcut
+ "session_list": "ctrl+l",
+ "app_heap_snapshot": "ctrl+h",
+ // Legacy delete shortcut
+ "session_delete": "ctrl+d",
+ // Canonical delete shortcut
+ "session.delete": "ctrl+x",
+ "app.heap_snapshot": "ctrl+shift+h"
+ }
+}
+`,
+ )
+
+ try {
+ const config = await run(
+ directory,
+ Effect.gen(function* () {
+ const service = yield* Config.Service
+ return yield* service.get()
+ }),
+ )
+
+ expect(config.keybinds).toEqual({
+ "session.list": "ctrl+l",
+ "session.delete": "ctrl+x",
+ })
+ const text = await Bun.file(file).text()
+ expect(text).toContain("// Preserve this comment")
+ expect(text).toContain("// Session list shortcut")
+ expect(text).toContain("// Legacy delete shortcut")
+ expect(text).toContain("// Canonical delete shortcut")
+ expect(parse(text).keybinds).toEqual({
+ "session.list": "ctrl+l",
+ "session.delete": "ctrl+x",
+ })
+ } finally {
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("uses migrated keybinds when persistence fails", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
+ const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
+ const fs = new Proxy(node, {
+ get(target, property, receiver) {
+ if (property === "rename") return () => Effect.die(new Error("read-only config"))
+ return Reflect.get(target, property, receiver)
+ },
+ })
+
+ try {
+ const config = await Effect.runPromise(
+ Effect.gen(function* () {
+ const service = yield* Config.Service
+ return yield* service.get()
+ }).pipe(
+ Effect.provide(Config.layer),
+ Effect.provide(Global.layerWith({ config: directory, state: directory })),
+ Effect.provideService(FileSystem.FileSystem, fs),
+ ),
+ )
+
+ expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
+ expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
+ expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
+ } finally {
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
+
+ try {
+ const config = await run(
+ directory,
+ Effect.gen(function* () {
+ const service = yield* Config.Service
+ return yield* service.get()
+ }),
+ )
+
+ expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
+ expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
+ } finally {
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("migrates and updates the effective duplicate top-level keybinds", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
+
+ try {
+ const config = await run(
+ directory,
+ Effect.gen(function* () {
+ const service = yield* Config.Service
+ expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
+ return yield* service.update((draft) => {
+ draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
+ })
+ }),
+ )
+
+ expect(config.keybinds).toEqual({ "session.delete": "changed" })
+ expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
+ } finally {
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("serializes migration and updates across processes", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ const started = path.join(directory, "started")
+ const release = path.join(directory, "release")
+ const migrateReady = path.join(directory, "migrate-ready")
+ const updateReady = path.join(directory, "update-ready")
+ await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
+ const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
+ const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
+ stdout: "ignore",
+ stderr: "pipe",
+ })
+
+ try {
+ await waitForFile(started, migrate.exited)
+ const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
+ stdout: "ignore",
+ stderr: "pipe",
+ })
+ try {
+ await waitForFile(updateReady, update.exited)
+ expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
+ await Bun.write(release, "")
+ const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
+ expect(await new Response(migrate.stderr).text()).toBe("")
+ expect(await new Response(update.stderr).text()).toBe("")
+ expect([migrateCode, updateCode]).toEqual([0, 0])
+ expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
+ } finally {
+ update.kill()
+ await update.exited
+ }
+ } finally {
+ await Bun.write(release, "")
+ migrate.kill()
+ await migrate.exited
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("config reads remain interruptible while waiting for the file lock", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ const locks = path.join(directory, "locks")
+ const held = await Flock.acquire(file, { dir: locks })
+
+ try {
+ const service = await Effect.runPromise(
+ Config.Service.pipe(
+ Effect.provide(Config.layer),
+ Effect.provide(Global.layerWith({ config: directory, state: directory })),
+ Effect.provide(NodeFileSystem.layer),
+ ),
+ )
+ const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
+ expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
+ } finally {
+ await held.release()
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("updates effective duplicate canonical keybinds", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ await Bun.write(
+ file,
+ `{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
+ )
+
+ try {
+ const config = await run(
+ directory,
+ Effect.gen(function* () {
+ const service = yield* Config.Service
+ expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
+ return yield* service.update((draft) => {
+ draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
+ })
+ }),
+ )
+
+ expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
+ expect(parse(await Bun.file(file).text()).keybinds).toEqual({
+ "session.delete": "changed",
+ "permission.mode": "changed",
+ })
+ } finally {
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
+test("removes orphaned keybinds without deleting trailing comments", async () => {
+ const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const file = path.join(directory, "cli.json")
+ await Bun.write(
+ file,
+ `{
+ "keybinds": {
+ "app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
+ "app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
+ },
+}
+`,
+ )
+
+ try {
+ const config = await run(
+ directory,
+ Effect.gen(function* () {
+ const service = yield* Config.Service
+ return yield* service.get()
+ }),
+ )
+
+ expect(config.keybinds).toEqual({})
+ const text = await Bun.file(file).text()
+ expect(text).toContain("/* Keep legacy explanation */")
+ expect(text).toContain("/* Keep canonical explanation */")
+ expect(parse(text).keybinds).toEqual({})
+ } finally {
+ await Bun.$`rm -rf ${directory}`
+ }
+})
+
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
@@ -167,3 +439,15 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}`
}
})
+
+async function waitForFile(file: string, exited: Promise) {
+ const found = await Promise.race([
+ (async () => {
+ while (!(await Bun.file(file).exists())) await Bun.sleep(10)
+ return true
+ })(),
+ exited.then(() => false),
+ Bun.sleep(5000).then(() => false),
+ ])
+ if (!found) throw new Error(`timed out waiting for ${file}`)
+}
diff --git a/packages/cli/test/fixture/config-concurrency.ts b/packages/cli/test/fixture/config-concurrency.ts
new file mode 100644
index 00000000000..43e84d94eac
--- /dev/null
+++ b/packages/cli/test/fixture/config-concurrency.ts
@@ -0,0 +1,42 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { Global } from "@opencode-ai/util/global"
+import { Effect, FileSystem } from "effect"
+import { Config } from "../../src/config"
+
+const [mode, directory, started, release, ready] = process.argv.slice(2)
+if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
+if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
+
+const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
+const state = { writes: 0 }
+const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
+ state.writes++
+ if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
+ return Effect.gen(function* () {
+ yield* Effect.promise(() => Bun.write(started, ""))
+ while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
+ yield* node.writeFileString(target, data, options)
+ })
+}
+const fs = new Proxy(node, {
+ get(target, property, receiver) {
+ if (property === "writeFileString") return writeFileString
+ return Reflect.get(target, property, receiver)
+ },
+})
+const service = await Effect.runPromise(
+ Config.Service.pipe(
+ Effect.provide(Config.layer),
+ Effect.provide(Global.layerWith({ config: directory, state: directory })),
+ Effect.provideService(FileSystem.FileSystem, fs),
+ ),
+)
+
+await Bun.write(ready, "")
+if (mode === "migrate") await Effect.runPromise(service.get())
+if (mode === "update")
+ await Effect.runPromise(
+ service.update((draft) => {
+ draft.mouse = false
+ }),
+ )
diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts
new file mode 100644
index 00000000000..a93990d0334
--- /dev/null
+++ b/packages/cli/test/mini-config.test.ts
@@ -0,0 +1,69 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { Global } from "@opencode-ai/util/global"
+import { Effect, Option } from "effect"
+import { expect, mock, test } from "bun:test"
+import { mkdir, rm } from "node:fs/promises"
+import path from "node:path"
+import { Config } from "../src/config"
+import type { MiniCommandInput } from "../src/mini"
+import { OPENCODE_VERSION } from "../src/version"
+
+test("mini handler passes resolved CLI keybinds to the runtime", async () => {
+ const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+ const configDirectory = path.join(root, "config")
+ const stateDirectory = path.join(root, "state")
+ await mkdir(configDirectory, { recursive: true })
+ await Bun.write(
+ path.join(configDirectory, "cli.json"),
+ JSON.stringify({
+ keybinds: { "composer.subagent.interrupt": "ctrl+i" },
+ leader: { timeout: 321 },
+ }),
+ )
+ let received: MiniCommandInput["tuiConfig"]
+ const mini = await import("../src/mini")
+ mock.module("../src/mini", () => ({
+ ...mini,
+ validateMiniTerminal() {},
+ runMini(input: Pick) {
+ received = input.tuiConfig
+ return Promise.resolve()
+ },
+ }))
+ const handler = (await import("../src/commands/handlers/mini")).default
+ const server = Bun.serve({
+ port: 0,
+ fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
+ })
+
+ try {
+ await Effect.runPromise(
+ handler({
+ server: Option.some(server.url.toString()),
+ standalone: false,
+ continue: false,
+ session: Option.none(),
+ fork: false,
+ replay: true as never,
+ replayLimit: Option.none(),
+ model: Option.none(),
+ agent: Option.none(),
+ prompt: Option.none(),
+ demo: false,
+ }).pipe(
+ Effect.provide(Config.layer),
+ Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
+ Effect.provide(NodeFileSystem.layer),
+ Effect.scoped,
+ ),
+ )
+
+ const config = await received
+ expect(config?.leader.timeout).toBe(321)
+ expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
+ } finally {
+ server.stop(true)
+ mock.restore()
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index c5a299b94b6..88bb8ab40c6 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -150,6 +150,7 @@ const appBindingCommands = [
"variant.cycle",
"variant.list",
"provider.connect",
+ "opencode.settings",
"opencode.status",
"server.pair",
"service.restart",
@@ -168,6 +169,7 @@ const appBindingCommands = [
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
+ "permission.mode",
] as const
export type TuiInput = {
diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx
index b0bfbab5b0d..52f52ffeb9a 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -234,10 +234,10 @@ export type Resolved = Omit values.indexOf(value) === index)
.join(",")
}
@@ -254,7 +254,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
sounds: input.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
- commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts
index 475f5120f88..bef9a0f0539 100644
--- a/packages/tui/src/config/keybind.ts
+++ b/packages/tui/src/config/keybind.ts
@@ -1,2 +1,332 @@
-export * from "./v1/keybind"
-export * as TuiKeybind from "./v1/keybind"
+export * as TuiKeybind from "./keybind"
+
+import type { KeyEvent, Renderable } from "@opentui/core"
+import type { Binding } from "@opentui/keymap"
+import type { BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
+import { Schema } from "effect"
+
+const KeyStroke = Schema.Struct({
+ name: Schema.String,
+ ctrl: Schema.optional(Schema.Boolean),
+ shift: Schema.optional(Schema.Boolean),
+ meta: Schema.optional(Schema.Boolean),
+ super: Schema.optional(Schema.Boolean),
+ hyper: Schema.optional(Schema.Boolean),
+})
+
+const BindingObject = Schema.StructWithRest(
+ Schema.Struct({
+ key: Schema.Union([Schema.String, KeyStroke]),
+ event: Schema.optional(Schema.Literals(["press", "release"])),
+ preventDefault: Schema.optional(Schema.Boolean),
+ fallthrough: Schema.optional(Schema.Boolean),
+ }),
+ [Schema.Record(Schema.String, Schema.Unknown)],
+)
+
+const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
+export const BindingValueSchema = Schema.Union([
+ Schema.Literal(false),
+ Schema.Literal("none"),
+ BindingItem,
+ Schema.Array(BindingItem),
+])
+export type BindingValueSchema = Schema.Schema.Type
+
+type Definition = {
+ default: BindingValueSchema
+ description: string
+}
+
+export const LeaderDefault = "ctrl+x"
+
+const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
+
+export const Definitions = {
+ leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
+
+ "app.exit": keybind("ctrl+c,ctrl+d,q", "Exit the application"),
+ "app.debug": keybind("none", "Toggle debug panel"),
+ "app.console": keybind("none", "Toggle console"),
+ "app.scrap": keybind("none", "Open scrap screen"),
+ "app.toggle.animations": keybind("none", "Toggle animations"),
+ "app.toggle.file_context": keybind("none", "Toggle file context"),
+ "app.toggle.diffwrap": keybind("none", "Toggle diff wrapping"),
+ "app.toggle.paste_summary": keybind("none", "Toggle paste summary"),
+ "command.palette.show": keybind("ctrl+p", "List available commands"),
+ "help.show": keybind("none", "Open help dialog"),
+ "docs.open": keybind("none", "Open documentation"),
+ "opencode.settings": keybind("none", "Open settings"),
+ "server.pair": keybind("none", "Pair device"),
+ "service.restart": keybind("none", "Restart service"),
+ "permission.mode": keybind("none", "Toggle auto-approve permissions"),
+ "diff.open": keybind("none", "Open diff viewer"),
+ "diff.close": keybind("escape,q", "Close diff viewer"),
+ "diff.down": keybind("j,down", "Move diff viewer down"),
+ "diff.up": keybind("k,up", "Move diff viewer up"),
+ "diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
+ "diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
+ "diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
+ "diff.expand": keybind("right", "Expand diff viewer item"),
+ "diff.expand_all": keybind("E", "Expand all diff viewer folders"),
+ "diff.collapse": keybind("left", "Collapse diff viewer item"),
+ "diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
+ "diff.next_hunk": keybind("]", "Jump to next diff hunk"),
+ "diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
+ "diff.next_file": keybind("n", "Jump to next diff file"),
+ "diff.previous_file": keybind("p", "Jump to previous diff file"),
+ "diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
+ "diff.single_patch": keybind("s", "Toggle single patch view"),
+ "diff.switch_source": keybind("d", "Switch diff viewer source"),
+ "diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
+ "diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
+ "diff.help": keybind("?", "Show more diff viewer shortcuts"),
+
+ "prompt.editor": keybind("e", "Open external editor"),
+ "theme.switch": keybind("t", "List available themes"),
+ "theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
+ "theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
+ "session.sidebar.toggle": keybind("b", "Toggle sidebar"),
+ "session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
+ "opencode.status": keybind("s", "View status"),
+ "opencode.debug": keybind("none", "View debug info"),
+
+ "session.export": keybind("x", "Export session to editor"),
+ "session.copy": keybind("none", "Copy session transcript"),
+ "session.move": keybind("none", "Move session"),
+ "session.new": keybind("n", "Create a new session"),
+ "session.list": keybind("l", "List all sessions"),
+ "session.tab.next": keybind("ctrl+tab,right", "Switch to next open session tab"),
+ "session.tab.previous": keybind("ctrl+shift+tab,left", "Switch to previous open session tab"),
+ "session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
+ "session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
+ "session.tab.next_unread": keybind("down", "Switch to next unread session tab"),
+ "session.tab.previous_unread": keybind("up", "Switch to previous unread session tab"),
+ "session.tab.close": keybind("w", "Close current session tab"),
+ "session.timeline": keybind("g", "Show session timeline"),
+ "session.fork": keybind("none", "Fork session from message"),
+ "session.rename": keybind("ctrl+r", "Rename session"),
+ "session.delete": keybind("ctrl+d", "Delete session"),
+ "session.share": keybind("none", "Share current session"),
+ "session.unshare": keybind("none", "Unshare current session"),
+ "session.interrupt": keybind("escape", "Interrupt current session"),
+ "session.background": keybind("ctrl+b", "Background blocking session tools"),
+ "session.compact": keybind("c", "Compact the session"),
+ "session.cd": keybind("none", "Change working directory"),
+ "session.queued_prompts": keybind("q", "Manage queued prompts"),
+ "queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
+ "session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
+ "session.child.first": keybind("down", "Toggle subagent picker"),
+ "session.child.next": keybind("right", "Go to next child session"),
+ "session.child.previous": keybind("left", "Go to previous child session"),
+ "session.parent": keybind("up", "Go to parent session"),
+ "session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"),
+ "session.quick_switch.1": keybind("1", "Switch to session in quick slot 1"),
+ "session.quick_switch.2": keybind("2", "Switch to session in quick slot 2"),
+ "session.quick_switch.3": keybind("3", "Switch to session in quick slot 3"),
+ "session.quick_switch.4": keybind("4", "Switch to session in quick slot 4"),
+ "session.quick_switch.5": keybind("5", "Switch to session in quick slot 5"),
+ "session.quick_switch.6": keybind("6", "Switch to session in quick slot 6"),
+ "session.quick_switch.7": keybind("7", "Switch to session in quick slot 7"),
+ "session.quick_switch.8": keybind("8", "Switch to session in quick slot 8"),
+ "session.quick_switch.9": keybind("9", "Switch to session in quick slot 9"),
+ "session.tab.select.1": keybind("1,ctrl+1", "Switch to session tab 1"),
+ "session.tab.select.2": keybind("2,ctrl+2", "Switch to session tab 2"),
+ "session.tab.select.3": keybind("3,ctrl+3", "Switch to session tab 3"),
+ "session.tab.select.4": keybind("4,ctrl+4", "Switch to session tab 4"),
+ "session.tab.select.5": keybind("5,ctrl+5", "Switch to session tab 5"),
+ "session.tab.select.6": keybind("6,ctrl+6", "Switch to session tab 6"),
+ "session.tab.select.7": keybind("7,ctrl+7", "Switch to session tab 7"),
+ "session.tab.select.8": keybind("8,ctrl+8", "Switch to session tab 8"),
+ "session.tab.select.9": keybind("9,ctrl+9", "Switch to session tab 9"),
+
+ "stash.delete": keybind("ctrl+d", "Delete stash entry"),
+ "model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
+ "model.dialog.favorite": keybind("ctrl+f", "Toggle model favorite status"),
+ "model.list": keybind("m", "List available models"),
+ "model.cycle_recent": keybind("f2", "Next recently used model"),
+ "model.cycle_recent_reverse": keybind("shift+f2", "Previous recently used model"),
+ "model.cycle_favorite": keybind("none", "Next favorite model"),
+ "model.cycle_favorite_reverse": keybind("none", "Previous favorite model"),
+ "mcp.list": keybind("none", "List MCP servers"),
+ "provider.connect": keybind("none", "Connect integration"),
+ "agent.list": keybind("a", "List agents"),
+ "agent.cycle": keybind("shift+tab", "Next agent"),
+ "agent.cycle.reverse": keybind("none", "Previous agent"),
+ "variant.cycle": keybind("ctrl+t", "Cycle model variants"),
+ "variant.list": keybind("none", "List model variants"),
+
+ "session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
+ "session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
+ "session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"),
+ "session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"),
+ "session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"),
+ "session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
+ "session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
+ "session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
+ "session.message.next": keybind("alt+down", "Navigate to next message"),
+ "session.message.previous": keybind("alt+up", "Navigate to previous message"),
+ "session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
+ "session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
+ "session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
+ "messages.copy": keybind("y", "Copy message"),
+ "session.undo": keybind("u", "Undo message"),
+ "session.redo": keybind("r", "Redo message"),
+ "session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
+
+ "prompt.submit": keybind("none", "Submit prompt"),
+ "prompt.queue": keybind("alt+return", "Queue prompt"),
+ "prompt.editor_context.clear": keybind("none", "Clear editor context"),
+ "prompt.skills": keybind("none", "Open skill selector"),
+ "prompt.stash": keybind("none", "Stash prompt"),
+ "prompt.stash.pop": keybind("none", "Pop stashed prompt"),
+ "prompt.stash.list": keybind("none", "List stashed prompts"),
+
+ "prompt.clear": keybind("ctrl+c", "Clear input field"),
+ "prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
+ "input.submit": keybind("return", "Submit input"),
+ "input.newline": keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
+ "input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
+ "input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
+ "input.move.up": keybind("up", "Move cursor up in input"),
+ "input.move.down": keybind("down", "Move cursor down in input"),
+ "input.select.left": keybind("shift+left", "Select left in input"),
+ "input.select.right": keybind("shift+right", "Select right in input"),
+ "input.select.up": keybind("shift+up", "Select up in input"),
+ "input.select.down": keybind("shift+down", "Select down in input"),
+ "input.line.home": keybind("ctrl+a", "Move to start of line in input"),
+ "input.line.end": keybind("ctrl+e", "Move to end of line in input"),
+ "input.select.line.home": keybind("ctrl+shift+a", "Select to start of line in input"),
+ "input.select.line.end": keybind("ctrl+shift+e", "Select to end of line in input"),
+ "input.visual.line.home": keybind("alt+a", "Move to start of visual line in input"),
+ "input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
+ "input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
+ "input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
+ "input.buffer.home": keybind("home", "Move to start of buffer in input"),
+ "input.buffer.end": keybind("end", "Move to end of buffer in input"),
+ "input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
+ "input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
+ "input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
+ "input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"),
+ "input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"),
+ "input.backspace": keybind("backspace,shift+backspace", "Backspace in input"),
+ "input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
+ "input.undo": keybind("ctrl+-,super+z", "Undo in input"),
+ "input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"),
+ "input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
+ "input.word.backward": keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
+ "input.select.word.forward": keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
+ "input.select.word.backward": keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
+ "input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
+ "input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
+ "input.select.all": keybind("super+a", "Select all in input"),
+ "prompt.history.previous": keybind("up", "Previous history item"),
+ "prompt.history.next": keybind("down", "Next history item"),
+
+ "composer.subagent.up": keybind("up", "Previous subagent"),
+ "composer.subagent.down": keybind("down", "Next subagent"),
+ "composer.subagent.select": keybind("return", "Navigate to subagent"),
+ "composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
+ "composer.shell.up": keybind("up", "Previous shell"),
+ "composer.shell.down": keybind("down", "Next shell"),
+ "composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
+
+ "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
+ "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
+ "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
+ "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
+ "dialog.select.home": keybind("home", "Move to first dialog item"),
+ "dialog.select.end": keybind("end", "Move to last dialog item"),
+ "dialog.select.submit": keybind("return", "Submit selected dialog item"),
+ "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
+ "dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
+ "dialog.move_session.new": keybind("ctrl+m", "New project copy"),
+ "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
+ "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
+ "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
+ "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
+ "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
+ "prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
+ "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
+ "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
+ "plugins.toggle": keybind("space", "Toggle plugin"),
+ "dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
+ "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
+
+ "terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
+ "terminal.title.toggle": keybind("none", "Toggle terminal title"),
+ "plugins.list": keybind("none", "Open plugin manager dialog"),
+ "plugins.install": keybind("none", "Install plugin"),
+
+ "which-key.toggle": keybind("ctrl+alt+k", "Toggle which-key panel"),
+ "which-key.layout.toggle": keybind("ctrl+alt+shift+k", "Switch which-key layout"),
+ "which-key.pending.toggle": keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
+ "which-key.group.previous": keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
+ "which-key.group.next": keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
+ "which-key.scroll.up": keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
+ "which-key.scroll.down": keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
+ "which-key.page.up": keybind("ctrl+alt+pageup", "Page which-key up"),
+ "which-key.page.down": keybind("ctrl+alt+pagedown", "Page which-key down"),
+ "which-key.home": keybind("ctrl+alt+home", "Jump to first which-key binding"),
+ "which-key.end": keybind("ctrl+alt+end", "Jump to last which-key binding"),
+} satisfies Record
+
+type KeybindName = keyof typeof Definitions
+const KeybindNames = new Set(Object.keys(Definitions))
+
+export const KeybindOverrides = Schema.Struct(
+ Object.fromEntries(
+ Object.entries(Definitions).map(([name, item]) => [
+ name,
+ Schema.optional(BindingValueSchema).annotate({ description: item.description }),
+ ]),
+ ),
+).annotate({ description: "TUI keybinding overrides" })
+export const Descriptions = Object.fromEntries(
+ Object.entries(Definitions).map(([name, item]) => [name, item.description]),
+) as Record
+
+export type Keybinds = { [K in KeybindName]: BindingValueSchema }
+export type KeybindOverrides = Partial
+export type BindingLookupView = {
+ readonly bindings: readonly Binding[]
+ get(command: string): readonly Binding[]
+ has(command: string): boolean
+ gather(name: string, commands: readonly string[]): readonly Binding[]
+ pick(name: string, commands: readonly string[]): Binding[]
+ omit(name: string, commands: readonly string[]): Binding[]
+}
+
+export function toBindingConfig(keybinds: Keybinds): BindingConfig {
+ return Object.fromEntries(Object.entries(keybinds)) as BindingConfig
+}
+
+const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
+
+export function defaultValue(name: KeybindName) {
+ return Definitions[name].default
+}
+
+export function parse(keybinds: KeybindOverrides): Keybinds {
+ const invalid = unknownKeys(keybinds)
+ if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
+ return Object.fromEntries(
+ Object.entries(Definitions).map(([name, item]) => [
+ name,
+ decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
+ ]),
+ ) as Keybinds
+}
+
+export const Keybinds = { parse }
+
+export function unknownKeys(input: object) {
+ return Object.keys(input).filter((key) => !KeybindNames.has(key))
+}
+
+export function bindingDefaults(): BindingDefaults {
+ return ({ command, binding }) => {
+ if (binding.desc !== undefined) return
+ return { desc: Descriptions[command as KeybindName] }
+ }
+}
diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx
index f5b06c4f5e6..f71d8604ead 100644
--- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx
+++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx
@@ -427,7 +427,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.down",
title: "Move diff viewer down",
group: "VCS",
- bind: "j,down",
run: focusRunner({
files() {
moveFileSelection(1)
@@ -442,7 +441,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.up",
title: "Move diff viewer up",
group: "VCS",
- bind: "k,up",
run: focusRunner({
files() {
moveFileSelection(-1)
@@ -457,7 +455,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.down",
title: "Page diff viewer down",
group: "VCS",
- bind: "pagedown,ctrl+f",
run: focusRunner({
files() {
moveFileSelection(8)
@@ -472,7 +469,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.up",
title: "Page diff viewer up",
group: "VCS",
- bind: "pageup,ctrl+b",
run: focusRunner({
files() {
moveFileSelection(-8)
@@ -578,7 +574,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
group: "VCS",
- bind: "m",
run() {
toggleSelectedFileReviewed()
},
diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx
index 2af744f47b7..1770758cbfd 100644
--- a/packages/tui/src/mini/footer.view.tsx
+++ b/packages/tui/src/mini/footer.view.tsx
@@ -191,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const subagentShortcut = () => shortcut("session.child.first")
const queuedShortcut = () => shortcut("session.queued_prompts")
const backgroundShortcut = () => shortcut("session.background")
- const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
+ const subagentInterruptShortcut = () => shortcut("composer.subagent.interrupt")
const interrupt = () => shortcut("session.interrupt")
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
const clearShortcut = () => shortcut("prompt.clear")
@@ -610,10 +610,9 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
- id: "subagent.interrupt",
+ id: "composer.subagent.interrupt",
title: "Interrupt subagent",
group: "Session",
- bind: "ctrl+d",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx
index 6c0f02ce2ac..9a5092399f1 100644
--- a/packages/tui/src/routes/session/composer/shell-tab.tsx
+++ b/packages/tui/src/routes/session/composer/shell-tab.tsx
@@ -55,7 +55,6 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.up",
title: "Previous shell",
group: "Composer",
- bind: "up",
run() {
if (store.selected === 0) {
composer.close()
@@ -68,7 +67,6 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.down",
title: "Next shell",
group: "Composer",
- bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@@ -79,7 +77,6 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.kill",
title: "Kill shell command",
group: "Composer",
- bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry) return
diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx
index 70cbd39df04..964003e4945 100644
--- a/packages/tui/src/routes/session/composer/subagents-tab.tsx
+++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx
@@ -169,7 +169,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.up",
title: "Previous subagent",
group: "Composer",
- bind: "up",
run() {
if (store.selected === 0) {
composer.close()
@@ -182,7 +181,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.down",
title: "Next subagent",
group: "Composer",
- bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@@ -193,7 +191,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.select",
title: "Navigate to subagent",
group: "Composer",
- bind: "return",
run() {
const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
@@ -213,7 +210,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
group: "Composer",
- bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx
index e6a57e8cf26..1e1c8cb9cf7 100644
--- a/packages/tui/src/ui/dialog-prompt.tsx
+++ b/packages/tui/src/ui/dialog-prompt.tsx
@@ -40,7 +40,6 @@ export function DialogPrompt(props: DialogPromptProps) {
{
id: "dialog.prompt.submit",
title: "Submit dialog prompt",
- bind: "return",
group: "Dialog",
run: confirm,
},
diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx
index ef31b879544..1ac5cde0c29 100644
--- a/packages/tui/test/app-lifecycle.test.tsx
+++ b/packages/tui/test/app-lifecycle.test.tsx
@@ -294,3 +294,63 @@ test("session startup prompt is submitted exactly once", async () => {
await server.stop()
}
})
+
+test("configured app bindings execute settings and permission commands", async () => {
+ const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
+ setup.renderer.start()
+ const ready = Promise.withResolvers()
+ const events = createEventStream()
+ const calls = createFetch(undefined, events)
+ const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
+
+ try {
+ const { run } = await import("../src/app")
+ const task = Effect.runPromise(
+ run({
+ app: { name: "test", version: "test", channel: "test" },
+ server: { endpoint: { url: server.url.toString() } },
+ config: {
+ get: async () => ({
+ animations: false,
+ keybinds: { "opencode.settings": "f6", "permission.mode": "f7" },
+ }),
+ update: async () => ({}),
+ },
+ packages: { resolve: async () => undefined },
+ args: {},
+ terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
+ log: () => {},
+ }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
+ )
+ await ready.promise
+ await setup.waitForFrame((frame) => frame.includes("commands"))
+
+ setup.mockInput.pressKey("F6")
+ const settings = await setup.waitForFrame((frame) => frame.includes("Settings"))
+ expect(settings).toContain("Color mode")
+ expect(settings).toContain("Animations")
+
+ setup.mockInput.pressEscape()
+ await setup.waitForFrame((frame) => !frame.includes("Settings"))
+ setup.mockInput.pressKey("F7")
+ await setup.renderOnce()
+ setup.mockInput.pressKey("p", { ctrl: true })
+ await setup.waitForFrame((frame) => frame.includes("Commands"))
+ setup.mockInput.pressKey("END")
+ const commands = await setup.waitForFrame(
+ (frame) => {
+ if (frame.includes("Disable auto-approve permissions")) return true
+ setup.mockInput.pressArrow("up")
+ return false
+ },
+ { maxPasses: 100 },
+ )
+ expect(commands).not.toContain("Enable auto-approve permissions")
+
+ setup.renderer.destroy()
+ await task
+ } finally {
+ if (!setup.renderer.isDestroyed) setup.renderer.destroy()
+ await server.stop()
+ }
+})
diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx
new file mode 100644
index 00000000000..fbccd5b3f20
--- /dev/null
+++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx
@@ -0,0 +1,190 @@
+/** @jsxImportSource @opentui/solid */
+import { testRender } from "@opentui/solid"
+import { expect, test } from "bun:test"
+import { onMount } from "solid-js"
+import { ConfigProvider } from "../../../src/config"
+import type { TuiKeybind } from "../../../src/config/keybind"
+import { ClientProvider } from "../../../src/context/client"
+import { DataProvider, useData } from "../../../src/context/data"
+import { Keymap } from "../../../src/context/keymap"
+import { LocationProvider } from "../../../src/context/location"
+import { RouteProvider, useRoute } from "../../../src/context/route"
+import { ThemeProvider } from "../../../src/context/theme"
+import { Composer } from "../../../src/routes/session/composer"
+import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
+import { TestTuiContexts } from "../../fixture/tui-environment"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+
+const sessions = {
+ parent: session("parent", "Parent"),
+ "child-a": session("child-a", "First", "parent"),
+ "child-b": session("child-b", "Second", "parent"),
+}
+
+const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
+
+async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial) {
+ const events = createEventStream()
+ const interrupted: string[] = []
+ const removed: string[] = []
+ const ready = Promise.withResolvers()
+ let closed = 0
+ let dispatch!: ReturnType["dispatch"]
+ let route!: ReturnType
+ const calls = createFetch((url, request) => {
+ if (url.pathname === "/api/session/active")
+ return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
+ const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
+ if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] })
+ const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1]
+ if (interruptID && request.method === "POST") {
+ interrupted.push(interruptID)
+ return new Response(null, { status: 204 })
+ }
+ if (url.pathname === "/api/shell" && request.method === "GET") {
+ const requestDirectory = url.searchParams.get("location[directory]") ?? directory
+ return json({
+ location: { directory: requestDirectory, project: { id: "proj_test", directory: requestDirectory } },
+ data: shells,
+ })
+ }
+ const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1]
+ if (shellID && request.method === "DELETE") {
+ removed.push(shellID)
+ return new Response(null, { status: 204 })
+ }
+ }, events)
+
+ function Content() {
+ const data = useData()
+ route = useRoute()
+ dispatch = Keymap.use().dispatch
+ onMount(() => {
+ void Promise.all([
+ data.session.sync("parent"),
+ data.session.sync("child-a"),
+ data.session.sync("child-b"),
+ data.shell.sync(),
+ ])
+ .then(() => wait(() => data.session.status("child-a") === "running"))
+ .then(() => ready.resolve(), ready.reject)
+ })
+ return closed++} />
+ }
+
+ const app = await testRender(
+ () => (
+
+
+
+
+
+
+
+ ({}) }}>
+
+
+
+
+
+
+
+
+
+ ),
+ { width: 100, height: 20, kittyKeyboard: true },
+ )
+ await ready.promise
+ await app.renderOnce()
+ return {
+ app,
+ interrupted,
+ removed,
+ route: () => route.data,
+ dispatch: (command: string) => dispatch(command),
+ closed: () => closed,
+ }
+}
+
+test("disabled subagent bindings have no component fallbacks", async () => {
+ const composer = await renderComposer("subagents", {
+ "composer.subagent.up": "none",
+ "composer.subagent.down": "none",
+ "composer.subagent.select": "none",
+ "composer.subagent.interrupt": "none",
+ })
+ try {
+ expect(composer.app.captureCharFrame()).toContain("First")
+ composer.app.mockInput.pressArrow("up")
+ composer.app.mockInput.pressEnter()
+ composer.app.mockInput.pressKey("d", { ctrl: true })
+ await composer.app.renderOnce()
+ expect(composer.closed()).toBe(0)
+ expect(composer.route()).toMatchObject({ type: "session", sessionID: "parent" })
+ expect(composer.interrupted).toEqual([])
+
+ composer.app.mockInput.pressArrow("down")
+ composer.dispatch("composer.subagent.select")
+ expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" })
+ } finally {
+ composer.app.renderer.destroy()
+ }
+})
+
+test("disabled shell bindings have no component fallbacks", async () => {
+ const composer = await renderComposer("shell", {
+ "composer.shell.up": "none",
+ "composer.shell.down": "none",
+ "composer.shell.kill": "none",
+ })
+ try {
+ expect(composer.app.captureCharFrame()).toContain("bun test")
+ composer.app.mockInput.pressArrow("up")
+ composer.app.mockInput.pressKey("d", { ctrl: true })
+ await composer.app.renderOnce()
+ expect(composer.closed()).toBe(0)
+ expect(composer.removed).toEqual([])
+
+ composer.app.mockInput.pressArrow("down")
+ composer.dispatch("composer.shell.kill")
+ await wait(() => composer.removed.length === 1)
+ expect(composer.removed).toEqual(["sh-a"])
+ } finally {
+ composer.app.renderer.destroy()
+ }
+})
+
+function session(id: string, title: string, parentID?: string) {
+ return {
+ id,
+ projectID: "proj_test",
+ title,
+ agent: "build",
+ location: { directory },
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: 0, updated: 0 },
+ ...(parentID ? { parentID } : {}),
+ }
+}
+
+function shell(id: string, command: string) {
+ return {
+ id,
+ status: "running" as const,
+ command,
+ cwd: directory,
+ shell: "/bin/sh",
+ file: `/tmp/${id}`,
+ metadata: { sessionID: "parent" },
+ time: { started: 1 },
+ }
+}
+
+async function wait(fn: () => boolean, timeout = 2000) {
+ const start = Date.now()
+ while (!fn()) {
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+ await Bun.sleep(10)
+ }
+}
diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx
index b2257860c9c..135991d7374 100644
--- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx
+++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx
@@ -87,8 +87,8 @@ test("dialog prompt submit wins when return is also input newline", async () =>
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
- input_submit: "super+return",
- input_newline: "return,shift+return,alt+return,ctrl+j",
+ "input.submit": "super+return",
+ "input.newline": "return,shift+return,alt+return,ctrl+j",
},
onConfirm: (value) => confirmed.push(value),
})
@@ -113,7 +113,7 @@ test("dialog prompt submit can be rebound separately from input submit", async (
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {
- input_submit: "return",
+ "input.submit": "return",
"dialog.prompt.submit": "ctrl+y",
},
onConfirm: (value) => confirmed.push(value),
@@ -135,3 +135,29 @@ test("dialog prompt submit can be rebound separately from input submit", async (
await prompt.cleanup()
}
})
+
+test("dialog prompt submit can be disabled", async () => {
+ await using tmp = await tmpdir()
+ const confirmed: string[] = []
+ const prompt = await mountPrompt({
+ root: tmp.path,
+ keybinds: {
+ "input.submit": "return",
+ "dialog.prompt.submit": "none",
+ },
+ onConfirm: (value) => confirmed.push(value),
+ })
+
+ try {
+ await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+ const textarea = prompt.app.renderer.currentFocusedEditor
+ if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
+
+ prompt.app.mockInput.pressEnter()
+
+ expect(confirmed).toEqual([])
+ expect(textarea.plainText).toBe("draft")
+ } finally {
+ await prompt.cleanup()
+ }
+})
diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx
index e9b393683ec..8588a77da35 100644
--- a/packages/tui/test/cli/tui/diff-viewer.test.tsx
+++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx
@@ -14,7 +14,7 @@ import type {
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
import { ConfigProvider } from "../../../src/config"
-import { TuiKeybind } from "../../../src/config/keybind"
+import type { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -22,6 +22,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
+import { createSignal } from "solid-js"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
@@ -49,7 +50,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
})
test("shows an error instead of an empty diff when loading fails", async () => {
- const viewer = await renderDiffViewer([], 20, undefined, true)
+ const viewer = await renderDiffViewer([], { fail: true })
try {
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
@@ -59,7 +60,7 @@ test("shows an error instead of an empty diff when loading fails", async () => {
})
test("uses the active location when opened outside a session", async () => {
- const viewer = await renderDiffViewer([], 20, { type: "home" })
+ const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
try {
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/default" },
@@ -72,66 +73,35 @@ test("uses the active location when opened outside a session", async () => {
})
test("brackets navigate diff hunks", async () => {
- const viewer = await renderDiffViewer(
- [
- {
- file: "src/file.ts",
- additions: 3,
- deletions: 3,
- status: "modified",
- patch: `--- a/src/file.ts
-+++ b/src/file.ts
-@@ -1,3 +1,3 @@
- const first = true
--const oldFirst = true
-+const newFirst = true
- const afterFirst = true
-@@ -20,3 +20,3 @@
- const second = true
--const oldSecond = true
-+const newSecond = true
- const afterSecond = true
-@@ -40,3 +40,3 @@
- const third = true
--const oldThird = true
-+const newThird = true
- const afterThird = true`,
- },
- ],
- 12,
- )
+ const viewer = await renderDiffViewer(hunkDiff, { height: 12 })
try {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
await viewer.app.flush()
- expect(viewer.app.captureCharFrame()).toContain("@@ -20,3 +20,3 @@")
expect(countDiffs(viewer.app.renderer.root)).toBe(3)
const scroll = findScrollBox(viewer.app.renderer.root)!
const initial = scroll.scrollTop
- expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
- expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
-
- viewer.commands.get("diff.next_hunk")!.run()
+ viewer.app.mockInput.pressKey("]")
await viewer.app.renderOnce()
const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial)
- viewer.commands.get("diff.next_hunk")!.run()
+ viewer.app.mockInput.pressKey("]")
await viewer.app.renderOnce()
const second = scroll.scrollTop
expect(second).toBeGreaterThan(first)
- viewer.commands.get("diff.previous_hunk")!.run()
+ viewer.app.mockInput.pressKey("[")
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
- viewer.commands.get("diff.next_hunk")!.run()
+ viewer.app.mockInput.pressKey("]")
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial)
- viewer.commands.get("diff.next_hunk")!.run()
+ viewer.app.mockInput.pressKey("]")
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
} finally {
@@ -139,13 +109,49 @@ test("brackets navigate diff hunks", async () => {
}
})
-async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
+test("disabled diff keybinds have no component fallbacks", async () => {
+ const viewer = await renderDiffViewer(hunkDiff, {
+ height: 12,
+ keybinds: disabledDiffKeybinds,
+ })
+ try {
+ await viewer.app.waitForFrame((frame) => frame.includes("const first"))
+ await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
+ await viewer.app.flush()
+ const scroll = findScrollBox(viewer.app.renderer.root)!
+ const initial = scroll.scrollTop
+
+ Object.keys(disabledDiffKeybinds).forEach((command) => expect(viewer.shortcut(command)).toBe(""))
+
+ viewer.app.mockInput.pressKey("j")
+ await viewer.app.renderOnce()
+
+ expect(scroll.scrollTop).toBe(initial)
+ } finally {
+ viewer.app.renderer.destroy()
+ }
+})
+
+async function renderDiffViewer(
+ vcsDiff: unknown[],
+ options: {
+ height?: number
+ initialRoute?: Route
+ fail?: boolean
+ keybinds?: TuiKeybind.KeybindOverrides
+ } = {},
+) {
const commands = new Map()
- let current = initialRoute ?? startRoute
+ const [current, setCurrent] = createSignal(options.initialRoute ?? startRoute)
+ const currentData = () => {
+ const route = current()
+ return route.type === "plugin" ? route.data : undefined
+ }
let renderDiff: Page["render"] | undefined
let renderCommands: SlotClaim<"app">["render"] | undefined
let vcsDiffInput: unknown
- const config = createTuiResolvedConfig()
+ let shortcut: (command: string) => string | undefined = () => undefined
+ const config = createTuiResolvedConfig({ keybinds: options.keybinds })
const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return
vcsDiffInput = {
@@ -153,7 +159,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
mode: url.searchParams.get("mode"),
context: url.searchParams.get("context"),
}
- if (fail) return json({ message: "boom" }, { status: 500 })
+ if (options.fail) return json({ message: "boom" }, { status: 500 })
return json({
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
data: vcsDiff,
@@ -161,61 +167,65 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
}, createEventStream())
function Harness() {
let theme: ReturnType["currentTokens"]>
- const context = {
- options: {},
- client: createApi(transport.fetch),
- data: {
- session: { get: () => session },
- location: { default: () => ({ directory: "/repo/default" }) },
- },
- get theme() {
- return theme
- },
- keymap: {
- layer(input: () => KeymapLayer) {
- input().commands?.forEach((command) => {
- if (command.id) commands.set(command.id, command)
- })
+ function Content() {
+ const keymap = Keymap.use()
+ const shortcuts = Keymap.useShortcuts()
+ shortcut = shortcuts.get
+ theme = useThemes().currentTokens()
+ const context = {
+ options: {},
+ client: createApi(transport.fetch),
+ data: {
+ session: { get: () => session },
+ location: { default: () => ({ directory: "/repo/default" }) },
},
- dispatch() {},
- shortcuts: () => [],
- mode: { current: () => "base", push: () => () => {} },
- },
- ui: {
- dialog: {
- show: () => () => {},
- set() {},
- clear() {},
+ get theme() {
+ return theme
},
- router: {
- register(page: Page) {
- if (page.name === "diff") renderDiff = page.render
+ keymap: {
+ layer(input: () => KeymapLayer) {
+ input().commands?.forEach((command) => {
+ if (command.id) commands.set(command.id, command)
+ })
+ Keymap.createLayer(input)
+ },
+ dispatch: keymap.dispatch,
+ shortcuts: shortcuts.list,
+ mode: keymap.mode,
+ },
+ ui: {
+ dialog: {
+ show: () => () => {},
+ set() {},
+ clear() {},
+ },
+ router: {
+ register(page: Page) {
+ if (page.name === "diff") renderDiff = page.render
+ return () => {}
+ },
+ navigate(destination: Destination) {
+ setCurrent(
+ destination.type === "plugin" && !("id" in destination)
+ ? { ...destination, id: "diff-viewer" }
+ : destination,
+ )
+ },
+ current,
+ },
+ slot(claim: SlotClaim<"app">) {
+ renderCommands = claim.render
return () => {}
},
- navigate(destination: Destination) {
- current =
- destination.type === "plugin" && !("id" in destination)
- ? { ...destination, id: "diff-viewer" }
- : destination
- },
- current: () => current,
},
- slot(claim: SlotClaim<"app">) {
- renderCommands = claim.render
- return () => {}
- },
- },
- } as unknown as Context
+ } as unknown as Context
- void diffViewerPlugin.setup(context)
- function Content() {
- theme = useThemes().currentTokens()
+ void diffViewerPlugin.setup(context)
const commandView = renderCommands?.({})
- if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
<>
{commandView}
- {renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })}
+ {renderDiff?.({ data: currentData() })}
>
)
}
@@ -237,19 +247,60 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
)
}
- const app = await testRender(() => , { width: 80, height })
- await waitForCommand(app, commands, "diff.close")
+ const app = await testRender(() => , { width: 80, height: options.height ?? 20 })
+ for (let attempt = 0; attempt < 100; attempt++) {
+ await app.renderOnce()
+ if (current().type !== "plugin") commands.get("diff.open")?.run()
+ if (commands.has("diff.close")) break
+ await Bun.sleep(25)
+ }
+ await app.waitFor(() => commands.has("diff.close"), { maxPasses: 1 })
await app.waitFor(() => vcsDiffInput !== undefined)
return {
app,
commands,
- current: () => current,
+ current,
+ shortcut: (command: string) => shortcut(command),
vcsDiffInput: () => vcsDiffInput,
}
}
const startRoute: Route = { type: "session", sessionID: "session-1" }
+const disabledDiffKeybinds = {
+ "diff.down": "none",
+ "diff.up": "none",
+ "diff.page.down": "none",
+ "diff.page.up": "none",
+ "diff.mark_reviewed": "none",
+} satisfies TuiKeybind.KeybindOverrides
+
+const hunkDiff = [
+ {
+ file: "src/file.txt",
+ additions: 3,
+ deletions: 3,
+ status: "modified",
+ patch: `--- a/src/file.txt
++++ b/src/file.txt
+@@ -1,3 +1,3 @@
+ const first = true
+-const oldFirst = true
++const newFirst = true
+ const afterFirst = true
+@@ -20,3 +20,3 @@
+ const second = true
+-const oldSecond = true
++const newSecond = true
+ const afterSecond = true
+@@ -40,3 +40,3 @@
+ const third = true
+-const oldThird = true
++const newThird = true
+ const afterThird = true`,
+ },
+]
+
function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
return root.getChildren().map(findScrollBox).find(Boolean)
@@ -280,11 +331,13 @@ const session = {
}
test("branch diff source requests branch VCS diff", async () => {
- const viewer = await renderDiffViewer([], 20, {
- type: "plugin",
- id: "diff-viewer",
- name: "diff",
- data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
+ const viewer = await renderDiffViewer([], {
+ initialRoute: {
+ type: "plugin",
+ id: "diff-viewer",
+ name: "diff",
+ data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
+ },
})
try {
expect(viewer.current()).toEqual({
@@ -302,15 +355,3 @@ test("branch diff source requests branch VCS diff", async () => {
viewer.app.renderer.destroy()
}
})
-
-async function waitForCommand(
- app: Awaited>,
- commands: Map,
- command: string,
-) {
- for (let attempt = 0; attempt < 10; attempt++) {
- await app.renderOnce()
- if (commands.has(command)) return
- await new Promise((resolve) => setTimeout(resolve, 25))
- }
-}
diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx
index c774fb04843..55965adcd9c 100644
--- a/packages/tui/test/config-v2.test.tsx
+++ b/packages/tui/test/config-v2.test.tsx
@@ -4,15 +4,17 @@ import { expect, test } from "bun:test"
import { Schema } from "effect"
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
import { settings } from "../src/component/dialog-config"
+import { TuiKeybind } from "../src/config/keybind"
+import { CommandMap, Definitions } from "../src/config/v1/keybind"
+
+const decodeInfo = Schema.decodeUnknownSync(Info)
test("validates mini replay settings", () => {
- const decode = Schema.decodeUnknownSync(Info)
-
- expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({
+ expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
mini: { replay: false, replay_limit: 50 },
})
- expect(() => decode({ mini: { replay_limit: 0 } })).toThrow()
- expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
+ expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
+ expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
})
test("validates the session tabs setting", () => {
@@ -60,6 +62,105 @@ test("shows the new session location default in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
})
+test("uses command IDs as keybind keys", () => {
+ const config = resolve({ keybinds: { "session.list": "ctrl+l" } }, { terminalSuspend: true })
+
+ expect(config.keybinds.get("session.list")).toMatchObject([{ key: "ctrl+l" }])
+ expect(TuiKeybind.unknownKeys({ session_list: "ctrl+l" })).toEqual(["session_list"])
+ expect(
+ Object.keys(TuiKeybind.Definitions)
+ .filter((key) => key !== "leader")
+ .every((key) => key.includes(".")),
+ ).toBe(true)
+})
+
+test("preserves migrated v1 keybind defaults", () => {
+ const pairs = [
+ ["app.exit", "app_exit"],
+ ["prompt.paste", "input_paste"],
+ ["session.delete", "session_delete"],
+ ["session.list", "session_list"],
+ ["agent.list", "agent_list"],
+ ] as const
+
+ pairs.forEach(([command, name]) => {
+ expect(CommandMap[name]).toBe(command)
+ expect(TuiKeybind.Definitions[command].default).toEqual(Definitions[name].default)
+ })
+})
+
+test("accepts every v2-only named command ID", () => {
+ const commands = [
+ "server.pair",
+ "session.toggle.exploration_grouping",
+ "composer.subagent.up",
+ "composer.subagent.down",
+ "composer.subagent.select",
+ "composer.subagent.interrupt",
+ "composer.shell.up",
+ "composer.shell.down",
+ "composer.shell.kill",
+ "diff.down",
+ "diff.up",
+ "diff.page.down",
+ "diff.page.up",
+ "diff.mark_reviewed",
+ "opencode.settings",
+ "service.restart",
+ "permission.mode",
+ "session.cd",
+ "app.scrap",
+ ]
+ const config = resolve(
+ decodeInfo({ keybinds: Object.fromEntries(commands.map((command) => [command, "ctrl+alt+z"])) }),
+ { terminalSuspend: true },
+ )
+
+ commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }]))
+})
+
+test("centralizes named command defaults and resolves explicit none", () => {
+ const defaults = {
+ "composer.subagent.up": "up",
+ "composer.subagent.down": "down",
+ "composer.subagent.select": "return",
+ "composer.subagent.interrupt": "ctrl+d",
+ "composer.shell.up": "up",
+ "composer.shell.down": "down",
+ "composer.shell.kill": "ctrl+d",
+ "diff.down": "j,down",
+ "diff.up": "k,up",
+ "diff.page.down": "pagedown,ctrl+f",
+ "diff.page.up": "pageup,ctrl+b",
+ "diff.mark_reviewed": "m",
+ }
+ const config = resolve({}, { terminalSuspend: true })
+ Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
+
+ const disabled = resolve(
+ decodeInfo({ keybinds: Object.fromEntries(Object.keys(defaults).map((command) => [command, "none"])) }),
+ { terminalSuspend: true },
+ )
+ Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
+})
+
+test("rejects orphaned keybind definitions", () => {
+ expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
+})
+
+test("uses ctrl+z for input undo when terminal suspend is unavailable", () => {
+ const config = resolve({}, { terminalSuspend: false })
+ expect(config.keybinds.has("terminal.suspend")).toBe(false)
+ expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }])
+
+ const overridden = resolve(
+ { keybinds: { "terminal.suspend": "ctrl+s", "input.undo": "ctrl+u" } },
+ { terminalSuspend: false },
+ )
+ expect(overridden.keybinds.has("terminal.suspend")).toBe(false)
+ expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
+})
+
test("provides config and its host interface", async () => {
const config = resolve({}, { terminalSuspend: true })
let current = {}
diff --git a/packages/tui/test/keybind.test.ts b/packages/tui/test/keybind.test.ts
index 1af999d9d33..64b007cb38f 100644
--- a/packages/tui/test/keybind.test.ts
+++ b/packages/tui/test/keybind.test.ts
@@ -2,6 +2,6 @@ import { expect, test } from "bun:test"
import { TuiKeybind } from "../src/config/keybind"
test("binds agent cycling only to shift+tab by default", () => {
- expect(TuiKeybind.Definitions.agent_cycle.default).toBe("shift+tab")
- expect(TuiKeybind.Definitions.agent_cycle_reverse.default).toBe("none")
+ expect(TuiKeybind.Definitions["agent.cycle"].default).toBe("shift+tab")
+ expect(TuiKeybind.Definitions["agent.cycle.reverse"].default).toBe("none")
})
diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx
index 11141ddf10e..f07a4beebb2 100644
--- a/packages/tui/test/keymap.test.tsx
+++ b/packages/tui/test/keymap.test.tsx
@@ -27,8 +27,8 @@ test("legacy page key aliases compile as page keys", async () => {
diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx
index 40d7e09813d..b5ff4ef13a3 100644
--- a/packages/tui/test/mini/footer-keymap.test.tsx
+++ b/packages/tui/test/mini/footer-keymap.test.tsx
@@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types"
-test("down opens subagents from an empty prompt", async () => {
+async function renderSubagent(interrupt: "ctrl+i" | "none") {
const [state] = createSignal({
phase: "idle",
status: "",
@@ -34,9 +34,17 @@ test("down opens subagents from an empty prompt", async () => {
forms: [],
})
const config = resolve(
- { keybinds: { editor_open: "none", session_queued_prompts: "none" } },
+ {
+ keybinds: {
+ "prompt.editor": "none",
+ "session.queued_prompts": "none",
+ "composer.subagent.interrupt": interrupt,
+ },
+ },
{ terminalSuspend: true },
)
+ const interrupted: string[] = []
+
function Harness() {
return (
@@ -82,18 +90,47 @@ test("down opens subagents from an empty prompt", async () => {
onLayout={() => {}}
onStatus={() => {}}
onMiniSettingChange={() => {}}
+ onSubagentInterrupt={(sessionID) => interrupted.push(sessionID)}
/>
)
}
const app = await testRender(() => , { width: 100, height: 8, kittyKeyboard: true })
+ return { app, interrupted }
+}
+
+async function openSubagent(app: Awaited>) {
+ await app.renderOnce()
+ expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
+ app.mockInput.pressArrow("down")
+ await app.renderOnce()
+ expect(app.captureCharFrame()).toContain("Select subagent")
+ app.mockInput.pressEnter()
+ await app.renderOnce()
+}
+
+test("configured subagent key updates its hint and action", async () => {
+ const { app, interrupted } = await renderSubagent("ctrl+i")
try {
- await app.renderOnce()
- expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
- app.mockInput.pressArrow("down")
- await app.renderOnce()
- expect(app.captureCharFrame()).toContain("Select subagent")
+ await openSubagent(app)
+ expect(app.captureCharFrame()).toContain("ctrl+i")
+ app.mockInput.pressKey("i", { ctrl: true })
+ expect(interrupted).toEqual(["subagent-1"])
+ } finally {
+ app.renderer.currentFocusedRenderable?.blur()
+ app.renderer.currentFocusedEditor?.blur()
+ app.renderer.destroy()
+ }
+})
+
+test("disabled subagent interrupt has no component fallback", async () => {
+ const { app, interrupted } = await renderSubagent("none")
+ try {
+ await openSubagent(app)
+ expect(app.captureCharFrame()).not.toContain("ctrl+d")
+ app.mockInput.pressKey("d", { ctrl: true })
+ expect(interrupted).toEqual([])
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx
index 509e71b1318..2988f768475 100644
--- a/packages/tui/test/mini/footer.view.test.tsx
+++ b/packages/tui/test/mini/footer.view.test.tsx
@@ -1072,7 +1072,7 @@ test.skip("direct footer recreates the frame across command panel transitions",
test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
const calls: string[] = []
const app = await renderFooter({
- tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "t" } }),
+ tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", "variant.cycle": "t" } }),
onCycle: () => calls.push("cycle"),
})
@@ -1092,7 +1092,7 @@ test.skip("direct footer dispatches leader variant binding only when leader is r
test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
const calls: string[] = []
const app = await renderFooter({
- tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "t" } }),
+ tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", "variant.cycle": "t" } }),
onCycle: () => calls.push("cycle"),
})
@@ -1603,7 +1603,7 @@ test("direct footer keeps the command hint at its minimum width", async () => {
test("direct footer keeps complete status text ahead of the spinner", async () => {
const app = await renderFooter({
- tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }),
+ tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }),
state: { phase: "running" },
width: 22,
})
@@ -1663,7 +1663,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
const app = await renderFooter({
- tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
+ tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }),
state: { phase: "running" },
mono: true,
})
diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts
index 067a4d70112..5a08ce02bf3 100644
--- a/packages/tui/test/mini/runtime.boot.test.ts
+++ b/packages/tui/test/mini/runtime.boot.test.ts
@@ -1,75 +1,14 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
-import type { Resolved } from "../../src/config"
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { catalogModel, catalogProvider } from "./fixture/catalog"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
-function config(input?: {
- leader?: string
- leaderTimeout?: number
- bindings?: Partial<{
- commandList: string[]
- variantCycle: string[]
- interrupt: string[]
- historyPrevious: string[]
- historyNext: string[]
- inputClear: string[]
- inputSubmit: string[]
- inputNewline: string[]
- }>
-}): Resolved {
- const bind = input?.bindings
- return createTuiResolvedConfig({
- leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout },
- keybinds: {
- ...(input?.leader && { leader: input.leader }),
- ...(bind?.commandList && { command_list: bind.commandList }),
- ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
- ...(bind?.interrupt && { session_interrupt: bind.interrupt }),
- ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
- ...(bind?.historyNext && { history_next: bind.historyNext }),
- ...(bind?.inputClear && { input_clear: bind.inputClear }),
- ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
- ...(bind?.inputNewline && { input_newline: bind.inputNewline }),
- },
- })
-}
-
describe("run runtime boot", () => {
afterEach(() => {
mock.restore()
})
- test("reads footer keybinds from resolved keybind config", async () => {
- const input = config({
- leader: "ctrl+g",
- bindings: {
- commandList: ["ctrl+p"],
- variantCycle: ["ctrl+t", "alt+t"],
- interrupt: ["ctrl+c"],
- historyPrevious: ["k"],
- historyNext: ["j"],
- inputClear: ["ctrl+l"],
- inputSubmit: ["ctrl+s"],
- inputNewline: ["alt+return"],
- },
- })
-
- const result = await resolveRunTuiConfig(input)
-
- expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
- expect(result.leader.timeout).toBe(2000)
- expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
- expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
- expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
- expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
- expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
- expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
- expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
- expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
- })
-
test("falls back to default tui keymap config when config load fails", async () => {
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
@@ -86,12 +25,6 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
- test("preserves disabled leader from resolved tui config", async () => {
- const result = await resolveRunTuiConfig(config({ leader: "none" }))
-
- expect(result.keybinds.get("leader")).toEqual([])
- })
-
test("preserves shared config while resolving independent Mini defaults", async () => {
const result = await resolveRunTuiConfig(
createTuiResolvedConfig({
diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts
index ff661d66677..b125eddcb8e 100644
--- a/packages/tui/test/mini/runtime.test.ts
+++ b/packages/tui/test/mini/runtime.test.ts
@@ -5,6 +5,7 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
import type { FooterEvent, MiniHost } from "../../src/mini/types"
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
import { createFooterApiFixture } from "./fixture/footer-api"
+import { createTuiResolvedConfig } from "../fixture/tui-runtime"
function defer() {
let resolve!: (value: T | PromiseLike) => void
@@ -488,7 +489,7 @@ describe("run interactive runtime", () => {
expect(closedTitle).toBe("Cached title")
})
- test("adopts the deferred target location for catalogs, files, and runtime placement", async () => {
+ test("adopts deferred target placement and supplied TUI config", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const lifecycleStarted = defer()
const painted = defer()
@@ -498,6 +499,8 @@ describe("run interactive runtime", () => {
let getDirectory: (() => string) | undefined
let findFiles: ((query: string) => Promise) | undefined
let transportLocation: unknown
+ let runtimeConfig: LifecycleInput["tuiConfig"] | undefined
+ const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } })
const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" },
})
@@ -534,11 +537,13 @@ describe("run interactive runtime", () => {
model: undefined,
variant: undefined,
files: [],
+ tuiConfig,
},
{
createRuntimeLifecycle: async (input) => {
getDirectory = input.getDirectory
findFiles = input.findFiles
+ runtimeConfig = input.tuiConfig
lifecycleStarted.resolve()
return {
footer: api,
@@ -577,6 +582,8 @@ describe("run interactive runtime", () => {
const query = { location: { directory: "/session", workspace: "work-1" } }
expect(getDirectory?.()).toBe("/session")
+ if (!runtimeConfig) throw new Error("runtime lifecycle did not receive TUI config")
+ expect(await runtimeConfig).toBe(tuiConfig)
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })