diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 6764b074540..fb6a3336390 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -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(props: DialogSelectProps) { 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(props: DialogSelectProps) { 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(props: DialogSelectProps) { 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(props: DialogSelectProps) { ), ) 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) }), ) diff --git a/packages/tui/test/cli/tui/command-palette.test.tsx b/packages/tui/test/cli/tui/command-palette.test.tsx index b4f93e02afa..ca656962145 100644 --- a/packages/tui/test/cli/tui/command-palette.test.tsx +++ b/packages/tui/test/cli/tui/command-palette.test.tsx @@ -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() }