Просмотр исходного кода

tui: add reject message support to permission dialogs for better user feedback

Dax Raad 1 месяц назад
Родитель
Сommit
47c670aea9

+ 3 - 66
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -1,4 +1,4 @@
-import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
+import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
 import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
 import "opentui-spinner/solid"
 import { useLocal } from "@tui/context/local"
@@ -10,7 +10,6 @@ import { useSync } from "@tui/context/sync"
 import { Identifier } from "@/id/id"
 import { createStore, produce } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
-import { Keybind } from "@/util/keybind"
 import { usePromptHistory, type PromptInfo } from "./history"
 import { usePromptStash } from "./stash"
 import { DialogStash } from "../dialog-stash"
@@ -30,6 +29,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
 import { DialogAlert } from "../../ui/dialog-alert"
 import { useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv"
+import { useTextareaKeybindings } from "../textarea-keybindings"
 
 export type PromptProps = {
   sessionID?: string
@@ -53,61 +53,6 @@ export type PromptRef = {
 
 const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
 
-const TEXTAREA_ACTIONS = [
-  "submit",
-  "newline",
-  "move-left",
-  "move-right",
-  "move-up",
-  "move-down",
-  "select-left",
-  "select-right",
-  "select-up",
-  "select-down",
-  "line-home",
-  "line-end",
-  "select-line-home",
-  "select-line-end",
-  "visual-line-home",
-  "visual-line-end",
-  "select-visual-line-home",
-  "select-visual-line-end",
-  "buffer-home",
-  "buffer-end",
-  "select-buffer-home",
-  "select-buffer-end",
-  "delete-line",
-  "delete-to-line-end",
-  "delete-to-line-start",
-  "backspace",
-  "delete",
-  "undo",
-  "redo",
-  "word-forward",
-  "word-backward",
-  "select-word-forward",
-  "select-word-backward",
-  "delete-word-forward",
-  "delete-word-backward",
-] as const
-
-function mapTextareaKeybindings(
-  keybinds: Record<string, Keybind.Info[]>,
-  action: (typeof TEXTAREA_ACTIONS)[number],
-): KeyBinding[] {
-  const configKey = `input_${action.replace(/-/g, "_")}`
-  const bindings = keybinds[configKey]
-  if (!bindings) return []
-  return bindings.map((binding) => ({
-    name: binding.name,
-    ctrl: binding.ctrl || undefined,
-    meta: binding.meta || undefined,
-    shift: binding.shift || undefined,
-    super: binding.super || undefined,
-    action,
-  }))
-}
-
 export function Prompt(props: PromptProps) {
   let input: TextareaRenderable
   let anchor: BoxRenderable
@@ -139,15 +84,7 @@ export function Prompt(props: PromptProps) {
     }
   }
 
-  const textareaKeybindings = createMemo(() => {
-    const keybinds = keybind.all
-
-    return [
-      { name: "return", action: "submit" },
-      { name: "return", meta: true, action: "newline" },
-      ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
-    ] satisfies KeyBinding[]
-  })
+  const textareaKeybindings = useTextareaKeybindings()
 
   const fileStyleId = syntax().getStyleId("extmark.file")!
   const agentStyleId = syntax().getStyleId("extmark.agent")!

+ 73 - 0
packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts

@@ -0,0 +1,73 @@
+import { createMemo } from "solid-js"
+import type { KeyBinding } from "@opentui/core"
+import { useKeybind } from "../context/keybind"
+import { Keybind } from "@/util/keybind"
+
+const TEXTAREA_ACTIONS = [
+  "submit",
+  "newline",
+  "move-left",
+  "move-right",
+  "move-up",
+  "move-down",
+  "select-left",
+  "select-right",
+  "select-up",
+  "select-down",
+  "line-home",
+  "line-end",
+  "select-line-home",
+  "select-line-end",
+  "visual-line-home",
+  "visual-line-end",
+  "select-visual-line-home",
+  "select-visual-line-end",
+  "buffer-home",
+  "buffer-end",
+  "select-buffer-home",
+  "select-buffer-end",
+  "delete-line",
+  "delete-to-line-end",
+  "delete-to-line-start",
+  "backspace",
+  "delete",
+  "undo",
+  "redo",
+  "word-forward",
+  "word-backward",
+  "select-word-forward",
+  "select-word-backward",
+  "delete-word-forward",
+  "delete-word-backward",
+] as const
+
+function mapTextareaKeybindings(
+  keybinds: Record<string, Keybind.Info[]>,
+  action: (typeof TEXTAREA_ACTIONS)[number],
+): KeyBinding[] {
+  const configKey = `input_${action.replace(/-/g, "_")}`
+  const bindings = keybinds[configKey]
+  if (!bindings) return []
+  return bindings.map((binding) => ({
+    name: binding.name,
+    ctrl: binding.ctrl || undefined,
+    meta: binding.meta || undefined,
+    shift: binding.shift || undefined,
+    super: binding.super || undefined,
+    action,
+  }))
+}
+
+export function useTextareaKeybindings() {
+  const keybind = useKeybind()
+
+  return createMemo(() => {
+    const keybinds = keybind.all
+
+    return [
+      { name: "return", action: "submit" },
+      { name: "return", meta: true, action: "newline" },
+      ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
+    ] satisfies KeyBinding[]
+  })
+}

+ 99 - 6
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -1,16 +1,20 @@
 import { createStore } from "solid-js/store"
 import { createMemo, For, Match, Show, Switch } from "solid-js"
 import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
+import type { TextareaRenderable } from "@opentui/core"
 import { useKeybind } from "../../context/keybind"
 import { useTheme } from "../../context/theme"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2"
 import { useSDK } from "../../context/sdk"
 import { SplitBorder } from "../../component/border"
 import { useSync } from "../../context/sync"
+import { useTextareaKeybindings } from "../../component/textarea-keybindings"
 import path from "path"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import { Locale } from "@/util/locale"
 
+type PermissionStage = "permission" | "always" | "reject"
+
 function normalizePath(input?: string) {
   if (!input) return ""
   if (path.isAbsolute(input)) {
@@ -101,9 +105,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
   const sdk = useSDK()
   const sync = useSync()
   const [store, setStore] = createStore({
-    always: false,
+    stage: "permission" as PermissionStage,
   })
 
+  const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
+
   const input = createMemo(() => {
     const tool = props.request.tool
     if (!tool) return {}
@@ -120,7 +126,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
 
   return (
     <Switch>
-      <Match when={store.always}>
+      <Match when={store.stage === "always"}>
         <Prompt
           title="Always allow"
           body={
@@ -148,7 +154,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
           options={{ confirm: "Confirm", cancel: "Cancel" }}
           escapeKey="cancel"
           onSelect={(option) => {
-            setStore("always", false)
+            setStore("stage", "permission")
             if (option === "cancel") return
             sdk.client.permission.reply({
               reply: "always",
@@ -157,7 +163,19 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
           }}
         />
       </Match>
-      <Match when={!store.always}>
+      <Match when={store.stage === "reject"}>
+        <RejectPrompt
+          onConfirm={(message) => {
+            sdk.client.permission.reply({
+              reply: "reject",
+              requestID: props.request.id,
+              message: message || undefined,
+            })
+          }}
+          onCancel={() => setStore("stage", "permission")}
+        />
+      </Match>
+      <Match when={store.stage === "permission"}>
         <Prompt
           title="Permission required"
           body={
@@ -215,11 +233,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
           escapeKey="reject"
           onSelect={(option) => {
             if (option === "always") {
-              setStore("always", true)
+              setStore("stage", "always")
               return
             }
+            if (option === "reject") {
+              if (session()?.parentID) {
+                setStore("stage", "reject")
+                return
+              }
+              sdk.client.permission.reply({
+                reply: "reject",
+                requestID: props.request.id,
+              })
+            }
             sdk.client.permission.reply({
-              reply: option as "once" | "reject",
+              reply: "once",
               requestID: props.request.id,
             })
           }}
@@ -229,6 +257,71 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
   )
 }
 
+function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
+  let input: TextareaRenderable
+  const { theme } = useTheme()
+  const keybind = useKeybind()
+  const textareaKeybindings = useTextareaKeybindings()
+
+  useKeyboard((evt) => {
+    if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+      evt.preventDefault()
+      props.onCancel()
+      return
+    }
+    if (evt.name === "return") {
+      evt.preventDefault()
+      props.onConfirm(input.plainText)
+    }
+  })
+
+  return (
+    <box
+      backgroundColor={theme.backgroundPanel}
+      border={["left"]}
+      borderColor={theme.error}
+      customBorderChars={SplitBorder.customBorderChars}
+    >
+      <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
+        <box flexDirection="row" gap={1} paddingLeft={1}>
+          <text fg={theme.error}>{"△"}</text>
+          <text fg={theme.text}>Reject permission</text>
+        </box>
+        <box paddingLeft={1}>
+          <text fg={theme.textMuted}>Tell OpenCode what to do differently</text>
+        </box>
+      </box>
+      <box
+        flexDirection="row"
+        flexShrink={0}
+        paddingTop={1}
+        paddingLeft={2}
+        paddingRight={3}
+        paddingBottom={1}
+        backgroundColor={theme.backgroundElement}
+        justifyContent="space-between"
+      >
+        <textarea
+          ref={(val: TextareaRenderable) => (input = val)}
+          focused
+          textColor={theme.text}
+          focusedTextColor={theme.text}
+          cursorColor={theme.primary}
+          keyBindings={textareaKeybindings()}
+        />
+        <box flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
+          <text fg={theme.text}>
+            enter <span style={{ fg: theme.textMuted }}>confirm</span>
+          </text>
+          <text fg={theme.text}>
+            esc <span style={{ fg: theme.textMuted }}>cancel</span>
+          </text>
+        </box>
+      </box>
+    </box>
+  )
+}
+
 function Prompt<const T extends Record<string, string>>(props: {
   title: string
   body: JSX.Element

+ 14 - 4
packages/opencode/src/permission/next.ts

@@ -124,7 +124,7 @@ export namespace PermissionNext {
         const rule = evaluate(request.permission, pattern, ruleset, s.approved)
         log.info("evaluated", { permission: request.permission, pattern, action: rule })
         if (rule.action === "deny")
-          throw new AutoRejectedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
+          throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
         if (rule.action === "ask") {
           const id = input.id ?? Identifier.ascending("permission")
           return new Promise<void>((resolve, reject) => {
@@ -149,6 +149,7 @@ export namespace PermissionNext {
     z.object({
       requestID: Identifier.schema("permission"),
       reply: Reply,
+      message: z.string().optional(),
     }),
     async (input) => {
       const s = await state()
@@ -161,7 +162,7 @@ export namespace PermissionNext {
         reply: input.reply,
       })
       if (input.reply === "reject") {
-        existing.reject(new RejectedError())
+        existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError())
         // Reject all other pending permissions for this session
         const sessionID = existing.info.sessionID
         for (const [id, pending] of Object.entries(s.pending)) {
@@ -238,13 +239,22 @@ export namespace PermissionNext {
     return result
   }
 
+  /** User rejected without message - halts execution */
   export class RejectedError extends Error {
     constructor() {
-      super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
+      super(`The user rejected permission to use this specific tool call.`)
     }
   }
 
-  export class AutoRejectedError extends Error {
+  /** User rejected with message - continues with guidance */
+  export class CorrectedError extends Error {
+    constructor(message: string) {
+      super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`)
+    }
+  }
+
+  /** Auto-rejected by config rule - halts execution */
+  export class DeniedError extends Error {
     constructor(public readonly ruleset: Ruleset) {
       super(
         `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,

+ 2 - 1
packages/opencode/src/server/server.ts

@@ -1631,13 +1631,14 @@ export namespace Server {
             requestID: z.string(),
           }),
         ),
-        validator("json", z.object({ reply: PermissionNext.Reply })),
+        validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
         async (c) => {
           const params = c.req.valid("param")
           const json = c.req.valid("json")
           await PermissionNext.reply({
             requestID: params.requestID,
             reply: json.reply,
+            message: json.message,
           })
           return c.json(true)
         },

+ 2 - 2
packages/opencode/test/permission/next.test.ts

@@ -451,7 +451,7 @@ test("ask - throws RejectedError when action is deny", async () => {
           always: [],
           ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
         }),
-      ).rejects.toBeInstanceOf(PermissionNext.AutoRejectedError)
+      ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
     },
   })
 })
@@ -628,7 +628,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
             { permission: "bash", pattern: "rm *", action: "deny" },
           ],
         }),
-      ).rejects.toBeInstanceOf(PermissionNext.AutoRejectedError)
+      ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
     },
   })
 })

+ 2 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -1706,6 +1706,7 @@ export class Permission extends HeyApiClient {
       requestID: string
       directory?: string
       reply?: "once" | "always" | "reject"
+      message?: string
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -1717,6 +1718,7 @@ export class Permission extends HeyApiClient {
             { in: "path", key: "requestID" },
             { in: "query", key: "directory" },
             { in: "body", key: "reply" },
+            { in: "body", key: "message" },
           ],
         },
       ],

+ 1 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3449,6 +3449,7 @@ export type PermissionRespondResponse = PermissionRespondResponses[keyof Permiss
 export type PermissionReplyData = {
   body?: {
     reply: "once" | "always" | "reject"
+    message?: string
   }
   path: {
     requestID: string