Merge pull request #35 from aromancev/fix/mobile-enter-newline

fix: keep Enter as newline in mobile chat composer
This commit is contained in:
Federico Jaramillo Martinez
2026-06-26 00:15:12 +02:00
committed by GitHub
6 changed files with 278 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
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).
+1 -1
View File
@@ -1575,7 +1575,7 @@
"typebox": "1.1.38"
},
"bin": {
"pi-ai": "./dist/cli.js"
"pi-ai": "dist/cli.js"
},
"engines": {
"node": ">=22.19.0"
+39 -4
View File
@@ -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, 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";
@@ -52,6 +53,8 @@ export class PromptEditor extends LitElement {
private editor: EditorView | undefined;
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;
@@ -235,6 +238,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)),
@@ -242,11 +249,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: () => this.handleEditorEnter() },
{ 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) },
@@ -342,12 +348,41 @@ export class PromptEditor extends LitElement {
return true;
}
private handleEditorEnter(): 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 (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) {
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
}
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
return true;
}
@@ -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: "Enter sends the chat message; Shift+Enter adds a new line when supported.",
},
{
value: "newline",
label: "Enter inserts new line",
description: "Enter adds a line break; Shift+Enter sends the chat message when supported.",
},
];
@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<void>;
@state() private drafts: Record<string, string> = {};
@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 {
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
${this.renderPromptEnterPreferenceCard()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
@@ -100,6 +121,40 @@ export class SettingsShortcutsPanel extends LitElement {
return null;
}
private renderPromptEnterPreferenceCard(): TemplateResult {
return html`
<section class="prompt-enter-card" aria-labelledby="prompt-enter-preference-title">
<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 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="Enter and Shift Enter behavior in the chat composer">
${PROMPT_ENTER_OPTIONS.map((option) => html`
<label class="prompt-enter-option">
<input
type="radio"
name="prompt-enter-preference"
.value=${option.value}
.checked=${this.promptEnterPreference === option.value}
@change=${() => { this.updatePromptEnterPreference(option.value); }}
>
<span>
<strong>${option.label}</strong>
<small>${option.description}</small>
</span>
</label>
`)}
</div>
</section>
`;
}
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; }
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
MOBILE_PROMPT_ENTER_MEDIA_QUERY,
parsePromptEnterPreference,
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
readPromptEnterPreference,
shouldSendPromptOnEnter,
shouldSendPromptOnEnterShortcut,
shouldUsePromptEnterShiftShortcut,
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("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("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("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");
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<string, string>();
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");
}
}
+61
View File
@@ -0,0 +1,61 @@
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<MediaQueryList, "matches">;
export type PromptEnterPreferenceStorage = Pick<Storage, "getItem" | "setItem">;
export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined {
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined;
}
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;
}
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 {
return window.localStorage;
} catch {
return undefined;
}
}