ソースを参照

add dialog prompt submit keybind (#27807)

Sebastian 3 ヶ月 前
コミット
d441e931f9

+ 1 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -188,6 +188,7 @@ export const Definitions = {
   "dialog.select.home": keybind("home", "Move to first dialog item"),
   "dialog.select.end": keybind("end", "Move to last dialog item"),
   "dialog.select.submit": keybind("return", "Submit selected dialog item"),
+  "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
   "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
   "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
   "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),

+ 33 - 8
packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx

@@ -1,8 +1,10 @@
 import { TextareaRenderable, TextAttributes } from "@opentui/core"
 import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
-import { Show, createEffect, onMount, type JSX } from "solid-js"
+import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
 import { Spinner } from "../component/spinner"
+import { useTuiConfig } from "../context/tui-config"
+import { useBindings, useCommandShortcut } from "../keymap"
 
 export type DialogPromptProps = {
   title: string
@@ -18,8 +20,32 @@ export type DialogPromptProps = {
 export function DialogPrompt(props: DialogPromptProps) {
   const dialog = useDialog()
   const { theme } = useTheme()
+  const tuiConfig = useTuiConfig()
+  const submitShortcut = useCommandShortcut("dialog.prompt.submit")
+  const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
   let textarea: TextareaRenderable
 
+  function confirm() {
+    if (props.busy) return
+    props.onConfirm?.(textarea.plainText)
+  }
+
+  useBindings(() => ({
+    target: textareaTarget,
+    enabled: textareaTarget() !== undefined && !props.busy,
+    // Dialog form semantics must win over the global managed textarea input layer.
+    priority: 1,
+    commands: [
+      {
+        name: "dialog.prompt.submit",
+        title: "Submit dialog prompt",
+        category: "Dialog",
+        run: confirm,
+      },
+    ],
+    bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
+  }))
+
   onMount(() => {
     dialog.setSize("medium")
     setTimeout(() => {
@@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) {
       <box gap={1}>
         {props.description}
         <textarea
-          onSubmit={() => {
-            if (props.busy) return
-            props.onConfirm?.(textarea.plainText)
-          }}
           height={3}
           ref={(val: TextareaRenderable) => {
             textarea = val
+            setTextareaTarget(val)
           }}
           initialValue={props.value}
           placeholder={props.placeholder ?? "Enter text"}
@@ -80,9 +103,11 @@ export function DialogPrompt(props: DialogPromptProps) {
       </box>
       <box paddingBottom={1} gap={1} flexDirection="row">
         <Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
-          <text fg={theme.text}>
-            enter <span style={{ fg: theme.textMuted }}>submit</span>
-          </text>
+          <Show when={submitShortcut()}>
+            <text fg={theme.text}>
+              {submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
+            </text>
+          </Show>
         </Show>
       </box>
     </box>

+ 146 - 0
packages/opencode/test/cli/tui/dialog-prompt.test.tsx

@@ -0,0 +1,146 @@
+/** @jsxImportSource @opentui/solid */
+import { TextareaRenderable } from "@opentui/core"
+import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
+import { testRender, useRenderer } from "@opentui/solid"
+import { expect, test } from "bun:test"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+import { onCleanup } from "solid-js"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+import type { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
+
+async function wait(fn: () => boolean, timeout = 2000) {
+  const start = Date.now()
+  while (!fn()) {
+    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+    await Bun.sleep(10)
+  }
+}
+
+async function mountPrompt(input: {
+  root: string
+  keybinds: Partial<TuiKeybind.Keybinds>
+  onConfirm: (value: string) => void
+}) {
+  const { Global } = await import("@opencode-ai/core/global")
+  const previous = {
+    config: Global.Path.config,
+    state: Global.Path.state,
+  }
+  Global.Path.config = path.join(input.root, "config")
+  Global.Path.state = path.join(input.root, "state")
+  await mkdir(Global.Path.config, { recursive: true })
+  await mkdir(Global.Path.state, { recursive: true })
+  await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
+
+  const [
+    { DialogProvider },
+    { DialogPrompt },
+    { KVProvider },
+    { ThemeProvider },
+    { TuiConfigProvider },
+    { ToastProvider },
+    { OpencodeKeymapProvider, registerOpencodeKeymap },
+  ] = await Promise.all([
+    import("../../../src/cli/cmd/tui/ui/dialog"),
+    import("../../../src/cli/cmd/tui/ui/dialog-prompt"),
+    import("../../../src/cli/cmd/tui/context/kv"),
+    import("../../../src/cli/cmd/tui/context/theme"),
+    import("../../../src/cli/cmd/tui/context/tui-config"),
+    import("../../../src/cli/cmd/tui/ui/toast"),
+    import("../../../src/cli/cmd/tui/keymap"),
+  ])
+
+  function Harness() {
+    const renderer = useRenderer()
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    const resolvedConfig = createTuiResolvedConfig({
+      keybinds: input.keybinds,
+      leader_timeout: 1000,
+    })
+    const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
+    onCleanup(off)
+
+    return (
+      <OpencodeKeymapProvider keymap={keymap}>
+        <TuiConfigProvider config={resolvedConfig}>
+          <KVProvider>
+            <ThemeProvider mode="dark">
+              <ToastProvider>
+                <DialogProvider>
+                  <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
+                </DialogProvider>
+              </ToastProvider>
+            </ThemeProvider>
+          </KVProvider>
+        </TuiConfigProvider>
+      </OpencodeKeymapProvider>
+    )
+  }
+
+  const app = await testRender(() => <Harness />, { kittyKeyboard: true })
+  return {
+    app,
+    async cleanup() {
+      app.renderer.destroy()
+      Global.Path.config = previous.config
+      Global.Path.state = previous.state
+    },
+  }
+}
+
+test("dialog prompt submit wins when return is also input newline", async () => {
+  await using tmp = await tmpdir()
+  const confirmed: string[] = []
+  const prompt = await mountPrompt({
+    root: tmp.path,
+    keybinds: {
+      input_submit: "super+return",
+      input_newline: "return,shift+return,alt+return,ctrl+j",
+    },
+    onConfirm: (value) => confirmed.push(value),
+  })
+
+  try {
+    await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    const textarea = prompt.app.renderer.currentFocusedEditor
+    if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
+
+    prompt.app.mockInput.pressEnter()
+
+    expect(confirmed).toEqual(["draft"])
+    expect(textarea.plainText).toBe("draft")
+  } finally {
+    await prompt.cleanup()
+  }
+})
+
+test("dialog prompt submit can be rebound separately from input submit", async () => {
+  await using tmp = await tmpdir()
+  const confirmed: string[] = []
+  const prompt = await mountPrompt({
+    root: tmp.path,
+    keybinds: {
+      input_submit: "return",
+      "dialog.prompt.submit": "ctrl+y",
+    },
+    onConfirm: (value) => confirmed.push(value),
+  })
+
+  try {
+    await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
+    const textarea = prompt.app.renderer.currentFocusedEditor
+    if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
+
+    prompt.app.mockInput.pressEnter()
+    expect(confirmed).toEqual([])
+    expect(textarea.plainText).toBe("draft")
+
+    prompt.app.mockInput.pressKey("y", { ctrl: true })
+
+    expect(confirmed).toEqual(["draft"])
+  } finally {
+    await prompt.cleanup()
+  }
+})

+ 2 - 0
packages/opencode/test/config/tui.test.ts

@@ -470,6 +470,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
           which_key_toggle: "alt+k",
           editor_open: "ctrl+e",
           "prompt.autocomplete.next": "ctrl+j",
+          "dialog.prompt.submit": "ctrl+s",
           "dialog.mcp.toggle": "ctrl+t",
           model_favorite_toggle: "ctrl+f",
           "dialog.plugins.install": "shift+i",
@@ -491,6 +492,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
       )
       expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
       expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
+      expect(config.keybinds.get("dialog.prompt.submit")?.[0]?.key).toBe("ctrl+s")
       expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
       expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
       expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")

+ 1 - 0
packages/web/src/content/docs/keybinds.mdx

@@ -145,6 +145,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`.
     "dialog.select.home": "home",
     "dialog.select.end": "end",
     "dialog.select.submit": "return",
+    "dialog.prompt.submit": "return",
     "dialog.mcp.toggle": "space",
     "prompt.autocomplete.prev": "up,ctrl+p",
     "prompt.autocomplete.next": "down,ctrl+n",