Compare commits

...
6 changed files with 546 additions and 10 deletions

No files matched your search

+23
View File
@@ -361,11 +361,34 @@ export interface DialogSelectOption<Value> {
readonly disabled?: boolean
}
export type DialogSelectAction<Value> = {
readonly bind: string
readonly title: string
readonly side?: "left" | "right"
} & (
| {
/** Unavailable while no option is selected. */
readonly selection?: "required"
readonly onTrigger: (value: Value) => void
}
| {
readonly selection: "none"
readonly onTrigger: () => void
}
)
export interface DialogSelectOptions<Value> {
readonly title: string
readonly placeholder?: string
readonly options: readonly DialogSelectOption<Value>[]
readonly current?: Value
/** Returns matching options in display order, including for an empty query. Receives only enabled options. Results are listed flat. */
readonly search?: (
query: string,
options: readonly DialogSelectOption<Value>[],
) => readonly DialogSelectOption<Value>[]
/** Footer actions, also triggered by their bindings. Triggering one does not close the dialog. */
readonly actions?: readonly DialogSelectAction<Value>[]
}
export interface Dialog {
+36 -6
View File
@@ -1,6 +1,15 @@
import { PluginContextProvider } from "@opencode/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode/plugin/tui/context"
import { createUniqueId, type JSX } from "solid-js"
import type {
Context,
Dialog,
DialogSelectOptions,
Page,
SlotClaim,
SlotMap,
SlotPath,
Toast,
} from "@opencode/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
@@ -356,16 +365,37 @@ export function createDialogApi(
)
})
},
select(options) {
return new Promise((resolve) => {
const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
select<Value>(options: DialogSelectOptions<Value>) {
return new Promise<Value | undefined>((resolve) => {
const done = settle<Value | undefined>(resolve)
const search = options.search
const id = createUniqueId()
api.show(
() => (
<DialogSelect
<DialogSelect<Value>
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
search={
search &&
((query) =>
search(
query,
options.options.filter((option) => !option.disabled),
))
}
actions={options.actions?.map((action, index) => {
const base = {
command: `plugin.dialog.select.${id}.${index}`,
title: action.title,
side: action.side,
bind: action.bind,
}
if (action.selection === "none")
return { ...base, selection: action.selection, onTrigger: action.onTrigger }
return { ...base, onTrigger: (option) => action.onTrigger(option.value) }
})}
onSelect={(option) => {
done(option.value)
api.clear()
+38 -2
View File
@@ -29,6 +29,7 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void
onCancel?: () => void
skipFilter?: boolean
search?: (query: string) => readonly DialogSelectOption<T>[]
renderFilter?: boolean
locked?: boolean
preserveSelection?: boolean
@@ -48,6 +49,7 @@ export interface DialogSelectProps<T> {
type DialogSelectActionBase<T> = {
command: string
title: string
bind?: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
@@ -136,7 +138,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
[() => props.focusTarget ?? props.current, () => (props.focusTarget === undefined ? undefined : flat())],
([current]) => {
if (props.focusCurrent === false) return
if (props.search || props.focusCurrent === false) return
if (props.focusTarget !== undefined && (props.preserveSelection || store.filter.length > 0)) return
if (current !== undefined) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
@@ -183,6 +185,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
const filtered = createMemo(() => {
if (props.search) return props.search(store.filter).filter((x) => x.disabled !== true)
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
const options = pipe(
@@ -209,7 +212,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setFocusedAction(undefined)
})
const flatten = createMemo(() => props.flat && store.filter.length > 0)
const flatten = createMemo(() => props.search !== undefined || (props.flat && store.filter.length > 0))
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
if (flatten()) return filtered().length ? [["", filtered()]] : []
@@ -246,6 +249,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (props.search) return
if (
!props.preserveSelection &&
((props.focusTarget ?? props.current) === undefined || props.focusCurrent === false)
@@ -302,6 +306,36 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
},
),
)
createEffect(
on(
[
flat,
() => store.filter,
() => (props.focusCurrent === false ? undefined : (props.focusTarget ?? props.current)),
],
([options, query, current], previous) => {
if (!props.search) return
const queryChanged = previous !== undefined && query !== previous[1]
const currentChanged = current !== undefined && (previous === undefined || !isDeepEqual(current, previous[2]))
selection = intent()
const intended = selection
const index = intended ? options.findIndex((option) => isDeepEqual(option.value, intended.value)) : -1
const next = index >= 0 ? index : reconcileSelection(store.selected, options.length)
const option = options[next]
if (!option) return
setStore("selected", next)
selection = option
scrollAfterLayout(queryChanged || currentChanged, option.value)
function intent() {
if (queryChanged && query) return options[0]
if ((queryChanged || currentChanged) && current !== undefined) return { value: current }
if (queryChanged) return options[reconcileSelection(store.selected, options.length)]
return selection
}
},
),
)
onCleanup(() => {
if (!pendingScroll) return
renderer.off(CliRenderEvents.FRAME, pendingScroll)
@@ -310,6 +344,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
createEffect(
on([() => store.filter, () => props.focusTarget ?? props.current], ([filter, current]) => {
if (props.search) return
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
@@ -476,6 +511,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
id: item.command,
title: item.title,
group: "Dialog",
bind: item.bind,
run: () => trigger(item),
})),
...(visible.length
@@ -4,7 +4,7 @@ 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 { batch, createSignal, onCleanup, onMount } from "solid-js"
import { dialogWidth } from "../../../src/ui/dialog"
import {
dialogSelectContentWidth,
@@ -91,7 +91,10 @@ async function mountSelect<T>(
initial: DialogSelectOption<T>[],
current?: T,
focusCurrent?: boolean,
select?: Pick<DialogSelectProps<T>, "flat" | "ref" | "onFilter" | "renderFilter" | "onCancel" | "focusTarget">,
select?: Pick<
DialogSelectProps<T>,
"flat" | "ref" | "onFilter" | "renderFilter" | "onCancel" | "focusTarget" | "search"
>,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -522,6 +525,27 @@ test("keeps the current option selected when options reorder", async () => {
}
})
test("custom search follows a current change batched with reordered results", async () => {
await using tmp = await tmpdir()
const options = ["alpha", "beta", "gamma"].map((value) => ({ title: value, value }))
const [results, setResults] = createSignal(options)
const select = await mountSelect(tmp.path, options, "alpha", undefined, { search: () => results() })
try {
batch(() => {
select.replaceCurrent("gamma")
setResults(options.toReversed())
})
await select.app.waitForFrame((frame) => frame.indexOf("gamma") < frame.indexOf("alpha"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["gamma"])
} finally {
select.app.renderer.destroy()
}
})
test.each([false, 0, "", null, "current", undefined])("focuses current %p when it changes", async (current) => {
await using tmp = await tmpdir()
const select = await mountSelect<string | typeof current>(
+385
View File
@@ -0,0 +1,385 @@
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<Dialog>()
function Fixture() {
dialog.resolve(createDialogApi(useDialog(), (render) => render()))
return null
}
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 80, height: 24, kittyKeyboard: true },
)
app.renderer.start()
return { app, dialog: await dialog.promise }
}
async function opened(app: Awaited<ReturnType<typeof mount>>["app"], title: string) {
await app.waitForFrame((frame) => frame.includes(title))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
}
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<typeof options>([])
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, footer click, and footer focus", 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<typeof value | typeof other>({
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)
select.app.mockInput.pressTab()
select.app.mockInput.pressEnter()
await select.app.waitFor(() => triggered.length === 3)
await Bun.sleep(10)
expect(triggered).toEqual([value, value, value])
expect(settled).toBe(false)
expect(select.app.captureCharFrame()).toContain("Values")
} 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()
}
})
@@ -333,6 +333,44 @@ const mode = await context.ui.dialog.select({
})
```
### Select search
Pass `search` to own matching and result order. It runs for every query, including the empty one, and receives only enabled options. Results are listed in the returned order, with each category shown inline.
```ts
const project = await context.ui.dialog.select({
title: "Projects",
options: [
{ title: "Alpha", value: "alpha", category: "Recent" },
{ title: "Beta", value: "beta", category: "Pinned" },
],
search: (query, options) =>
options
.filter((option) => option.title.toLowerCase().includes(query.toLowerCase()))
.toSorted((left, right) => right.title.localeCompare(left.title)),
})
```
### Select actions
Actions appear in the footer and run from their keyboard binding, a click, or Tab focus followed by Enter. By default they receive the selected value and are unavailable without a selection. Use `selection: "none"` for actions that need no selection.
```ts
await context.ui.dialog.select({
title: "Projects",
options: [{ title: "Alpha", value: "alpha" }],
actions: [
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
{ bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
],
})
```
- An action does not close the dialog or settle the promise.
- Enter or clicking an option resolves the selected value.
- Escape, `clear()`, or showing another dialog resolves `undefined`.
- Do not bind `ctrl+n` or `ctrl+p`. The dialog reserves them for navigation, and its own bindings take precedence.
Custom JSX dialogs can set their size and close themselves.
```tsx