feat: add configurable prompt Enter behavior

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 23:34:41 +02:00
parent fd386b2d6b
commit a43c782043
5 changed files with 73 additions and 11 deletions
+1 -1
View File
@@ -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).
+36 -6
View File
@@ -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<this>) {
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);
@@ -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 {
<div class="prompt-enter-copy">
<span class="card-eyebrow">Chat composer</span>
<h3 id="prompt-enter-preference-title">Enter key behavior</h3>
<p>Choose what plain Enter does in this browser.</p>
<p>Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.</p>
</div>
<div class="prompt-enter-options" role="radiogroup" aria-label="Plain Enter behavior in the chat composer">
<div class="prompt-enter-options" role="radiogroup" aria-label="Enter and Shift Enter behavior in the chat composer">
${PROMPT_ENTER_OPTIONS.map((option) => html`
<label class="prompt-enter-option">
<input
@@ -5,6 +5,8 @@ import {
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
readPromptEnterPreference,
shouldSendPromptOnEnter,
shouldSendPromptOnEnterShortcut,
shouldUsePromptEnterShiftShortcut,
writePromptEnterPreference,
type PromptEnterMedia,
} from "./promptEnterBehavior";
@@ -26,6 +28,23 @@ describe("promptEnterBehavior", () => {
expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false);
});
it("swaps Shift+Enter with the plain Enter behavior", () => {
expect(shouldSendPromptOnEnterShortcut(false, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnterShortcut(true, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(false, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(true, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnterShortcut(true, undefined, "send")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(true, undefined, "newline")).toBe(true);
});
it("ignores implicit Shift state on mobile-like keyboards", () => {
expect(shouldUsePromptEnterShiftShortcut(false, true, { matches: true } satisfies PromptEnterMedia)).toBe(false);
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: true } satisfies PromptEnterMedia)).toBe(false);
expect(shouldUsePromptEnterShiftShortcut(true, true, { matches: true } satisfies PromptEnterMedia)).toBe(true);
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: false } satisfies PromptEnterMedia)).toBe(true);
expect(shouldUsePromptEnterShiftShortcut(true, false, undefined)).toBe(true);
});
it("parses local storage preference values", () => {
expect(parsePromptEnterPreference("auto")).toBe("auto");
expect(parsePromptEnterPreference("send")).toBe("send");
+13
View File
@@ -38,6 +38,19 @@ export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(),
return media?.matches !== true;
}
export function shouldUsePromptEnterShiftShortcut(shiftKey: boolean, explicitShiftKeyActive: boolean, media = createMobilePromptEnterMedia()): boolean {
// Touch keyboards can report autocapitalization as Shift on Enter after a line break.
// On mobile-like screens, only trust Shift when the editor saw an explicit Shift keydown.
if (!shiftKey) return false;
if (media?.matches === true) return explicitShiftKeyActive;
return true;
}
export function shouldSendPromptOnEnterShortcut(shiftKey: boolean, media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
const plainEnterSends = shouldSendPromptOnEnter(media, preference);
return shiftKey ? !plainEnterSends : plainEnterSends;
}
function browserStorage(): PromptEnterPreferenceStorage | undefined {
if (typeof window === "undefined") return undefined;
try {