Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline b353795792 fix(tui): prevent session picker crash
Track footer focus by stable command and initialize option state before action bindings can evaluate selection-dependent availability.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 22:36:28 +00:00
2 changed files with 88 additions and 47 deletions
+50 -46
View File
@@ -106,7 +106,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
filter: "", filter: "",
input: "keyboard" as "keyboard" | "mouse", input: "keyboard" as "keyboard" | "mouse",
}) })
const [focusedAction, setFocusedAction] = createSignal<number>() const [focusedAction, setFocusedAction] = createSignal<string>()
const actionFocused = createMemo(() => focusedAction() !== undefined) const actionFocused = createMemo(() => focusedAction() !== undefined)
let selection: { value: T; category?: string } | undefined let selection: { value: T; category?: string } | undefined
let resetSelection = false let resetSelection = false
@@ -130,42 +130,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable let input: InputRenderable
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: shownActions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), config)
if (label) labels.set(action.command, label)
}
return labels
})
const visibleActions = createMemo(() => [
...shownActions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() =>
visibleActions()
.filter(isActionItem)
.filter((item) => !isActionDisabled(item)),
)
createEffect(() => {
const index = focusedAction()
if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
})
const filtered = createMemo(() => { const filtered = createMemo(() => {
if (props.skipFilter || props.renderFilter === false) return props.options.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 needle = store.filter.toLowerCase()
@@ -229,6 +193,44 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const selected = createMemo(() => flat()[store.selected]) const selected = createMemo(() => flat()[store.selected])
// Action availability depends on the selected option, so initialize the
// option graph before registering action bindings that may run immediately.
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: shownActions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), config)
if (label) labels.set(action.command, label)
}
return labels
})
const visibleActions = createMemo(() => [
...shownActions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => item.label),
...(props.footerHints ?? []),
])
const actionItems = createMemo(() =>
visibleActions()
.filter(isActionItem)
.filter((item) => !isActionDisabled(item)),
)
createEffect(() => {
const command = focusedAction()
if (command && !actionItems().some((item) => item.command === command)) setFocusedAction(undefined)
})
createEffect( createEffect(
on( on(
() => props.options, () => props.options,
@@ -363,9 +365,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function submit() { function submit() {
if (props.locked) return if (props.locked) return
setStore("input", "keyboard") setStore("input", "keyboard")
const index = focusedAction() const command = focusedAction()
if (index !== undefined) { if (command) {
trigger(actionItems()[index]) trigger(actionItems().find((item) => item.command === command))
return return
} }
const option = selected() const option = selected()
@@ -376,12 +378,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function moveAction(direction: 1 | -1) { function moveAction(direction: 1 | -1) {
if (props.locked) return if (props.locked) return
const total = actionItems().length const items = actionItems()
if (total === 0) return if (items.length === 0) return
setFocusedAction((index) => { setFocusedAction((command) => {
if (index === undefined) return direction === 1 ? 0 : total - 1 if (!command) return items[direction === 1 ? 0 : items.length - 1].command
const index = items.findIndex((item) => item.command === command)
if (index === -1) return items[direction === 1 ? 0 : items.length - 1].command
const next = index + direction const next = index + direction
return next < 0 || next >= total ? undefined : next return next < 0 || next >= items.length ? undefined : items[next].command
}) })
} }
@@ -537,7 +541,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function isActionFocused(item: VisibleAction) { function isActionFocused(item: VisibleAction) {
if (props.locked) return false if (props.locked) return false
if (!isActionItem(item)) return false if (!isActionItem(item)) return false
return actionItems().indexOf(item) === focusedAction() return item.command === focusedAction()
} }
function FooterAction(action: { item: VisibleAction }) { function FooterAction(action: { item: VisibleAction }) {
@@ -104,14 +104,19 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
const selected: string[] = [] const selected: string[] = []
const moved: string[] = [] const moved: string[] = []
const globals: number[] = []
const rows: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void let replaceOptions!: (options: DialogSelectOption<string>[]) => void
let disableRow!: () => void
function Harness() { function Harness() {
const renderer = useRenderer() const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer) const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config) const off = registerOpencodeKeymap(keymap, renderer, config)
const [options, setOptions] = createSignal(initial) const [options, setOptions] = createSignal(initial)
const [rowEnabled, setRowEnabled] = createSignal(true)
replaceOptions = setOptions replaceOptions = setOptions
disableRow = () => setRowEnabled(false)
onCleanup(off) onCleanup(off)
function Fixture() { function Fixture() {
@@ -121,8 +126,23 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
<DialogSelect <DialogSelect
title="Mutable options" title="Mutable options"
options={options()} options={options()}
current={initial[0]?.value}
onMove={(option) => moved.push(option.value)} onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.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 +170,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
app.renderer.start() app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options")) await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) 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 () => { test("dialog actions run without options while row actions still require a selection", async () => {
@@ -201,6 +221,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 () => { test("row actions receive the selected option", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const rows: string[] = [] const rows: string[] = []