Archived
feat: add configurable prompt Enter behavior
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@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 { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
|
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 { promptEditorStyles, type CompletionItem } from "./shared";
|
||||||
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
|
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
|
||||||
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
|
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
|
||||||
@@ -61,6 +61,7 @@ export class PromptEditor extends LitElement {
|
|||||||
private readonly editableCompartment = new Compartment();
|
private readonly editableCompartment = new Compartment();
|
||||||
private readonly readOnlyCompartment = new Compartment();
|
private readonly readOnlyCompartment = new Compartment();
|
||||||
private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia();
|
private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia();
|
||||||
|
private explicitShiftKeyActive = false;
|
||||||
|
|
||||||
protected override willUpdate(changed: PropertyValues<this>) {
|
protected override willUpdate(changed: PropertyValues<this>) {
|
||||||
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
||||||
@@ -230,6 +231,10 @@ export class PromptEditor extends LitElement {
|
|||||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
|
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"),
|
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
|
||||||
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||||
this.readOnlyCompartment.of(EditorState.readOnly.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());
|
if (update.docChanged) this.updateDraft(update.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
{ any: (view, event) => this.handleEditorKeyDown(event, view) },
|
||||||
{ key: "ArrowDown", run: () => this.moveCompletion(1) },
|
{ key: "ArrowDown", run: () => this.moveCompletion(1) },
|
||||||
{ key: "ArrowUp", run: () => this.moveCompletion(-1) },
|
{ key: "ArrowUp", run: () => this.moveCompletion(-1) },
|
||||||
{ key: "Escape", run: () => this.closeCompletions() },
|
{ 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: "Tab", run: (view) => this.handleEditorTab(view) },
|
||||||
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
|
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
|
||||||
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) },
|
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) },
|
||||||
@@ -337,13 +341,39 @@ export class PromptEditor extends LitElement {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEditorEnter(view: EditorView): boolean {
|
private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean {
|
||||||
if (this.completions.length) {
|
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];
|
const completion = this.completions[this.selectedIndex];
|
||||||
if (completion !== undefined) this.pick(completion);
|
if (completion !== undefined) this.pick(completion);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia, readPromptEnterPreference())) {
|
if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) {
|
||||||
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
|
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
|
||||||
}
|
}
|
||||||
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
|
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: stri
|
|||||||
{
|
{
|
||||||
value: "send",
|
value: "send",
|
||||||
label: "Enter sends message",
|
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",
|
value: "newline",
|
||||||
label: "Enter inserts new line",
|
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">
|
<div class="prompt-enter-copy">
|
||||||
<span class="card-eyebrow">Chat composer</span>
|
<span class="card-eyebrow">Chat composer</span>
|
||||||
<h3 id="prompt-enter-preference-title">Enter key behavior</h3>
|
<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>
|
||||||
<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`
|
${PROMPT_ENTER_OPTIONS.map((option) => html`
|
||||||
<label class="prompt-enter-option">
|
<label class="prompt-enter-option">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
|
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
|
||||||
readPromptEnterPreference,
|
readPromptEnterPreference,
|
||||||
shouldSendPromptOnEnter,
|
shouldSendPromptOnEnter,
|
||||||
|
shouldSendPromptOnEnterShortcut,
|
||||||
|
shouldUsePromptEnterShiftShortcut,
|
||||||
writePromptEnterPreference,
|
writePromptEnterPreference,
|
||||||
type PromptEnterMedia,
|
type PromptEnterMedia,
|
||||||
} from "./promptEnterBehavior";
|
} from "./promptEnterBehavior";
|
||||||
@@ -26,6 +28,23 @@ describe("promptEnterBehavior", () => {
|
|||||||
expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false);
|
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", () => {
|
it("parses local storage preference values", () => {
|
||||||
expect(parsePromptEnterPreference("auto")).toBe("auto");
|
expect(parsePromptEnterPreference("auto")).toBe("auto");
|
||||||
expect(parsePromptEnterPreference("send")).toBe("send");
|
expect(parsePromptEnterPreference("send")).toBe("send");
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(),
|
|||||||
return media?.matches !== true;
|
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 {
|
function browserStorage(): PromptEnterPreferenceStorage | undefined {
|
||||||
if (typeof window === "undefined") return undefined;
|
if (typeof window === "undefined") return undefined;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user