diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md index 1151cd2..f3978be 100644 --- a/.changeset/mobile-enter-newline.md +++ b/.changeset/mobile-enter-newline.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). +Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 85d022e..0a0a0f5 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,7 +13,7 @@ import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; -import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnter } from "../promptEnterBehavior"; +import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -61,6 +61,7 @@ export class PromptEditor extends LitElement { private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); + private explicitShiftKeyActive = false; protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -230,6 +231,10 @@ export class PromptEditor extends LitElement { syntaxHighlighting(defaultHighlightStyle, { fallback: true }), EditorView.lineWrapping, EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), + EditorView.domEventHandlers({ + keyup: (event) => this.handleEditorKeyUp(event), + blur: () => this.resetEditorModifierState(), + }), placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), @@ -237,11 +242,10 @@ export class PromptEditor extends LitElement { if (update.docChanged) this.updateDraft(update.state.doc.toString()); }), keymap.of([ + { any: (view, event) => this.handleEditorKeyDown(event, view) }, { key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "Escape", run: () => this.closeCompletions() }, - { key: "Enter", run: (view) => this.handleEditorEnter(view) }, - { key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, { key: "Backspace", run: (view) => deleteMarkupBackward(view) }, @@ -337,13 +341,39 @@ export class PromptEditor extends LitElement { return true; } - private handleEditorEnter(view: EditorView): boolean { - if (this.completions.length) { + private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean { + if (event.key === "Shift") { + this.explicitShiftKeyActive = true; + return false; + } + if (event.key !== "Enter") { + this.explicitShiftKeyActive = false; + return false; + } + if (event.defaultPrevented || event.isComposing || view.composing) return false; + + const shiftKey = shouldUsePromptEnterShiftShortcut(event.shiftKey, this.explicitShiftKeyActive, this.mobilePromptEnterMedia); + this.explicitShiftKeyActive = false; + return this.handleEditorEnter(view, shiftKey); + } + + private handleEditorKeyUp(event: KeyboardEvent): boolean { + if (event.key === "Shift") this.explicitShiftKeyActive = false; + return false; + } + + private resetEditorModifierState(): boolean { + this.explicitShiftKeyActive = false; + return false; + } + + private handleEditorEnter(view: EditorView, shiftKey: boolean): boolean { + if (!shiftKey && this.completions.length) { const completion = this.completions[this.selectedIndex]; if (completion !== undefined) this.pick(completion); return true; } - if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia, readPromptEnterPreference())) { + if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) { return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts index c0847a9..0f9cda7 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -16,12 +16,12 @@ const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: stri { value: "send", label: "Enter sends message", - description: "Plain Enter sends the chat message from this browser.", + description: "Enter sends the chat message; Shift+Enter adds a new line when supported.", }, { value: "newline", label: "Enter inserts new line", - description: "Plain Enter adds a line break; use the send button to send.", + description: "Enter adds a line break; Shift+Enter sends the chat message when supported.", }, ]; @@ -127,9 +127,9 @@ export class SettingsShortcutsPanel extends LitElement {
Chat composer

Enter key behavior

-

Choose what plain Enter does in this browser.

+

Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.

-
+
${PROMPT_ENTER_OPTIONS.map((option) => html`