test: harden keybind integration coverage
This commit is contained in:
committed by
opencode-agent[bot]
parent
b021a58b4f
commit
068b42576d
@@ -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}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user