mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-28 04:29:42 +00:00
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:
parent
f033d2d8fb
commit
e255a26527
2 changed files with 135 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
116
packages/opencode/test/cli/tui/permission.test.tsx
Normal file
116
packages/opencode/test/cli/tui/permission.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue