From 32ea809adcae7d0225ccdbecb8eb05abcd285174 Mon Sep 17 00:00:00 2001 From: Andrey Romantsev Date: Thu, 25 Jun 2026 18:11:41 +0200 Subject: [PATCH 1/3] fix: keep Enter as newline in mobile chat composer --- .changeset/mobile-enter-newline.md | 5 +++++ src/client/src/components/PromptEditor.ts | 9 +++++++-- src/client/src/promptEnterBehavior.test.ts | 17 +++++++++++++++++ src/client/src/promptEnterBehavior.ts | 11 +++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 .changeset/mobile-enter-newline.md create mode 100644 src/client/src/promptEnterBehavior.test.ts create mode 100644 src/client/src/promptEnterBehavior.ts diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md new file mode 100644 index 0000000..fd2296b --- /dev/null +++ b/.changeset/mobile-enter-newline.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep Enter/Return in the mobile chat composer for new lines, and send messages there only from the send button. diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index a5dc404..944c3a9 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,6 +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, shouldSendPromptOnEnter } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -59,6 +60,7 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); + private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -238,7 +240,7 @@ export class PromptEditor extends LitElement { { key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "Escape", run: () => this.closeCompletions() }, - { key: "Enter", run: () => this.handleEditorEnter() }, + { 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 }, @@ -335,12 +337,15 @@ export class PromptEditor extends LitElement { return true; } - private handleEditorEnter(): boolean { + private handleEditorEnter(view: EditorView): boolean { if (this.completions.length) { const completion = this.completions[this.selectedIndex]; if (completion !== undefined) this.pick(completion); return true; } + if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia)) { + return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); + } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); return true; } diff --git a/src/client/src/promptEnterBehavior.test.ts b/src/client/src/promptEnterBehavior.test.ts new file mode 100644 index 0000000..bb5eabe --- /dev/null +++ b/src/client/src/promptEnterBehavior.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { MOBILE_PROMPT_ENTER_MEDIA_QUERY, shouldSendPromptOnEnter, type PromptEnterMedia } from "./promptEnterBehavior"; + +describe("promptEnterBehavior", () => { + it("uses the expected mobile media query", () => { + expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)"); + }); + + it("sends on Enter outside the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia)).toBe(true); + expect(shouldSendPromptOnEnter(undefined)).toBe(true); + }); + + it("keeps Enter as a newline in the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia)).toBe(false); + }); +}); diff --git a/src/client/src/promptEnterBehavior.ts b/src/client/src/promptEnterBehavior.ts new file mode 100644 index 0000000..faef8ac --- /dev/null +++ b/src/client/src/promptEnterBehavior.ts @@ -0,0 +1,11 @@ +export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)"; + +export type PromptEnterMedia = Pick; + +export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined { + return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined; +} + +export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia()): boolean { + return media?.matches !== true; +} From fd386b2d6be02a7f8d8b4b5af19847b206e8ca7c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 22:55:09 +0200 Subject: [PATCH 2/3] feat: add prompt enter behavior setting --- .changeset/mobile-enter-newline.md | 2 +- package-lock.json | 2 +- src/client/src/components/PromptEditor.ts | 4 +- .../settings/SettingsShortcutsPanel.ts | 69 +++++++++++++++- src/client/src/promptEnterBehavior.test.ts | 81 +++++++++++++++++-- src/client/src/promptEnterBehavior.ts | 39 ++++++++- 6 files changed, 184 insertions(+), 13 deletions(-) diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md index fd2296b..1151cd2 100644 --- a/.changeset/mobile-enter-newline.md +++ b/.changeset/mobile-enter-newline.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep Enter/Return in the mobile chat composer for new lines, and send messages there only from the send button. +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). diff --git a/package-lock.json b/package-lock.json index de4644b..ea3cc33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1575,7 +1575,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 944c3a9..85d022e 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, shouldSendPromptOnEnter } from "../promptEnterBehavior"; +import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnter } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -343,7 +343,7 @@ export class PromptEditor extends LitElement { if (completion !== undefined) this.pick(completion); return true; } - if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia)) { + if (!shouldSendPromptOnEnter(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 34db3f0..c0847a9 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -3,9 +3,28 @@ import { customElement, property, state } from "lit/decorators.js"; import type { AppAction } from "../../actions"; import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api"; import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts"; +import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior"; const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; +const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: string; description: string }[] = [ + { + value: "auto", + label: "Auto/default", + description: "Desktop-like Enter sends; mobile, coarse pointer, or narrow screens insert a new line.", + }, + { + value: "send", + label: "Enter sends message", + description: "Plain Enter sends the chat message from this browser.", + }, + { + value: "newline", + label: "Enter inserts new line", + description: "Plain Enter adds a line break; use the send button to send.", + }, +]; + @customElement("settings-shortcuts-panel") export class SettingsShortcutsPanel extends LitElement { @property({ attribute: false }) actions: AppAction[] = []; @@ -18,6 +37,7 @@ export class SettingsShortcutsPanel extends LitElement { @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; @state() private drafts: Record = {}; @state() private localError = ""; + @state() private promptEnterPreference: PromptEnterPreference = readPromptEnterPreference(); @state() private recording: RecordingState | undefined; private recordingTimer: number | undefined; private recordingListenerActive = false; @@ -75,6 +95,7 @@ export class SettingsShortcutsPanel extends LitElement { ${this.renderMessages()} + ${this.renderPromptEnterPreferenceCard()} ${this.configResponse === undefined && this.loading ? html`
Loading shortcuts…
` : html`
Config file @@ -100,6 +121,40 @@ export class SettingsShortcutsPanel extends LitElement { return null; } + private renderPromptEnterPreferenceCard(): TemplateResult { + return html` +
+
+ Chat composer +

Enter key behavior

+

Choose what plain Enter does in this browser.

+
+
+ ${PROMPT_ENTER_OPTIONS.map((option) => html` + + `)} +
+
+ `; + } + + private updatePromptEnterPreference(preference: PromptEnterPreference): void { + this.promptEnterPreference = preference; + writePromptEnterPreference(preference); + } + private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult { const shortcuts = this.configResponse?.config.shortcuts; const configured = shortcutPreference(action.id, shortcuts); @@ -291,13 +346,22 @@ export class SettingsShortcutsPanel extends LitElement { button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } .secondary { flex: 0 0 auto; } - .message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message, .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .message { margin-bottom: 12px; } .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } .loading-card, .config-path-card { color: var(--pi-muted); } .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } - .config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + .config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + .prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; } + .prompt-enter-copy { display: grid; gap: 5px; min-width: 0; } + .prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; } + .prompt-enter-options { display: grid; gap: 7px; } + .prompt-enter-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; color: var(--pi-text); } + .prompt-enter-option input { box-sizing: border-box; width: 14px; min-width: 14px; height: 14px; margin: 3px 0 0; padding: 0; border: 0; background: transparent; accent-color: var(--pi-accent); font-family: inherit; } + .prompt-enter-option input:focus { border-color: transparent; box-shadow: none; outline: 2px solid var(--pi-accent-border); outline-offset: 2px; } + .prompt-enter-option span { display: grid; gap: 2px; } + .prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } .shortcut-group { margin: 0 0 16px; } .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } @@ -330,6 +394,7 @@ export class SettingsShortcutsPanel extends LitElement { @media (max-width: 760px) { .section-heading { display: grid; gap: 12px; } .section-heading .secondary { justify-self: start; } + .prompt-enter-card { grid-template-columns: minmax(0, 1fr); } .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } .shortcut-status, .shortcut-actions { justify-content: flex-start; } } diff --git a/src/client/src/promptEnterBehavior.test.ts b/src/client/src/promptEnterBehavior.test.ts index bb5eabe..b3a14a9 100644 --- a/src/client/src/promptEnterBehavior.test.ts +++ b/src/client/src/promptEnterBehavior.test.ts @@ -1,17 +1,86 @@ import { describe, expect, it } from "vitest"; -import { MOBILE_PROMPT_ENTER_MEDIA_QUERY, shouldSendPromptOnEnter, type PromptEnterMedia } from "./promptEnterBehavior"; +import { + MOBILE_PROMPT_ENTER_MEDIA_QUERY, + parsePromptEnterPreference, + PROMPT_ENTER_PREFERENCE_STORAGE_KEY, + readPromptEnterPreference, + shouldSendPromptOnEnter, + writePromptEnterPreference, + type PromptEnterMedia, +} from "./promptEnterBehavior"; describe("promptEnterBehavior", () => { it("uses the expected mobile media query", () => { expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)"); }); - it("sends on Enter outside the mobile environment", () => { - expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia)).toBe(true); - expect(shouldSendPromptOnEnter(undefined)).toBe(true); + it("uses the environment default when the preference is auto", () => { + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "auto")).toBe(true); + expect(shouldSendPromptOnEnter(undefined, "auto")).toBe(true); + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "auto")).toBe(false); }); - it("keeps Enter as a newline in the mobile environment", () => { - expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia)).toBe(false); + it("lets explicit preferences override the environment", () => { + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "send")).toBe(true); + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "newline")).toBe(false); + expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false); + }); + + it("parses local storage preference values", () => { + expect(parsePromptEnterPreference("auto")).toBe("auto"); + expect(parsePromptEnterPreference("send")).toBe("send"); + expect(parsePromptEnterPreference("newline")).toBe("newline"); + expect(parsePromptEnterPreference(null)).toBe("auto"); + expect(parsePromptEnterPreference("return")).toBe("auto"); + }); + + it("reads and writes the stored preference", () => { + const storage = new FakeStorage(); + + expect(readPromptEnterPreference(storage)).toBe("auto"); + writePromptEnterPreference("send", storage); + expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("send"); + expect(readPromptEnterPreference(storage)).toBe("send"); + + writePromptEnterPreference("newline", storage); + expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("newline"); + expect(readPromptEnterPreference(storage)).toBe("newline"); + + writePromptEnterPreference("auto", storage); + expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("auto"); + expect(readPromptEnterPreference(storage)).toBe("auto"); + }); + + it("ignores storage failures", () => { + const storage = new ThrowingStorage(); + + expect(readPromptEnterPreference(storage)).toBe("auto"); + expect(() => { writePromptEnterPreference("send", storage); }).not.toThrow(); }); }); + +class FakeStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + value(key: string): string | undefined { + return this.values.get(key); + } +} + +class ThrowingStorage { + getItem(): string | null { + throw new Error("blocked"); + } + + setItem(): void { + throw new Error("blocked"); + } +} diff --git a/src/client/src/promptEnterBehavior.ts b/src/client/src/promptEnterBehavior.ts index faef8ac..b47fcb3 100644 --- a/src/client/src/promptEnterBehavior.ts +++ b/src/client/src/promptEnterBehavior.ts @@ -1,11 +1,48 @@ export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)"; +export const PROMPT_ENTER_PREFERENCE_STORAGE_KEY = "pi-web.promptEnterPreference"; +export type PromptEnterPreference = "auto" | "send" | "newline"; export type PromptEnterMedia = Pick; +export type PromptEnterPreferenceStorage = Pick; export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined { return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined; } -export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia()): boolean { +export function parsePromptEnterPreference(value: string | null): PromptEnterPreference { + if (value === "send" || value === "newline") return value; + return "auto"; +} + +export function readPromptEnterPreference(storage = browserStorage()): PromptEnterPreference { + if (storage === undefined) return "auto"; + try { + return parsePromptEnterPreference(storage.getItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)); + } catch { + return "auto"; + } +} + +export function writePromptEnterPreference(preference: PromptEnterPreference, storage = browserStorage()): void { + if (storage === undefined) return; + try { + storage.setItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY, preference); + } catch { + // Ignore localStorage quota/privacy errors; Auto remains the safe fallback. + } +} + +export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean { + if (preference === "send") return true; + if (preference === "newline") return false; return media?.matches !== true; } + +function browserStorage(): PromptEnterPreferenceStorage | undefined { + if (typeof window === "undefined") return undefined; + try { + return window.localStorage; + } catch { + return undefined; + } +} From a43c782043ad511e09d4d9d4638e3aac42587d3b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 23:34:41 +0200 Subject: [PATCH 3/3] feat: add configurable prompt Enter behavior --- .changeset/mobile-enter-newline.md | 2 +- src/client/src/components/PromptEditor.ts | 42 ++++++++++++++++--- .../settings/SettingsShortcutsPanel.ts | 8 ++-- src/client/src/promptEnterBehavior.test.ts | 19 +++++++++ src/client/src/promptEnterBehavior.ts | 13 ++++++ 5 files changed, 73 insertions(+), 11 deletions(-) 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`