mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-17 01:52:55 +00:00
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.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"),
|
||||
|
||||
@@ -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
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",
|
||||
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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user