fix(tui): run dialog actions without selection (#36711)

This commit is contained in:
Kit Langton 2026-07-13 15:19:40 -04:00 committed by GitHub
commit 41349ff20a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 185 additions and 22 deletions

View file

@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
{
command: "model.dialog.provider",
title: connected() ? "Connect integration" : "View all integrations",
selection: "none",
onTrigger() {
dialog.replace(() => (
<DialogIntegration

View file

@ -345,6 +345,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: () => void create(),
},
{
@ -360,6 +361,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.refresh",
title: "refresh",
selection: "none",
onTrigger: () => void refetch(),
},
]

View file

@ -217,6 +217,7 @@ function View(props: { api: TuiPluginApi }) {
{
title: "install",
command: "dialog.plugins.install",
selection: "none",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)

View file

@ -36,14 +36,7 @@ export interface DialogSelectProps<T> {
renderFilter?: boolean
locked?: boolean
preserveSelection?: boolean
actions?: {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
onTrigger: (option: DialogSelectOption<T>) => void
}[]
actions?: DialogSelectAction<T>[]
footerHints?: {
title: string
label: string
@ -54,6 +47,24 @@ export interface DialogSelectProps<T> {
focusCurrent?: boolean
}
type DialogSelectActionBase<T> = {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
}
type DialogSelectAction<T> =
| (DialogSelectActionBase<T> & {
selection?: "required"
onTrigger: (option: DialogSelectOption<T>) => void
})
| (DialogSelectActionBase<T> & {
selection: "none"
onTrigger: () => void
})
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
@ -350,7 +361,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("input", "keyboard")
const index = focusedAction()
if (index !== undefined) {
triggerAction(actionItems()[index])
trigger(actionItems()[index])
return
}
const option = selected()
@ -441,14 +452,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
name: item.command,
title: item.title,
category: "Dialog",
run() {
if (props.locked) return
if (isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
},
run: () => trigger(item),
})),
],
bindings: [
@ -504,10 +508,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
function triggerAction(item: VisibleAction | undefined) {
if (props.locked) return
if (!item || !isActionItem(item) || isActionDisabled(item)) return
function trigger(item: Action | undefined) {
if (props.locked || !item || isActionDisabled(item)) return
setStore("input", "keyboard")
if (item.selection === "none") {
item.onTrigger()
return
}
const option = selected()
if (!option) return
item.onTrigger(option)
@ -518,7 +525,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
function isActionDisabled(item: Action) {
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
const option = selected()
if (item.selection !== "none" && !option) return true
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
}
function isActionFocused(item: VisibleAction) {
@ -545,7 +554,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
flexDirection="row"
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => triggerAction(item)}
onMouseUp={() => trigger(item)}
>
<text
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}

View file

@ -0,0 +1,150 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } 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,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<DialogSelect
title="Items"
options={options}
actions={[
{
command: "dialog.move_session.delete",
title: "delete",
onTrigger: onRow,
},
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: onGlobal,
},
]}
/>
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</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
}
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()
}
})