1
0
Эх сурвалжийг харах

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.
Simon Klee 3 сар өмнө
parent
commit
e255a26527

+ 19 - 7
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -1,5 +1,5 @@
 import { createStore } from "solid-js/store"
 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 { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import type { TextareaRenderable } from "@opentui/core"
 import type { TextareaRenderable } from "@opentui/core"
 import { useKeybind } from "../../context/keybind"
 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
   title: string
   header?: JSX.Element
   header?: JSX.Element
   body: 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 keys = Object.keys(props.options) as (keyof T)[]
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     selected: keys[0],
     selected: keys[0],
+    hovered: undefined as keyof T | undefined,
     expanded: false,
     expanded: false,
   })
   })
   const diffKey = Keybind.parse("ctrl+f")[0]
   const diffKey = Keybind.parse("ctrl+f")[0]
@@ -577,14 +578,20 @@ function Prompt<const T extends Record<string, string>>(props: {
       evt.preventDefault()
       evt.preventDefault()
       const idx = keys.indexOf(store.selected)
       const idx = keys.indexOf(store.selected)
       const next = keys[(idx - 1 + keys.length) % keys.length]
       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") {
     if (evt.name === "right" || evt.name == "l") {
       evt.preventDefault()
       evt.preventDefault()
       const idx = keys.indexOf(store.selected)
       const idx = keys.indexOf(store.selected)
       const next = keys[(idx + 1) % keys.length]
       const next = keys[(idx + 1) % keys.length]
-      setStore("selected", next)
+      batch(() => {
+        setStore("selected", next)
+        setStore("hovered", undefined)
+      })
     }
     }
 
 
     if (evt.name === "return") {
     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 hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
+  const active = (option: keyof T) => option === (store.hovered ?? store.selected)
   useRenderer()
   useRenderer()
 
 
   const content = () => (
   const content = () => (
@@ -658,14 +666,18 @@ function Prompt<const T extends Record<string, string>>(props: {
               <box
               <box
                 paddingLeft={1}
                 paddingLeft={1}
                 paddingRight={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={() => {
                 onMouseUp={() => {
                   setStore("selected", option)
                   setStore("selected", option)
                   props.onSelect(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]}
                   {props.options[option]}
                 </text>
                 </text>
               </box>
               </box>

+ 116 - 0
packages/opencode/test/cli/tui/permission.test.tsx

@@ -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()
+    }
+  })
+})