Skip to content

Commit ebea514

Browse files
chore(casual): merge prompt keybinds (anomalyco#43088)
2 parents 99503e8 + ab1363a commit ebea514

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)