fix(tui): stabilize dialog action focus

Track focused footer actions by command instead of re-reading the reactive action list from footer rendering. This avoids the session selector mount cycle that could expose an undefined action-list memo and crash in indexOf.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
Aiden Cline 2026-07-13 20:37:30 +00:00
commit b7a3671b30
2 changed files with 51 additions and 13 deletions

View file

@ -104,14 +104,19 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
const selected: string[] = []
const moved: string[] = []
const globals: number[] = []
const rows: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
let disableRow!: () => void
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
const [options, setOptions] = createSignal(initial)
const [rowEnabled, setRowEnabled] = createSignal(true)
replaceOptions = setOptions
disableRow = () => setRowEnabled(false)
onCleanup(off)
function Fixture() {
@ -123,6 +128,20 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
options={options()}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
actions={[
{
command: "dialog.move_session.delete",
title: "delete",
disabled: !rowEnabled(),
onTrigger: (option) => rows.push(option.value),
},
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: () => globals.push(1),
},
]}
/>
)),
)
@ -150,7 +169,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected }
return { app, disableRow, globals, moved, replaceOptions, rows, selected }
}
test("dialog actions run without options while row actions still require a selection", async () => {
@ -201,6 +220,23 @@ test("footer actions run when filtering leaves no selected row", async () => {
}
})
test("does not move focus when the focused action becomes disabled", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }])
try {
select.app.mockInput.pressTab()
select.disableRow()
select.app.mockInput.pressEnter()
expect(select.globals).toEqual([])
expect(select.rows).toEqual([])
expect(select.selected).toEqual(["alpha"])
} finally {
select.app.renderer.destroy()
}
})
test("row actions receive the selected option", async () => {
await using tmp = await tmpdir()
const rows: string[] = []