diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts
index 0653828531..9c6cb7d49f 100644
--- a/packages/cli/test/config.test.ts
+++ b/packages/cli/test/config.test.ts
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
-import { Effect, FileSystem } from "effect"
+import { Effect, Fiber, FileSystem, Latch } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -16,6 +16,36 @@ function run(directory: string, effect: Effect.Effect
+ state.blocked && target === file ? Effect.succeed(true) : node.exists(target)
+ const readFileString: FileSystem.FileSystem["readFileString"] = (target, encoding) =>
+ state.blocked && target === file ? Effect.succeed(initial) : node.readFileString(target, encoding)
+ const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
+ state.writes++
+ if (state.writes !== 1) return node.writeFileString(target, data, options)
+ return Effect.gen(function* () {
+ yield* release.await
+ state.blocked = false
+ yield* node.writeFileString(target, data, options)
+ })
+ }
+ const fs = new Proxy(node, {
+ get(target, property, receiver) {
+ if (property === "exists") return exists
+ if (property === "readFileString") return readFileString
+ if (property === "writeFileString") return writeFileString
+ return Reflect.get(target, property, receiver)
+ },
+ })
+ return { fs, release, state }
+ })
+}
+
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
@@ -256,58 +286,37 @@ test("migrates the effective duplicate top-level keybinds", async () => {
test("does not overwrite a concurrent config update during migration", 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+d"}}`)
- const node = await Effect.runPromise(
- Effect.gen(function* () {
- return yield* FileSystem.FileSystem
- }).pipe(Effect.provide(NodeFileSystem.layer)),
- )
- const started = Promise.withResolvers()
- const resume = Promise.withResolvers()
- const state = { writes: 0 }
- const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => {
- state.writes++
- if (state.writes !== 1) return node.writeFileString(path, data, options)
- started.resolve()
- return Effect.gen(function* () {
- yield* Effect.promise(() => resume.promise)
- yield* node.writeFileString(path, data, options)
- })
- }
- const fs = new Proxy(node, {
- get(target, property, receiver) {
- if (property === "writeFileString") return writeFileString
- return Reflect.get(target, property, receiver)
- },
- })
+ const initial = `{"keybinds":{"session_delete":"ctrl+d"}}`
+ await Bun.write(file, initial)
+ const gated = await Effect.runPromise(gateMigrationWrite(file, initial).pipe(Effect.provide(NodeFileSystem.layer)))
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
- return yield* Effect.promise(async () => {
- const reading = Effect.runPromise(service.get())
- await started.promise
- const updating = Effect.runPromise(
- service.update((draft) => {
- draft.mouse = false
- }),
- )
- await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve())
- await Promise.all([reading, updating])
- return Effect.runPromise(service.get())
- })
+ const reading = yield* service.get().pipe(Effect.forkChild({ startImmediately: true }))
+ expect(gated.state.writes).toBe(1)
+ const updating = yield* service
+ .update((draft) => {
+ draft.mouse = false
+ })
+ .pipe(Effect.forkChild({ startImmediately: true }))
+ expect(gated.state.writes).toBe(1)
+ yield* gated.release.open
+ yield* Fiber.join(reading)
+ yield* Fiber.join(updating)
+ return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
- Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(FileSystem.FileSystem, gated.fs),
),
)
expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
- resume.resolve()
+ gated.release.openUnsafe()
await Bun.$`rm -rf ${directory}`
}
})
@@ -315,30 +324,9 @@ test("does not overwrite a concurrent config update during migration", async ()
test("does not overwrite a concurrent update from another config layer", 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+d"}}`)
- const node = await Effect.runPromise(
- Effect.gen(function* () {
- return yield* FileSystem.FileSystem
- }).pipe(Effect.provide(NodeFileSystem.layer)),
- )
- const started = Promise.withResolvers()
- const resume = Promise.withResolvers()
- const state = { writes: 0 }
- const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => {
- state.writes++
- if (state.writes !== 1) return node.writeFileString(path, data, options)
- started.resolve()
- return Effect.gen(function* () {
- yield* Effect.promise(() => resume.promise)
- yield* node.writeFileString(path, data, options)
- })
- }
- const fs = new Proxy(node, {
- get(target, property, receiver) {
- if (property === "writeFileString") return writeFileString
- return Reflect.get(target, property, receiver)
- },
- })
+ const initial = `{"keybinds":{"session_delete":"ctrl+d"}}`
+ await Bun.write(file, initial)
+ const gated = await Effect.runPromise(gateMigrationWrite(file, initial).pipe(Effect.provide(NodeFileSystem.layer)))
const make = () =>
Effect.runPromise(
Effect.gen(function* () {
@@ -346,28 +334,34 @@ test("does not overwrite a concurrent update from another config layer", async (
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
- Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(FileSystem.FileSystem, gated.fs),
),
)
try {
const first = await make()
const second = await make()
- const reading = Effect.runPromise(first.get())
- await started.promise
- const updating = Effect.runPromise(
- second.update((draft) => {
- draft.mouse = false
+ const config = await Effect.runPromise(
+ Effect.gen(function* () {
+ const reading = yield* first.get().pipe(Effect.forkChild({ startImmediately: true }))
+ expect(gated.state.writes).toBe(1)
+ const updating = yield* second
+ .update((draft) => {
+ draft.mouse = false
+ })
+ .pipe(Effect.forkChild({ startImmediately: true }))
+ expect(gated.state.writes).toBe(1)
+ yield* gated.release.open
+ yield* Fiber.join(reading)
+ yield* Fiber.join(updating)
+ return yield* second.get()
}),
)
- await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve())
- await Promise.all([reading, updating])
- const config = await Effect.runPromise(second.get())
expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
- resume.resolve()
+ gated.release.openUnsafe()
await Bun.$`rm -rf ${directory}`
}
})
diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts
index 6d59e98e58..553f158e90 100644
--- a/packages/cli/test/mini-config.test.ts
+++ b/packages/cli/test/mini-config.test.ts
@@ -1,22 +1,32 @@
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 { expect, spyOn, 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 validateMiniTerminal = spyOn(mini, "validateMiniTerminal").mockImplementation(() => {})
+ const runMini = spyOn(mini, "runMini").mockImplementation((input: MiniCommandInput) => {
+ received = input.tuiConfig
+ return Promise.resolve()
+ })
const handler = (await import("../src/commands/handlers/mini")).default
const server = Bun.serve({
port: 0,
@@ -38,18 +48,8 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => {
prompt: Option.none(),
demo: false,
}).pipe(
- Effect.provideService(
- Config.Service,
- Config.Service.of({
- path: "/tmp/cli.json",
- get: () => Effect.succeed({
- keybinds: { "composer.subagent.interrupt": "ctrl+i" },
- leader: { timeout: 321 },
- }),
- update: () => Effect.fail(new Error("not used")),
- }),
- ),
- Effect.provide(Global.layerWith({ config: "/tmp", state: "/tmp" })),
+ Effect.provide(Config.layer),
+ Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
@@ -60,6 +60,8 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => {
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
} finally {
server.stop(true)
- mock.restore()
+ validateMiniTerminal.mockRestore()
+ runMini.mockRestore()
+ await rm(root, { recursive: true, force: true })
}
})
diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx
index ef31b87954..6b57e3a4c0 100644
--- a/packages/tui/test/app-lifecycle.test.tsx
+++ b/packages/tui/test/app-lifecycle.test.tsx
@@ -1,4 +1,4 @@
-import { expect, test } from "bun:test"
+import { expect, spyOn, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -294,3 +294,61 @@ 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 })
+ const core = await import("@opentui/core")
+ const createCliRenderer = spyOn(core, "createCliRenderer").mockImplementation(async () => setup.renderer)
+ const ready = Promise.withResolvers()
+ const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
+ setup.renderer.setTerminalTitle = (title) => {
+ if (title === "OpenCode") ready.resolve()
+ setTitle(title)
+ }
+ 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: {},
+ log: () => {},
+ }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
+ )
+ await ready.promise
+
+ 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"))
+ await setup.mockInput.typeText("auto-approve")
+ const commands = await setup.waitForFrame((frame) => frame.includes("Disable auto-approve permissions"))
+ expect(commands).not.toContain("Enable auto-approve permissions")
+
+ setup.renderer.destroy()
+ await task
+ } finally {
+ if (!setup.renderer.isDestroyed) setup.renderer.destroy()
+ await server.stop()
+ createCliRenderer.mockRestore()
+ }
+})
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 0000000000..fbccd5b3f2
--- /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/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx
index c91234e3ef..b5ff4ef13a 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("configured subagent key updates its hint and action", async () => {
+async function renderSubagent(interrupt: "ctrl+i" | "none") {
const [state] = createSignal({
phase: "idle",
status: "",
@@ -38,7 +38,7 @@ test("configured subagent key updates its hint and action", async () => {
keybinds: {
"prompt.editor": "none",
"session.queued_prompts": "none",
- "composer.subagent.interrupt": "ctrl+i",
+ "composer.subagent.interrupt": interrupt,
},
},
{ terminalSuspend: true },
@@ -97,14 +97,23 @@ test("configured subagent key updates its hint and action", async () => {
}
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")
- app.mockInput.pressEnter()
- await app.renderOnce()
+ await openSubagent(app)
expect(app.captureCharFrame()).toContain("ctrl+i")
app.mockInput.pressKey("i", { ctrl: true })
expect(interrupted).toEqual(["subagent-1"])
@@ -114,3 +123,17 @@ test("configured subagent key updates its hint and action", async () => {
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()
+ app.renderer.destroy()
+ }
+})
diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts
index 8a3901ad0d..f651847acc 100644
--- a/packages/tui/test/mini/runtime.boot.test.ts
+++ b/packages/tui/test/mini/runtime.boot.test.ts
@@ -8,31 +8,10 @@ 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.palette.show": bind.commandList }),
- ...(bind?.variantCycle && { "variant.cycle": bind.variantCycle }),
- ...(bind?.interrupt && { "session.interrupt": bind.interrupt }),
- ...(bind?.historyPrevious && { "prompt.history.previous": bind.historyPrevious }),
- ...(bind?.historyNext && { "prompt.history.next": bind.historyNext }),
- ...(bind?.inputClear && { "prompt.clear": bind.inputClear }),
- ...(bind?.inputSubmit && { "input.submit": bind.inputSubmit }),
- ...(bind?.inputNewline && { "input.newline": bind.inputNewline }),
- },
+ keybinds: input?.leader ? { leader: input.leader } : undefined,
})
}
@@ -41,33 +20,12 @@ describe("run runtime boot", () => {
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"],
- },
- })
+ test("returns supplied resolved config", async () => {
+ const input = config({ leader: "ctrl+g" })
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")
+ expect(result).toBe(input)
})
test("falls back to default tui keymap config when config load fails", async () => {
diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts
index ff661d6667..b125eddcb8 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) })