add dialog prompt submit keybind (#27807)
This commit is contained in:
@@ -188,6 +188,7 @@ export const Definitions = {
|
|||||||
"dialog.select.home": keybind("home", "Move to first dialog item"),
|
"dialog.select.home": keybind("home", "Move to first dialog item"),
|
||||||
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
||||||
"dialog.select.submit": keybind("return", "Submit selected 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"),
|
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
||||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
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 { Spinner } from "../component/spinner"
|
||||||
|
import { useTuiConfig } from "../context/tui-config"
|
||||||
|
import { useBindings, useCommandShortcut } from "../keymap"
|
||||||
|
|
||||||
export type DialogPromptProps = {
|
export type DialogPromptProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -18,8 +20,32 @@ export type DialogPromptProps = {
|
|||||||
export function DialogPrompt(props: DialogPromptProps) {
|
export function DialogPrompt(props: DialogPromptProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
|
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
|
||||||
|
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||||
let textarea: 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(() => {
|
onMount(() => {
|
||||||
dialog.setSize("medium")
|
dialog.setSize("medium")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
{props.description}
|
{props.description}
|
||||||
<textarea
|
<textarea
|
||||||
onSubmit={() => {
|
|
||||||
if (props.busy) return
|
|
||||||
props.onConfirm?.(textarea.plainText)
|
|
||||||
}}
|
|
||||||
height={3}
|
height={3}
|
||||||
ref={(val: TextareaRenderable) => {
|
ref={(val: TextareaRenderable) => {
|
||||||
textarea = val
|
textarea = val
|
||||||
|
setTextareaTarget(val)
|
||||||
}}
|
}}
|
||||||
initialValue={props.value}
|
initialValue={props.value}
|
||||||
placeholder={props.placeholder ?? "Enter text"}
|
placeholder={props.placeholder ?? "Enter text"}
|
||||||
@@ -80,9 +103,11 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
</box>
|
</box>
|
||||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||||
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
||||||
<text fg={theme.text}>
|
<Show when={submitShortcut()}>
|
||||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
<text fg={theme.text}>
|
||||||
</text>
|
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
146
packages/opencode/test/cli/tui/dialog-prompt.test.tsx
Normal file
146
packages/opencode/test/cli/tui/dialog-prompt.test.tsx
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -470,6 +470,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
|
|||||||
which_key_toggle: "alt+k",
|
which_key_toggle: "alt+k",
|
||||||
editor_open: "ctrl+e",
|
editor_open: "ctrl+e",
|
||||||
"prompt.autocomplete.next": "ctrl+j",
|
"prompt.autocomplete.next": "ctrl+j",
|
||||||
|
"dialog.prompt.submit": "ctrl+s",
|
||||||
"dialog.mcp.toggle": "ctrl+t",
|
"dialog.mcp.toggle": "ctrl+t",
|
||||||
model_favorite_toggle: "ctrl+f",
|
model_favorite_toggle: "ctrl+f",
|
||||||
"dialog.plugins.install": "shift+i",
|
"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.editor")?.[0]?.key).toBe("ctrl+e")
|
||||||
expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
|
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("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
|
||||||
expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
|
expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
|
||||||
expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
|
expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`.
|
|||||||
"dialog.select.home": "home",
|
"dialog.select.home": "home",
|
||||||
"dialog.select.end": "end",
|
"dialog.select.end": "end",
|
||||||
"dialog.select.submit": "return",
|
"dialog.select.submit": "return",
|
||||||
|
"dialog.prompt.submit": "return",
|
||||||
"dialog.mcp.toggle": "space",
|
"dialog.mcp.toggle": "space",
|
||||||
"prompt.autocomplete.prev": "up,ctrl+p",
|
"prompt.autocomplete.prev": "up,ctrl+p",
|
||||||
"prompt.autocomplete.next": "down,ctrl+n",
|
"prompt.autocomplete.next": "down,ctrl+n",
|
||||||
|
|||||||
Reference in New Issue
Block a user