Skip to content

Commit d441e93

Browse files
authored
add dialog prompt submit keybind (#27807)
1 parent ad79ad9 commit d441e93

5 files changed

Lines changed: 183 additions & 8 deletions

File tree

‎packages/opencode/src/cli/cmd/tui/config/keybind.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export const Definitions = {
188188
"dialog.select.home": keybind("home", "Move to first dialog item"),
189189
"dialog.select.end": keybind("end", "Move to last dialog item"),
190190
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
191+
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
191192
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
192193
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
193194
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),

‎packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { TextareaRenderable, TextAttributes } from "@opentui/core"
22
import { useTheme } from "../context/theme"
33
import { useDialog, type DialogContext } from "./dialog"
4-
import { Show, createEffect, onMount, type JSX } from "solid-js"
4+
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
55
import { Spinner } from "../component/spinner"
6+
import { useTuiConfig } from "../context/tui-config"
7+
import { useBindings, useCommandShortcut } from "../keymap"
68

79
export type DialogPromptProps = {
810
title: string
@@ -18,8 +20,32 @@ export type DialogPromptProps = {
1820
export function DialogPrompt(props: DialogPromptProps) {
1921
const dialog = useDialog()
2022
const { theme } = useTheme()
23+
const tuiConfig = useTuiConfig()
24+
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
25+
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
2126
let textarea: TextareaRenderable
2227

28+
function confirm() {
29+
if (props.busy) return
30+
props.onConfirm?.(textarea.plainText)
31+
}
32+
33+
useBindings(() => ({
34+
target: textareaTarget,
35+
enabled: textareaTarget() !== undefined && !props.busy,
36+
// Dialog form semantics must win over the global managed textarea input layer.
37+
priority: 1,
38+
commands: [
39+
{
40+
name: "dialog.prompt.submit",
41+
title: "Submit dialog prompt",
42+
category: "Dialog",
43+
run: confirm,
44+
},
45+
],
46+
bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
47+
}))
48+
2349
onMount(() => {
2450
dialog.setSize("medium")
2551
setTimeout(() => {
@@ -59,13 +85,10 @@ export function DialogPrompt(props: DialogPromptProps) {
5985
<box gap={1}>
6086
{props.description}
6187
<textarea
62-
onSubmit={() => {
63-
if (props.busy) return
64-
props.onConfirm?.(textarea.plainText)
65-
}}
6688
height={3}
6789
ref={(val: TextareaRenderable) => {
6890
textarea = val
91+
setTextareaTarget(val)
6992
}}
7093
initialValue={props.value}
7194
placeholder={props.placeholder ?? "Enter text"}
@@ -80,9 +103,11 @@ export function DialogPrompt(props: DialogPromptProps) {
80103
</box>
81104
<box paddingBottom={1} gap={1} flexDirection="row">
82105
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
83-
<text fg={theme.text}>
84-
enter <span style={{ fg: theme.textMuted }}>submit</span>
85-
</text>
106+
<Show when={submitShortcut()}>
107+
<text fg={theme.text}>
108+
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
109+
</text>
110+
</Show>
86111
</Show>
87112
</box>
88113
</box>
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { TextareaRenderable } from "@opentui/core"
3+
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
4+
import { testRender, useRenderer } from "@opentui/solid"
5+
import { expect, test } from "bun:test"
6+
import { mkdir } from "node:fs/promises"
7+
import path from "node:path"
8+
import { onCleanup } from "solid-js"
9+
import { tmpdir } from "../../fixture/fixture"
10+
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
11+
import type { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
12+
13+
async function wait(fn: () => boolean, timeout = 2000) {
14+
const start = Date.now()
15+
while (!fn()) {
16+
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
17+
await Bun.sleep(10)
18+
}
19+
}
20+
21+
async function mountPrompt(input: {
22+
root: string
23+
keybinds: Partial<TuiKeybind.Keybinds>
24+
onConfirm: (value: string) => void
25+
}) {
26+
const { Global } = await import("@opencode-ai/core/global")
27+
const previous = {
28+
config: Global.Path.config,
29+
state: Global.Path.state,
30+
}
31+
Global.Path.config = path.join(input.root, "config")
32+
Global.Path.state = path.join(input.root, "state")
33+
await mkdir(Global.Path.config, { recursive: true })
34+
await mkdir(Global.Path.state, { recursive: true })
35+
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
36+
37+
const [
38+
{ DialogProvider },
39+
{ DialogPrompt },
40+
{ KVProvider },
41+
{ ThemeProvider },
42+
{ TuiConfigProvider },
43+
{ ToastProvider },
44+
{ OpencodeKeymapProvider, registerOpencodeKeymap },
45+
] = await Promise.all([
46+
import("../../../src/cli/cmd/tui/ui/dialog"),
47+
import("../../../src/cli/cmd/tui/ui/dialog-prompt"),
48+
import("../../../src/cli/cmd/tui/context/kv"),
49+
import("../../../src/cli/cmd/tui/context/theme"),
50+
import("../../../src/cli/cmd/tui/context/tui-config"),
51+
import("../../../src/cli/cmd/tui/ui/toast"),
52+
import("../../../src/cli/cmd/tui/keymap"),
53+
])
54+
55+
function Harness() {
56+
const renderer = useRenderer()
57+
const keymap = createDefaultOpenTuiKeymap(renderer)
58+
const resolvedConfig = createTuiResolvedConfig({
59+
keybinds: input.keybinds,
60+
leader_timeout: 1000,
61+
})
62+
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
63+
onCleanup(off)
64+
65+
return (
66+
<OpencodeKeymapProvider keymap={keymap}>
67+
<TuiConfigProvider config={resolvedConfig}>
68+
<KVProvider>
69+
<ThemeProvider mode="dark">
70+
<ToastProvider>
71+
<DialogProvider>
72+
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
73+
</DialogProvider>
74+
</ToastProvider>
75+
</ThemeProvider>
76+
</KVProvider>
77+
</TuiConfigProvider>
78+
</OpencodeKeymapProvider>
79+
)
80+
}
81+
82+
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
83+
return {
84+
app,
85+
async cleanup() {
86+
app.renderer.destroy()
87+
Global.Path.config = previous.config
88+
Global.Path.state = previous.state
89+
},
90+
}
91+
}
92+
93+
test("dialog prompt submit wins when return is also input newline", async () => {
94+
await using tmp = await tmpdir()
95+
const confirmed: string[] = []
96+
const prompt = await mountPrompt({
97+
root: tmp.path,
98+
keybinds: {
99+
input_submit: "super+return",
100+
input_newline: "return,shift+return,alt+return,ctrl+j",
101+
},
102+
onConfirm: (value) => confirmed.push(value),
103+
})
104+
105+
try {
106+
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
107+
const textarea = prompt.app.renderer.currentFocusedEditor
108+
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
109+
110+
prompt.app.mockInput.pressEnter()
111+
112+
expect(confirmed).toEqual(["draft"])
113+
expect(textarea.plainText).toBe("draft")
114+
} finally {
115+
await prompt.cleanup()
116+
}
117+
})
118+
119+
test("dialog prompt submit can be rebound separately from input submit", async () => {
120+
await using tmp = await tmpdir()
121+
const confirmed: string[] = []
122+
const prompt = await mountPrompt({
123+
root: tmp.path,
124+
keybinds: {
125+
input_submit: "return",
126+
"dialog.prompt.submit": "ctrl+y",
127+
},
128+
onConfirm: (value) => confirmed.push(value),
129+
})
130+
131+
try {
132+
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
133+
const textarea = prompt.app.renderer.currentFocusedEditor
134+
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
135+
136+
prompt.app.mockInput.pressEnter()
137+
expect(confirmed).toEqual([])
138+
expect(textarea.plainText).toBe("draft")
139+
140+
prompt.app.mockInput.pressKey("y", { ctrl: true })
141+
142+
expect(confirmed).toEqual(["draft"])
143+
} finally {
144+
await prompt.cleanup()
145+
}
146+
})

‎packages/opencode/test/config/tui.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
470470
which_key_toggle: "alt+k",
471471
editor_open: "ctrl+e",
472472
"prompt.autocomplete.next": "ctrl+j",
473+
"dialog.prompt.submit": "ctrl+s",
473474
"dialog.mcp.toggle": "ctrl+t",
474475
model_favorite_toggle: "ctrl+f",
475476
"dialog.plugins.install": "shift+i",
@@ -491,6 +492,7 @@ it.instance("resolves keybind lookup from canonical keybinds", () =>
491492
)
492493
expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
493494
expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
495+
expect(config.keybinds.get("dialog.prompt.submit")?.[0]?.key).toBe("ctrl+s")
494496
expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
495497
expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
496498
expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")

‎packages/web/src/content/docs/keybinds.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`.
145145
"dialog.select.home": "home",
146146
"dialog.select.end": "end",
147147
"dialog.select.submit": "return",
148+
"dialog.prompt.submit": "return",
148149
"dialog.mcp.toggle": "space",
149150
"prompt.autocomplete.prev": "up,ctrl+p",
150151
"prompt.autocomplete.next": "down,ctrl+n",

0 commit comments

Comments
 (0)