test: harden keybind integration coverage

This commit is contained in:
Sebastian Herrlinger
2026-07-23 16:13:34 +02:00
committed by opencode-agent[bot]
parent b021a58b4f
commit 068b42576d
7 changed files with 384 additions and 152 deletions
+67 -73
View File
@@ -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<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
)
}
function gateMigrationWrite(file: string, initial: string) {
return Effect.gen(function* () {
const node = yield* FileSystem.FileSystem
const release = Latch.makeUnsafe()
const state = { blocked: true, writes: 0 }
const exists: FileSystem.FileSystem["exists"] = (target) =>
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<void>()
const resume = Promise.withResolvers<void>()
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<void>()
const resume = Promise.withResolvers<void>()
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}`
}
})
+24 -22
View File
@@ -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<MiniCommandInput, "tuiConfig">) {
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 })
}
})
+59 -1
View File
@@ -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<void>()
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()
}
})
@@ -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<TuiKeybind.Keybinds>) {
const events = createEventStream()
const interrupted: string[] = []
const removed: string[] = []
const ready = Promise.withResolvers<void>()
let closed = 0
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
let route!: ReturnType<typeof useRoute>
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 <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
}
const app = await testRender(
() => (
<TestTuiContexts directory={directory}>
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<Content />
</ThemeProvider>
</RouteProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ 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)
}
}
+32 -9
View File
@@ -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<FooterState>({
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(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
return { app, interrupted }
}
async function openSubagent(app: Awaited<ReturnType<typeof testRender>>) {
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()
}
})
+4 -46
View File
@@ -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 () => {
+8 -1
View File
@@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => 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<void>()
const painted = defer<void>()
@@ -498,6 +499,8 @@ describe("run interactive runtime", () => {
let getDirectory: (() => string) | undefined
let findFiles: ((query: string) => Promise<string[]>) | 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) })