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) })