Skip to content

Commit ab1363a

Browse files
feat(app): make prompt action keybinds configurable
Expose submit, newline, and alternate delivery (queue/steer) in App Keyboard Shortcuts while dispatching them inside the Composer. Preserve default bindings, suggestions, IME handling, and repeat suppression, with support for custom, swapped, or disabled bindings and current shortcut hints. Preserve native newline undo and reconcile individual shortcut resets so defaults are restored. Cover matching and delivery behavior with unit tests and live rebinding, reset, and newline undo with a Composer story and Chromium regression test. Closes anomalyco#43088
1 parent 9e153ce commit ab1363a

15 files changed

Lines changed: 654 additions & 21 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { expect, story } from "../../storybook/playwright/story"
2+
3+
story("rebinding updates the live composer and preserves native newline undo", async ({ mount }) => {
4+
const component = await mount("opencode-composer-flow--configurable-shortcuts")
5+
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
6+
await component.getByRole("button", { name: "Enter for newline", exact: true }).click()
7+
await input.fill("hello")
8+
await input.press("Home")
9+
await input.press("ArrowRight")
10+
await input.press("Shift+ArrowRight")
11+
await input.press("Shift+ArrowRight")
12+
await input.press("Shift+ArrowRight")
13+
await input.press("Enter")
14+
await expect.poll(() => input.innerText()).toBe("h\no")
15+
await expect(component.getByRole("status")).toHaveText("Ready")
16+
await input.press("ControlOrMeta+z")
17+
await expect(input).toHaveText("hello")
18+
await input.press("Control+Enter")
19+
await expect(component.getByRole("status")).toHaveText("Submitted: hello")
20+
21+
await component.getByRole("button", { name: "Disable shortcuts", exact: true }).click()
22+
await input.fill("unsent")
23+
await input.press("Enter")
24+
await input.press("Shift+Enter")
25+
await expect(input).toHaveText("unsent")
26+
await component.getByRole("button", { name: "Default shortcuts", exact: true }).click()
27+
await input.press("Enter")
28+
await expect(component.getByRole("status")).toHaveText("Submitted: unsent")
29+
})

‎packages/app/src/app.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ 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"
1415
import { HighlightsProvider } from "@/shell/updates/highlights"
15-
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
16+
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/runtime/i18n/language"
1617
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
1718
import { SettingsProvider } from "@/settings/model"
1819
import { TabsProvider } from "@/shell/tabs/tabs"
@@ -56,6 +57,21 @@ function BodyTypography() {
5657
return null
5758
}
5859

60+
function PromptCommands() {
61+
const command = useCommand()
62+
const language = useLanguage()
63+
64+
command.register("prompt", () =>
65+
promptKeybindOptions({
66+
submit: language.t(PROMPT_KEYBINDS.submit.title),
67+
newline: language.t(PROMPT_KEYBINDS.newline.title),
68+
alternate: language.t(PROMPT_KEYBINDS.alternate.title),
69+
}),
70+
)
71+
72+
return null
73+
}
74+
5975
export function AppBaseProviders(
6076
props: ParentProps<{
6177
locale?: Locale
@@ -112,6 +128,7 @@ export function AppInterface(props: {
112128
<GlobalProvider>
113129
<BodyTypography />
114130
<CommandProvider>
131+
<PromptCommands />
115132
<DesktopCommands />
116133
<SshRestore />
117134
<HighlightsProvider>

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

Lines changed: 60 additions & 1 deletion
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,10 @@ 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 { useCommand } from "@/shell/commands/command"
16+
import { useLanguage } from "@/runtime/i18n/language"
17+
import { PROMPT_KEYBINDS, promptKeybindOptions } from "./keybinds"
1418

1519
const selectedModel = {
1620
id: STORY_MODEL.id,
@@ -225,6 +229,61 @@ export const EmptyDraft = { render: () => <ComposerStory /> }
225229

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

232+
export const ConfigurableShortcuts = {
233+
render: () => {
234+
const settings = useSettings()
235+
const command = useCommand()
236+
const language = useLanguage()
237+
const original = {
238+
submit: settings.keybinds.get(PROMPT_KEYBINDS.submit.id),
239+
newline: settings.keybinds.get(PROMPT_KEYBINDS.newline.id),
240+
}
241+
command.register("prompt", () =>
242+
promptKeybindOptions({
243+
submit: language.t(PROMPT_KEYBINDS.submit.title),
244+
newline: language.t(PROMPT_KEYBINDS.newline.title),
245+
alternate: language.t(PROMPT_KEYBINDS.alternate.title),
246+
}),
247+
)
248+
onCleanup(() => {
249+
for (const action of ["submit", "newline"] as const) {
250+
const value = original[action]
251+
if (value === undefined) settings.keybinds.reset(PROMPT_KEYBINDS[action].id)
252+
if (value !== undefined) settings.keybinds.set(PROMPT_KEYBINDS[action].id, value)
253+
}
254+
})
255+
return (
256+
<div>
257+
<button
258+
onClick={() => {
259+
settings.keybinds.reset(PROMPT_KEYBINDS.submit.id)
260+
settings.keybinds.reset(PROMPT_KEYBINDS.newline.id)
261+
}}
262+
>
263+
Default shortcuts
264+
</button>
265+
<button
266+
onClick={() => {
267+
settings.keybinds.set(PROMPT_KEYBINDS.submit.id, "ctrl+enter")
268+
settings.keybinds.set(PROMPT_KEYBINDS.newline.id, "enter")
269+
}}
270+
>
271+
Enter for newline
272+
</button>
273+
<button
274+
onClick={() => {
275+
settings.keybinds.set(PROMPT_KEYBINDS.submit.id, "none")
276+
settings.keybinds.set(PROMPT_KEYBINDS.newline.id, "none")
277+
}}
278+
>
279+
Disable shortcuts
280+
</button>
281+
<ComposerStory prompt={text("Explain this change")} />
282+
</div>
283+
)
284+
},
285+
}
286+
228287
export const MultilineDraft = {
229288
render: () => <ComposerStory prompt={text("Review the implementation\nThen run the focused tests")} />,
230289
}

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

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

1516
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
1617
const dialog = useDialog()
1718
const command = useCommand()
1819
const language = useLanguage()
20+
const keybinds = createPromptKeybinds()
1921

2022
return (
2123
<div class="flex flex-col gap-3">
@@ -26,8 +28,10 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
2628
modelControlsVisible={!props.model.model.loading}
2729
attachKeybind={command.keybindParts("file.attach")}
2830
attachShortcut={command.keybind("file.attach")}
29-
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
31+
alternateKeybind={command.keybindParts(PROMPT_KEYBINDS.alternate.id)}
3032
exitShellKeybind={[formatKeybind("esc", language.t)]}
33+
keybinds={keybinds}
34+
submitKeybind={command.keybindParts(PROMPT_KEYBINDS.submit.id)}
3135
modelControl={
3236
<ComposerModelControl
3337
loading={props.model.model.loading}

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

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
ComposerSuggestion,
2525
} from "../types"
2626
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
27+
import { handleComposerKeyDown, type ComposerKeybinds } from "./keybind"
2728
import "../attachments/attachments.css"
2829
import "./editor.css"
2930

@@ -49,6 +50,8 @@ export type ComposerEditorProps = {
4950
attachShortcut?: string
5051
alternateKeybind?: string[]
5152
exitShellKeybind?: string[]
53+
keybinds?: ComposerKeybinds
54+
submitKeybind?: string[]
5255
}
5356

5457
export function ComposerEditor(props: ComposerEditorProps) {
@@ -75,6 +78,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
7578
onCleanup(() => observer.disconnect())
7679
})
7780
let localInput = false
81+
let composing = false
7882
const updateCursor = () => {
7983
if (!editor || !window.getSelection()?.isCollapsed) return
8084
props.controller.onCursor(composerCursor(editor))
@@ -85,7 +89,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
8589
"pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const),
8690
transition: "opacity 200ms ease",
8791
}))
88-
8992
createEffect(() => {
9093
const parts = props.controller.parts()
9194
if (!editor) return
@@ -197,18 +200,25 @@ export function ComposerEditor(props: ComposerEditorProps) {
197200
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
198201
}}
199202
onKeyDown={(event) => {
200-
if (!view.draftOnly && props.controller.onKeyDown(event)) return
201-
const mod = event.metaKey || event.ctrlKey
202-
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
203-
if (view.submit.queue?.editFirst()) event.preventDefault()
204-
return
205-
}
206-
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
207-
event.preventDefault()
208-
if (event.repeat) return
209-
props.controller.submit(mod ? { alternate: true } : undefined)
210-
}
203+
handleComposerKeyDown({
204+
event,
205+
keybinds: props.keybinds,
206+
composing,
207+
selectSuggestion: () => {
208+
const popover = state.popover
209+
if (popover.type === "closed") return false
210+
const item = props.controller.suggestions().find((entry) => entry.id === popover.activeID)
211+
if (item) props.controller.dispatch({ type: "popover.select", item })
212+
return true
213+
},
214+
handleController: () => !view.draftOnly && props.controller.onKeyDown(event),
215+
editFirst: view.submit.queue?.editFirst,
216+
insertNewline: props.controller.insertNewline,
217+
submit: (alternate) => props.controller.submit(alternate ? { alternate: true } : undefined),
218+
})
211219
}}
220+
onCompositionStart={() => (composing = true)}
221+
onCompositionEnd={() => (composing = false)}
212222
onKeyUp={updateCursor}
213223
onPointerUp={updateCursor}
214224
onPaste={(event) => {
@@ -333,6 +343,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
333343
disabled={!props.controller.canSubmit()}
334344
sendLabel={i18n.t("ui.promptInput.send")}
335345
stopLabel={i18n.t("ui.promptInput.stop")}
346+
sendKeybind={props.submitKeybind}
336347
onSubmit={() => props.controller.submit()}
337348
onStop={props.controller.stop}
338349
/>
@@ -777,8 +788,8 @@ export function ComposerEditorPopover(props: {
777788
)
778789
}
779790

780-
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
781-
// delivery opposite to what plain Enter does. Visible only while the queue
791+
// Steer / Queue action with its configured shortcut: submits with the
792+
// delivery opposite to normal submission. Visible only while the queue
782793
// exposes an alternate (turn running and composer holding a value), so it
783794
// disappears on its own when the current turn ends.
784795
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
@@ -826,14 +837,26 @@ export function ComposerEditorSubmitButton(props: {
826837
disabled: boolean
827838
sendLabel: string
828839
stopLabel: string
840+
sendKeybind?: string[]
829841
onSubmit: () => void
830842
onStop: () => void
831843
}) {
832844
return (
833845
<Tooltip
834846
placement="top"
835847
inactive={!props.stopping && props.disabled}
836-
value={props.stopping ? props.stopLabel : props.sendLabel}
848+
value={
849+
props.stopping ? (
850+
props.stopLabel
851+
) : (
852+
<>
853+
{props.sendLabel}
854+
<Show when={props.sendKeybind?.length}>
855+
<Keybind keys={props.sendKeybind ?? []} variant="neutral" />
856+
</Show>
857+
</>
858+
)
859+
}
837860
>
838861
<IconButton
839862
data-action="composer-submit"

‎packages/app/src/composer/editor/interaction.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ export function createComposerEditor(input: {
308308
parts() {
309309
return draft.state.prompt
310310
},
311+
insertNewline() {
312+
insertEditorNewline(editor)
313+
},
311314
contextItem(id: string) {
312315
return draft.state.context.items.find((item) => item.key === id)
313316
},
@@ -450,6 +453,23 @@ export function createComposerEditor(input: {
450453

451454
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
452455

456+
function insertEditorNewline(target: HTMLElement | undefined) {
457+
if (!target) return
458+
const selection = window.getSelection()
459+
if (!selection?.rangeCount) return
460+
const range = selection.getRangeAt(0)
461+
if (!target.contains(range.startContainer) || !target.contains(range.endContainer)) return
462+
if (typeof document.execCommand === "function" && document.execCommand("insertText", false, "\n")) return
463+
range.deleteContents()
464+
const node = document.createTextNode("\n")
465+
range.insertNode(node)
466+
range.setStartAfter(node)
467+
range.collapse(true)
468+
selection.removeAllRanges()
469+
selection.addRange(range)
470+
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertLineBreak", data: "\n" }))
471+
}
472+
453473
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
454474
const position = Math.max(0, Math.min(cursor, text.length))
455475
if (inHistory) return position === 0 || position === text.length

0 commit comments

Comments
 (0)