Archived
feat: add configurable prompt Enter behavior
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user