Skip to content

Commit ad364b9

Browse files
feat(app): make prompt action keybinds configurable
Expose submit, newline, and alternate delivery (queue/steer) in App Keyboard Shortcuts as disabled command entries, with editor-local dispatch. Preserve default bindings, suggestion acceptance, IME guards, repeat suppression, and native newline undo while supporting exact custom, swapped, or disabled bindings and effective shortcut hints. Cover matching and dispatch with unit tests and bounded newline insertion with browser tests. Exercise live Settings rebinding, delivery, disabling, default restoration, hints, and native undo in the Composer E2E keeper, and provide a safe submit/newline rebinding story. Closes anomalyco#43088
1 parent e9def88 commit ad364b9

14 files changed

Lines changed: 741 additions & 27 deletions

File tree

‎packages/app/e2e/regression/composer.spec.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,104 @@ async function expectCaretVisible(input: Locator) {
2727
.toBe(true)
2828
}
2929

30+
test("prompt shortcuts rebind through Settings, preserve newline undo, and restore defaults", async ({ page }) => {
31+
const prompts: { text: unknown; delivery: unknown }[] = []
32+
33+
const { editor } = await openSession(page, {
34+
name: "ComposerShortcuts",
35+
sessionStatus: { ses_composershortcuts: { type: "running" } },
36+
onPrompt: ({ body }) => void prompts.push({ text: body.text, delivery: body.delivery }),
37+
})
38+
39+
const settings = page.getByTestId("settings-screen")
40+
const binding = (id: string) => settings.locator(`[data-keybind-id="prompt.${id}"]`)
41+
42+
const shortcuts = async () => {
43+
await page.keyboard.press("Control+,")
44+
await settings.getByRole("tab", { name: "Shortcuts", exact: true }).click()
45+
await expect(settings.getByRole("heading", { name: "Keyboard shortcuts", exact: true })).toBeVisible()
46+
}
47+
48+
const record = async (id: string, keys: string, label: string | RegExp) => {
49+
await binding(id).click()
50+
await expect(binding(id)).toHaveText("Press keys")
51+
await page.keyboard.press(keys)
52+
await expect(binding(id)).toHaveText(label)
53+
}
54+
55+
await editor.fill("hello")
56+
await shortcuts()
57+
await expect(binding("submit")).toHaveText("Enter")
58+
// Move alternate delivery first, freeing Ctrl+Enter for primary submission.
59+
await record("submit.alternate", "Control+Shift+Enter", /^(Ctrl\+Shift\+|⌃⇧)Enter$/)
60+
await record("submit", "Backspace", "Unassigned")
61+
await record("newline", "Enter", "Enter")
62+
await record("submit", "Control+Enter", /^(Ctrl\+|⌃)Enter$/)
63+
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
64+
await expect(editor).toHaveText("hello")
65+
66+
await editor.press("ControlOrMeta+Home")
67+
await editor.press("ArrowRight")
68+
await editor.press("Shift+ArrowRight")
69+
await editor.press("Shift+ArrowRight")
70+
await editor.press("Shift+ArrowRight")
71+
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("ell")
72+
await editor.press("Enter")
73+
await expect.poll(() => editor.innerText()).toBe("h\no")
74+
await editor.press("ControlOrMeta+z")
75+
await expect(editor).toHaveText("hello")
76+
77+
await page.locator('[data-action="composer-submit"]').hover()
78+
await expect(page.getByRole("tooltip").locator('[data-slot="keybind-v2-label"]')).toHaveText([/^(Ctrl|⌃)$/, "Enter"])
79+
await editor.press("Control+Enter")
80+
await expect(editor).toBeEmpty()
81+
await expect.poll(() => prompts).toEqual([{ text: "hello", delivery: "steer" }])
82+
83+
await editor.fill("queued follow-up")
84+
await expect(page.locator('[data-action="composer-alternate-delivery"]')).toHaveText(/Queue.*Enter/)
85+
await expect(page.locator('[data-action="composer-alternate-delivery"] [data-slot="keybind-v2-label"]')).toHaveText([
86+
/^(Ctrl|⌃)$/,
87+
/^(Shift|⇧)$/,
88+
"Enter",
89+
])
90+
await editor.press("Control+Shift+Enter")
91+
await expect(editor).toBeEmpty()
92+
await expect
93+
.poll(() => prompts)
94+
.toEqual([
95+
{ text: "hello", delivery: "steer" },
96+
{ text: "queued follow-up", delivery: "queue" },
97+
])
98+
99+
await shortcuts()
100+
101+
for (const id of ["submit", "newline", "submit.alternate"]) await record(id, "Backspace", "Unassigned")
102+
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
103+
await editor.fill("disabled shortcuts")
104+
105+
for (const keys of ["Enter", "Control+Enter", "Control+Shift+Enter"]) await editor.press(keys)
106+
await expect(editor).toHaveText("disabled shortcuts")
107+
await expect(page.locator('[data-action="composer-alternate-delivery"] [data-slot="keybind-v2-label"]')).toHaveCount(
108+
0,
109+
)
110+
expect(prompts).toHaveLength(2)
111+
112+
await shortcuts()
113+
await settings.getByRole("button", { name: "Reset to defaults", exact: true }).click()
114+
await expect(binding("submit")).toHaveText("Enter")
115+
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
116+
await editor.fill("restored defaults")
117+
await editor.press("Enter")
118+
await expect(editor).toBeEmpty()
119+
await expect
120+
.poll(() => prompts)
121+
.toEqual([
122+
{ text: "hello", delivery: "steer" },
123+
{ text: "queued follow-up", delivery: "queue" },
124+
{ text: "restored defaults", delivery: "steer" },
125+
])
126+
})
127+
30128
test("keeps a 25000-line crash report editable in a new session", async ({ page }) => {
31129
const input = await draft(page)
32130
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(25000) + "End of report"

‎packages/app/src/app.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import { type BaseRouterProps, Router } from "@solidjs/router"
88
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
99
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
1010
import { Dynamic } from "solid-js/web"
11-
import { CommandProvider } from "@/shell/commands/command"
11+
import { CommandProvider, useCommand } from "@/shell/commands/command"
1212
import { DesktopCommands } from "@/shell/commands/desktop"
13+
import { promptKeybindOptions, PROMPT_KEYBINDS } from "@/composer/keybinds"
1314
import { GlobalProvider } from "@/runtime/server/runtime"
14-
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
15+
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/runtime/i18n/language"
1516
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
1617
import { SettingsProvider } from "@/settings/model"
1718
import { TabsProvider } from "@/shell/tabs/tabs"
@@ -45,6 +46,21 @@ function BodyTypography() {
4546
return null
4647
}
4748

49+
function PromptCommands() {
50+
const command = useCommand()
51+
const language = useLanguage()
52+
53+
command.register("prompt", () =>
54+
promptKeybindOptions({
55+
submit: language.t(PROMPT_KEYBINDS.submit.title),
56+
newline: language.t(PROMPT_KEYBINDS.newline.title),
57+
alternate: language.t(PROMPT_KEYBINDS.alternate.title),
58+
}),
59+
)
60+
61+
return null
62+
}
63+
4864
export function AppBaseProviders(
4965
props: ParentProps<{
5066
locale?: Locale
@@ -98,6 +114,7 @@ export function AppInterface(props: {
98114
<GlobalProvider>
99115
<BodyTypography />
100116
<CommandProvider>
117+
<PromptCommands />
101118
<DesktopCommands />
102119
{props.children}
103120
{rootProps.children}

‎packages/app/src/composer/composer.stories.tsx‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Show, createMemo, onMount, type JSX } from "solid-js"
1+
import { Show, createMemo, onCleanup, onMount, type JSX } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import type { ModelSelection } from "@/providers/models/selection"
44
import { STORY_MODEL, emptySessionDocument, pendingAndQueuedDocument } from "@opencode/session-ui/storybook"
@@ -11,6 +11,8 @@ import { promptLength } from "./prompt-parts"
1111
import { SessionPreview } from "@/session/story-model"
1212
import { Skill } from "@opencode/schema/skill"
1313
import { resolveSessionComposerSelection } from "@/session/composer/selection"
14+
import { useSettings } from "@/settings/model"
15+
import { PROMPT_KEYBINDS } from "./keybinds"
1416

1517
const selectedModel = {
1618
id: STORY_MODEL.id,
@@ -209,8 +211,7 @@ function ComposerStory(props: {
209211
: `Submitted: ${value}`,
210212
)
211213
},
212-
onStop: () =>
213-
setStory("activity", props.continueOnStop ? "POST /interrupt · resume: true" : "Stop requested"),
214+
onStop: () => setStory("activity", props.continueOnStop ? "POST /interrupt · resume: true" : "Stop requested"),
214215
},
215216
},
216217
})
@@ -250,6 +251,57 @@ export const EmptyDraft = { render: () => <ComposerStory /> }
250251

251252
export const TextDraft = { render: () => <ComposerStory prompt={text("Explain this change")} /> }
252253

254+
export const ConfigurableShortcuts = {
255+
render: () => {
256+
const settings = useSettings()
257+
258+
const original = {
259+
submit: settings.keybinds.get(PROMPT_KEYBINDS.submit.id),
260+
newline: settings.keybinds.get(PROMPT_KEYBINDS.newline.id),
261+
}
262+
263+
onCleanup(() => {
264+
for (const action of ["submit", "newline"] as const) {
265+
const value = original[action]
266+
267+
if (value === undefined) settings.keybinds.reset(PROMPT_KEYBINDS[action].id)
268+
269+
if (value !== undefined) settings.keybinds.set(PROMPT_KEYBINDS[action].id, value)
270+
}
271+
})
272+
273+
return (
274+
<div>
275+
<button
276+
onClick={() => {
277+
settings.keybinds.reset(PROMPT_KEYBINDS.submit.id)
278+
settings.keybinds.reset(PROMPT_KEYBINDS.newline.id)
279+
}}
280+
>
281+
Default shortcuts
282+
</button>
283+
<button
284+
onClick={() => {
285+
settings.keybinds.set(PROMPT_KEYBINDS.submit.id, "ctrl+enter")
286+
settings.keybinds.set(PROMPT_KEYBINDS.newline.id, "enter")
287+
}}
288+
>
289+
Enter for newline
290+
</button>
291+
<button
292+
onClick={() => {
293+
settings.keybinds.set(PROMPT_KEYBINDS.submit.id, "none")
294+
settings.keybinds.set(PROMPT_KEYBINDS.newline.id, "none")
295+
}}
296+
>
297+
Disable shortcuts
298+
</button>
299+
<ComposerStory prompt={text("Explain this change")} />
300+
</div>
301+
)
302+
},
303+
}
304+
253305
export const MultilineDraft = {
254306
render: () => <ComposerStory prompt={text("Review the implementation\nThen run the focused tests")} />,
255307
}

‎packages/app/src/composer/composer.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
1212
import { formatKeybind, useCommand } from "@/shell/commands/command"
1313
import { useLanguage } from "@/runtime/i18n/language"
1414
import type { ComposerModel } from "./model"
15+
import { createPromptKeybinds, PROMPT_KEYBINDS } from "./keybinds"
1516

1617
export function Composer(props: {
1718
class?: string
@@ -23,6 +24,7 @@ export function Composer(props: {
2324
const dialog = useDialog()
2425
const command = useCommand()
2526
const language = useLanguage()
27+
const keybinds = createPromptKeybinds()
2628

2729
return (
2830
<div class="flex flex-col gap-3">
@@ -34,9 +36,11 @@ export function Composer(props: {
3436
modelControlsVisible={!props.model.model.loading}
3537
attachKeybind={command.keybindParts("file.attach")}
3638
attachShortcut={command.keybind("file.attach")}
37-
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
39+
alternateKeybind={command.keybindParts(PROMPT_KEYBINDS.alternate.id)}
3840
exitShellKeybind={[formatKeybind("esc", language.t)]}
3941
suggestionBoundary={props.suggestionBoundary}
42+
keybinds={keybinds}
43+
submitKeybind={command.keybindParts(PROMPT_KEYBINDS.submit.id)}
4044
modelControl={
4145
<ComposerModelControl
4246
loading={props.model.model.loading}

‎packages/app/src/composer/editor/editor.tsx‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type {
4545
} from "../types"
4646
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
4747
import { isAttachment } from "../prompt-parts"
48+
import { handleComposerKeyDown, type ComposerKeybinds } from "./keybind"
4849
import "../attachments/attachments.css"
4950
import "./editor.css"
5051

@@ -89,6 +90,8 @@ export type ComposerEditorProps = {
8990
alternateKeybind?: string[]
9091
exitShellKeybind?: string[]
9192
suggestionBoundary?: () => HTMLElement | undefined
93+
keybinds?: ComposerKeybinds
94+
submitKeybind?: string[]
9295
}
9396

9497
export function ComposerEditor(props: ComposerEditorProps) {
@@ -117,6 +120,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
117120
onCleanup(() => observer.disconnect())
118121
})
119122
let localInput = false
123+
let composing = false
120124

121125
const updateCursor = () => {
122126
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -255,22 +259,28 @@ export function ComposerEditor(props: ComposerEditorProps) {
255259
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
256260
}}
257261
onKeyDown={(event) => {
258-
if (!view.draftOnly && props.controller.onKeyDown(event)) return
259-
const mod = event.metaKey || event.ctrlKey
260-
261-
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
262-
if (view.submit.queue?.editFirst()) event.preventDefault()
263-
264-
return
265-
}
266-
267-
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
268-
event.preventDefault()
269-
270-
if (event.repeat) return
271-
props.controller.submit(mod ? { alternate: true } : undefined)
272-
}
262+
handleComposerKeyDown({
263+
event,
264+
keybinds: props.keybinds,
265+
composing,
266+
selectSuggestion: () => {
267+
const popover = state.popover
268+
269+
if (popover.type === "closed") return false
270+
const item = props.controller.suggestions().find((entry) => entry.id === popover.activeID)
271+
272+
if (item) props.controller.dispatch({ type: "popover.select", item })
273+
274+
return true
275+
},
276+
handleController: () => !view.draftOnly && props.controller.onKeyDown(event),
277+
editFirst: view.submit.queue?.editFirst,
278+
insertNewline: props.controller.insertNewline,
279+
submit: (alternate) => props.controller.submit(alternate ? { alternate: true } : undefined),
280+
})
273281
}}
282+
onCompositionStart={() => (composing = true)}
283+
onCompositionEnd={() => (composing = false)}
274284
onKeyUp={updateCursor}
275285
onPointerUp={updateCursor}
276286
onPaste={(event) => {
@@ -401,6 +411,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
401411
disabled={!props.controller.canSubmit()}
402412
sendLabel={i18n.t("ui.promptInput.send")}
403413
stopLabel={i18n.t("ui.promptInput.stop")}
414+
sendKeybind={props.submitKeybind}
404415
onSubmit={() => props.controller.submit()}
405416
onStop={props.controller.stop}
406417
/>
@@ -1074,8 +1085,8 @@ function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
10741085
)
10751086
}
10761087

1077-
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
1078-
// delivery opposite to what plain Enter does. Visible only while the queue
1088+
// Steer / Queue action with its configured shortcut: submits with the
1089+
// delivery opposite to normal submission. Visible only while the queue
10791090
// exposes an alternate (turn running and composer holding a value), so it
10801091
// disappears on its own when the current turn ends.
10811092
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
@@ -1129,14 +1140,26 @@ export function ComposerEditorSubmitButton(props: {
11291140
disabled: boolean
11301141
sendLabel: string
11311142
stopLabel: string
1143+
sendKeybind?: string[]
11321144
onSubmit: () => void
11331145
onStop: () => void
11341146
}) {
11351147
return (
11361148
<Tooltip
11371149
placement="top"
11381150
inactive={!props.stopping && props.disabled}
1139-
value={props.stopping ? props.stopLabel : props.sendLabel}
1151+
value={
1152+
props.stopping ? (
1153+
props.stopLabel
1154+
) : (
1155+
<>
1156+
{props.sendLabel}
1157+
<Show when={props.sendKeybind?.length}>
1158+
<Keybind keys={props.sendKeybind ?? []} variant="neutral" />
1159+
</Show>
1160+
</>
1161+
)
1162+
}
11401163
>
11411164
<IconButton
11421165
data-action="composer-submit"

0 commit comments

Comments
 (0)