import { InputRenderable } from "@opentui/core" import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import type { Dialog } from "@opencode/plugin/tui/context" import { createSignal } from "solid-js" import { ConfigProvider } from "../src/config" import { Keymap } from "../src/context/keymap" import { ThemeProvider } from "../src/context/theme" import { createDialogApi } from "../src/plugin/api" import { DialogProvider, useDialog } from "../src/ui/dialog" import { ToastProvider } from "../src/ui/toast" import { emptyThemeSource, tmpdir } from "./fixture/fixture" import { TestTuiContexts } from "./fixture/tui-environment" import { createTuiResolvedConfig } from "./fixture/tui-runtime" async function mount(root: string) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) const dialog = Promise.withResolvers() function Fixture() { dialog.resolve(createDialogApi(useDialog(), (render) => render())) return null } const app = await testRender( () => ( ), { width: 80, height: 24, kittyKeyboard: true }, ) app.renderer.start() return { app, dialog: await dialog.promise } } async function opened(app: Awaited>["app"], title: string) { await app.waitForFrame((frame) => frame.includes(title)) await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) // Focus re-renders the footer actions, so wait for a frame that presents their hits. await app.renderOnce() } function rows(frame: string, titles: string[]) { return titles.toSorted((left, right) => frame.indexOf(left) - frame.indexOf(right)) } test("search orders enabled options across categories and receives every query", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const calls: [string, string[]][] = [] try { const chosen = select.dialog.select({ title: "Projects", options: [ { title: "Alpha", value: "alpha", category: "Recent" }, { title: "Beta", value: "beta", category: "Pinned" }, { title: "Gamma", value: "gamma", category: "Recent" }, { title: "Delta", value: "delta", category: "Pinned", disabled: true }, ], search: (query, options) => { calls.push([query, options.map((option) => option.value)]) return options .filter((option) => option.title.toLowerCase().includes(query)) .toSorted((left, right) => right.title.localeCompare(left.title)) }, }) await opened(select.app, "Projects") const frame = select.app.captureCharFrame() expect(rows(frame, ["Alpha", "Beta", "Gamma"])).toEqual(["Gamma", "Beta", "Alpha"]) expect(frame).not.toContain("Delta") expect(frame).toMatch(/Gamma +Recent/) expect(frame).toMatch(/Beta +Pinned/) await select.app.mockInput.typeText("l") await select.app.waitForFrame((frame) => !frame.includes("Gamma")) expect(select.app.captureCharFrame()).not.toContain("Delta") select.app.mockInput.pressEnter() expect(await chosen).toBe("alpha") expect(calls[0]).toEqual(["", ["alpha", "beta", "gamma"]]) expect(calls.at(-1)).toEqual(["l", ["alpha", "beta", "gamma"]]) } finally { select.app.renderer.destroy() } }) test("search keeps the selected value when its results reorder", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const [reversed, setReversed] = createSignal(false) try { const chosen = select.dialog.select({ title: "Ranked", options: ["First", "Second", "Third", "Fourth"].map((title) => ({ title, value: title.toLowerCase() })), search: (_, options) => (reversed() ? options.toReversed() : options), }) await opened(select.app, "Ranked") select.app.mockInput.pressArrow("down") setReversed(true) await select.app.waitForFrame((frame) => frame.indexOf("Fourth") < frame.indexOf("First")) select.app.mockInput.pressEnter() expect(await chosen).toBe("second") } finally { select.app.renderer.destroy() } }) test("search keeps a query's first result when the same query reorders", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const [reversed, setReversed] = createSignal(false) try { const chosen = select.dialog.select({ title: "Ranked", options: ["First", "Second", "Third", "Fourth"].map((title) => ({ title, value: title.toLowerCase() })), search: (query, options) => { const matches = options.filter((option) => option.value.includes(query)) return reversed() ? matches.toReversed() : matches }, }) await opened(select.app, "Ranked") await select.app.mockInput.typeText("r") await select.app.waitForFrame((frame) => !frame.includes("Second")) setReversed(true) await select.app.waitForFrame((frame) => frame.indexOf("Fourth") < frame.indexOf("First")) select.app.mockInput.pressEnter() expect(await chosen).toBe("first") } finally { select.app.renderer.destroy() } }) test("search scrolls a moved or replaced selection into view", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const items = Array.from({ length: 30 }, (_, index) => { const title = `Item ${String(index).padStart(2, "0")}` return { title, value: title } }) const [results, setResults] = createSignal(items) try { const chosen = select.dialog.select({ title: "Long", options: items, search: () => results(), }) await opened(select.app, "Long") expect(select.app.captureCharFrame()).not.toContain("Item 29") setResults(items.toReversed()) await select.app.waitForFrame((frame) => frame.includes("Item 00") && !frame.includes("Item 29")) setResults(items.slice(1).toReversed()) await select.app.waitForFrame((frame) => frame.includes("Item 01") && !frame.includes("Item 00")) select.app.mockInput.pressEnter() expect(await chosen).toBe("Item 01") } finally { select.app.renderer.destroy() } }) test("search ignores disabled records returned by the callback", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const hidden = { title: "Hidden", value: "hidden", disabled: true } const triggered: string[] = [] try { const chosen = select.dialog.select({ title: "Guarded", options: [{ title: "Alpha", value: "alpha" }], search: (_, options) => [hidden, ...options], actions: [{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => triggered.push(value) }], }) await opened(select.app, "Guarded") expect(select.app.captureCharFrame()).not.toContain("Hidden") select.app.mockInput.pressKey("o", { ctrl: true }) select.app.mockInput.pressEnter() expect(await chosen).toBe("alpha") expect(triggered).toEqual(["alpha"]) } finally { select.app.renderer.destroy() } }) test("search returns to the first row when the query clears without a current value", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) try { const chosen = select.dialog.select({ title: "Cleared", options: ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })), search: (query, options) => options.filter((option) => option.value.includes(query)), }) await opened(select.app, "Cleared") await select.app.mockInput.typeText("g") await select.app.waitForFrame((frame) => !frame.includes("Beta")) select.app.mockInput.pressBackspace() await select.app.waitForFrame((frame) => frame.includes("Beta")) select.app.mockInput.pressEnter() expect(await chosen).toBe("alpha") } finally { select.app.renderer.destroy() } }) test("search selects the current value once it arrives in the results", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const options = ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })) const [results, setResults] = createSignal([]) try { const chosen = select.dialog.select({ title: "Loading", options, current: "gamma", search: () => results() }) await opened(select.app, "Loading") setResults(options) await select.app.waitForFrame((frame) => frame.includes("Gamma")) select.app.mockInput.pressEnter() expect(await chosen).toBe("gamma") } finally { select.app.renderer.destroy() } }) test("search returns to the current value when the query clears", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) try { const chosen = select.dialog.select({ title: "Current", options: ["Alpha", "Beta", "Gamma"].map((title) => ({ title, value: title.toLowerCase() })), current: "beta", search: (query, options) => options.filter((option) => option.value.includes(query)), }) await opened(select.app, "Current") await select.app.mockInput.typeText("g") await select.app.waitForFrame((frame) => !frame.includes("Beta")) select.app.mockInput.pressBackspace() await select.app.waitForFrame((frame) => frame.includes("Beta")) select.app.mockInput.pressEnter() expect(await chosen).toBe("beta") } finally { select.app.renderer.destroy() } }) test.each([ [false, true], [0, 1], ["", "other"], ])("actions deliver the selected value %p from keyboard and footer click, not tab", async (value, other) => { await using tmp = await tmpdir() const select = await mount(tmp.path) const triggered: unknown[] = [] let settled = false try { const chosen = select.dialog.select({ title: "Values", options: [ { title: "Chosen", value }, { title: "Other", value: other }, ], actions: [{ bind: "ctrl+o", title: "Inspect", onTrigger: (selected) => triggered.push(selected) }], }) void chosen.then(() => (settled = true)) await opened(select.app, "Values") await select.app.waitForFrame((frame) => frame.includes("Inspect ctrl+o")) select.app.mockInput.pressKey("o", { ctrl: true }) const frame = select.app.captureCharFrame().split("\n") const row = frame.findIndex((line) => line.includes("Inspect ctrl+o")) await select.app.mockMouse.click(frame[row]!.indexOf("Inspect") + 1, row) await select.app.waitFor(() => triggered.length === 2) await Bun.sleep(10) expect(triggered).toEqual([value, value]) expect(settled).toBe(false) expect(select.app.captureCharFrame()).toContain("Values") select.app.mockInput.pressTab() select.app.mockInput.pressEnter() expect(await chosen).toBe(value) expect(triggered).toEqual([value, value]) } finally { select.app.renderer.destroy() } }) test("actions receive object values and unbind when the dialog closes", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const project = { id: "acme" } const triggered: { id: string }[] = [] try { const chosen = select.dialog.select({ title: "Objects", options: [{ title: "Acme", value: project }], actions: [{ bind: "ctrl+o", title: "Inspect", onTrigger: (selected) => triggered.push(selected) }], }) await opened(select.app, "Objects") select.app.mockInput.pressKey("o", { ctrl: true }) select.app.mockInput.pressEscape() expect(await chosen).toBeUndefined() await select.app.waitForFrame((frame) => !frame.includes("Objects")) select.app.mockInput.pressKey("o", { ctrl: true }) expect(triggered).toEqual([project]) expect(triggered[0]).toBe(project) } finally { select.app.renderer.destroy() } }) test("only no-selection actions run without results", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const triggered: string[] = [] try { void select.dialog.select({ title: "Empty", options: [{ title: "Alpha", value: "alpha" }], search: () => [], actions: [ { bind: "ctrl+o", title: "Inspect", onTrigger: (value) => triggered.push(value) }, { bind: "ctrl+a", title: "New", side: "right", selection: "none", onTrigger: () => triggered.push("new") }, ], }) await opened(select.app, "Empty") await select.app.waitForFrame((frame) => frame.includes("New ctrl+a")) select.app.mockInput.pressKey("o", { ctrl: true }) select.app.mockInput.pressKey("a", { ctrl: true }) expect(triggered).toEqual(["new"]) } finally { select.app.renderer.destroy() } }) test("an action that opens a replacement dialog settles the select and keeps the replacement", async () => { await using tmp = await tmpdir() const select = await mount(tmp.path) const triggered: string[] = [] try { const chosen = select.dialog.select({ title: "Projects", options: [{ title: "Alpha", value: "alpha" }], actions: [ { bind: "ctrl+o", title: "Rename", onTrigger: (value) => { triggered.push(value) void select.dialog.prompt({ title: `Rename ${value}` }) }, }, ], }) await opened(select.app, "Projects") select.app.mockInput.pressKey("o", { ctrl: true }) expect(await chosen).toBeUndefined() await select.app.waitForFrame((frame) => frame.includes("Rename alpha")) select.app.mockInput.pressKey("o", { ctrl: true }) await Bun.sleep(10) expect(triggered).toEqual(["alpha"]) expect(select.app.captureCharFrame()).toContain("Rename alpha") expect(select.app.captureCharFrame()).not.toContain("Projects") } finally { select.app.renderer.destroy() } })