fix(tui): focus palette settings after layout (#39585)

This commit is contained in:
Kit Langton 2026-07-30 19:39:16 -04:00 committed by GitHub
parent cc1289048e
commit 8e3e94aa26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 36 additions and 32 deletions

View file

@ -1,10 +1,10 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { useTheme, useThemes } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useTerminalDimensions } from "@opentui/solid"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "./dialog"
@ -100,6 +100,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const mode = themes.mode
const config = useConfig().data
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const renderer = useRenderer()
const [store, setStore] = createStore({
selected: 0,
@ -110,7 +111,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const actionFocused = createMemo(() => focusedAction() !== undefined)
let selection: { value: T; category?: string } | undefined
let resetSelection = false
let visibilityGeneration = 0
let pendingScroll: (() => void) | undefined
function scrollAfterLayout(center: boolean, value: T) {
if (pendingScroll) renderer.off(CliRenderEvents.FRAME, pendingScroll)
pendingScroll = () => {
pendingScroll = undefined
if (!isDeepEqual(selected()?.value, value)) return
scrollToSelection(center)
}
renderer.once(CliRenderEvents.FRAME, pendingScroll)
renderer.requestRender()
}
createEffect(
on(
@ -264,16 +276,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("selected", index)
selection = option
if (!moved) return
const value = option.value
const generation = ++visibilityGeneration
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (generation !== visibilityGeneration) return
if (!props.preserveSelection || store.filter.length > 0) return
if (!isDeepEqual(selected()?.value, value)) return
scrollToSelection(false)
})
})
if (!props.preserveSelection || store.filter.length > 0) return
scrollAfterLayout(false, option.value)
return
}
const next = reconcileSelection(store.selected, flat().length)
@ -284,22 +288,26 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
),
)
onCleanup(() => {
visibilityGeneration++
if (!pendingScroll) return
renderer.off(CliRenderEvents.FRAME, pendingScroll)
pendingScroll = undefined
})
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true, false)
} else if (current && props.focusCurrent !== false) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
moveTo(currentIndex, true)
}
}
}, 0)
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
}),
)

View file

@ -73,16 +73,12 @@ test("searches settings globally and opens the matching setting", async () => {
expect(app.captureCharFrame()).not.toContain("Animations")
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
for (const key of "side") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("Sidebar"))
expect(app.captureCharFrame()).not.toContain("New session")
expect(app.captureCharFrame()).not.toContain("Switch model")
expect(app.captureCharFrame()).not.toContain("Markdown")
app.mockInput.pressArrow("down")
for (const key of "sounds") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Color mode"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
app.mockInput.pressEnter()
await app.waitFor(() => current.session?.sidebar === "hide")
await app.waitFor(() => current.attention?.sound === false)
} finally {
app.renderer.destroy()
}