opencode/packages/tui/test/cli/tui/dialog-select.test.tsx
opencode-agent[bot] 016545513f
feat(tui): group sequential thinking (#36901)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-14 15:13:16 -04:00

274 lines
8.2 KiB
TypeScript

/** @jsxImportSource @opentui/solid */
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 { createSignal, onCleanup, onMount } from "solid-js"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function renderSelect(
root: string,
options: DialogSelectOption<string>[],
onGlobal: () => void,
onRow: (option: DialogSelectOption<string>) => void,
current?: string,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [{ ConfigProvider }, { ThemeProvider }, { Keymap }, { DialogProvider }, { DialogSelect }, { ToastProvider }] =
await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/context/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
function Harness() {
function Select() {
onCleanup(Keymap.use().mode.push("modal"))
return (
<DialogSelect
title="Items"
options={options}
current={current}
actions={[
{
command: "dialog.move_session.delete",
title: "delete",
onTrigger: onRow,
},
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: onGlobal,
},
]}
/>
)
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Select />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Items"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ Keymap },
{ DialogProvider, useDialog },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/context/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
const selected: string[] = []
const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
function Harness() {
const [options, setOptions] = createSignal(initial)
replaceOptions = setOptions
function Fixture() {
const dialog = useDialog()
onMount(() =>
dialog.replace(() => (
<DialogSelect
title="Mutable options"
options={options()}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
)),
)
return null
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 24, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected }
}
test("renders actions with a current selection", async () => {
await using tmp = await tmpdir()
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => {},
() => {},
"alpha",
)
try {
await app.waitForFrame((frame) => frame.includes("delete"))
} finally {
app.renderer.destroy()
}
})
test("dialog actions run without options while row actions still require a selection", async () => {
await using tmp = await tmpdir()
let global = 0
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[],
() => global++,
(option) => rows.push(option.value),
)
try {
app.mockInput.pressKey("m", { ctrl: true })
app.mockInput.pressKey("d", { ctrl: true })
expect(global).toBe(1)
expect(rows).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("footer actions run when filtering leaves no selected row", async () => {
await using tmp = await tmpdir()
let global = 0
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => global++,
(option) => rows.push(option.value),
)
try {
for (const key of "missing") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("No results found"))
app.mockInput.pressKey("d", { ctrl: true })
app.mockInput.pressTab()
app.mockInput.pressEnter()
expect(global).toBe(1)
expect(rows).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("row actions receive the selected option", async () => {
await using tmp = await tmpdir()
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => {},
(option) => rows.push(option.value),
)
try {
app.mockInput.pressKey("d", { ctrl: true })
expect(rows).toEqual(["alpha"])
} finally {
app.renderer.destroy()
}
})
test("selects the new final option immediately after removing the selected final option", async () => {
await using tmp = await tmpdir()
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
const select = await mountSelect(tmp.path, options)
try {
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "second")
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "third")
select.replaceOptions(options.slice(0, -1))
await select.app.waitForFrame((frame) => !frame.includes("third"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["second"])
} finally {
select.app.renderer.destroy()
}
})
test("selects a repopulated option after removing the only option", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "only", value: "only" }])
try {
select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No items available"))
select.app.mockInput.pressEnter()
expect(select.selected).toEqual([])
select.replaceOptions([{ title: "replacement", value: "replacement" }])
await select.app.waitForFrame((frame) => frame.includes("replacement"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["replacement"])
} finally {
select.app.renderer.destroy()
}
})