fix: keep permission prompt hover separate from selected action

Preserve the existing hover highlight without letting mouse movement change
what Enter confirms. Add a TUI regression test so hovered actions still click
correctly while keyboard selection stays stable.
This commit is contained in:
Simon Klee 2026-04-23 12:55:00 +02:00
parent f033d2d8fb
commit e255a26527
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
2 changed files with 135 additions and 7 deletions

View file

@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { batch, createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
@ -549,7 +549,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
)
}
function Prompt<const T extends Record<string, string>>(props: {
export function Prompt<const T extends Record<string, string>>(props: {
title: string
header?: JSX.Element
body: JSX.Element
@ -564,6 +564,7 @@ function Prompt<const T extends Record<string, string>>(props: {
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
hovered: undefined as keyof T | undefined,
expanded: false,
})
const diffKey = Keybind.parse("ctrl+f")[0]
@ -577,14 +578,20 @@ function Prompt<const T extends Record<string, string>>(props: {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
batch(() => {
setStore("selected", next)
setStore("hovered", undefined)
})
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
batch(() => {
setStore("selected", next)
setStore("hovered", undefined)
})
}
if (evt.name === "return") {
@ -605,6 +612,7 @@ function Prompt<const T extends Record<string, string>>(props: {
})
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
const active = (option: keyof T) => option === (store.hovered ?? store.selected)
useRenderer()
const content = () => (
@ -658,14 +666,18 @@ function Prompt<const T extends Record<string, string>>(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
onMouseOver={() => setStore("selected", option)}
backgroundColor={active(option) ? theme.warning : theme.backgroundMenu}
onMouseOver={() => setStore("hovered", option)}
onMouseOut={() => {
if (store.hovered !== option) return
setStore("hovered", undefined)
}}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
<text fg={active(option) ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]}
</text>
</box>

View file

@ -0,0 +1,116 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import path from "path"
import { DialogProvider } from "../../../src/cli/cmd/tui/ui/dialog"
import { ToastProvider } from "../../../src/cli/cmd/tui/ui/toast"
import { KeybindProvider } from "../../../src/cli/cmd/tui/context/keybind"
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
import { Prompt } from "../../../src/cli/cmd/tui/routes/session/permission"
import { Global } from "../../../src/global"
type Setup = Awaited<ReturnType<typeof testRender>>
function App(props: { onSelect: (option: "once" | "always" | "reject") => void }) {
return (
<TuiConfigProvider config={{}}>
<KVProvider>
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<KeybindProvider>
<Prompt
title="Permission required"
body={
<box>
<text>Prompt body</text>
</box>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={props.onSelect}
/>
</KeybindProvider>
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
)
}
function locate(frame: string, text: string) {
const lines = frame.split("\n")
const y = lines.findIndex((line) => line.includes(text))
if (y === -1) throw new Error(`Could not locate ${text}`)
return { x: lines[y]!.indexOf(text), y }
}
async function waitForPrompt(setup: Setup) {
for (let i = 0; i < 50; i++) {
await setup.renderOnce()
const frame = setup.captureCharFrame()
if (frame.includes("Allow once") && frame.includes("Allow always") && frame.includes("Reject")) {
return frame
}
await Bun.sleep(10)
}
throw new Error("Timed out waiting for permission prompt")
}
async function prepareKv() {
await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({}))
}
describe("permission prompt", () => {
test("hover does not change the option Enter confirms", async () => {
const calls: string[] = []
await prepareKv()
const setup = await testRender(() => <App onSelect={(option) => calls.push(option)} />, {
width: 80,
height: 20,
})
try {
const frame = await waitForPrompt(setup)
const reject = locate(frame, "Reject")
setup.mockInput.pressArrow("right")
await setup.renderOnce()
await setup.mockMouse.moveTo(reject.x, reject.y)
await setup.renderOnce()
setup.mockInput.pressEnter()
expect(calls).toEqual(["always"])
} finally {
setup.renderer.destroy()
}
})
test("click still selects the hovered option", async () => {
const calls: string[] = []
await prepareKv()
const setup = await testRender(() => <App onSelect={(option) => calls.push(option)} />, {
width: 80,
height: 20,
})
try {
const frame = await waitForPrompt(setup)
const reject = locate(frame, "Reject")
setup.mockInput.pressArrow("right")
await setup.renderOnce()
await setup.mockMouse.click(reject.x, reject.y)
expect(calls).toEqual(["reject"])
} finally {
setup.renderer.destroy()
}
})
})