Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { act, fakeTimers, render } from "../../test/render";

import { flatToMarkdown, pmToFlat, serializeEditorDoc } from "~/composer-rich-text-doc";
import { isMacPlatform } from "~/lib/utils";
import { registerCommands } from "~/shortcuts/dispatcher";
import {
ComposerPromptEditorTiptap as ComposerPromptEditor,
type ComposerPromptEditorHandle,
Expand All @@ -36,11 +37,13 @@ function Composer({
richTextEnabled,
skills,
onChange,
onCommandKeyDown = () => false,
}: {
value: string;
richTextEnabled: boolean;
skills: ReadonlyArray<ServerProviderSkill>;
onChange?: ComposerPromptEditorProps["onChange"] | undefined;
onCommandKeyDown?: ComposerPromptEditorProps["onCommandKeyDown"] | undefined;
}) {
const [prompt, setPrompt] = useState({ value, cursor: value.length });
return (
Expand All @@ -58,6 +61,7 @@ function Composer({
onChange?.(...args);
}}
onPaste={() => {}}
onCommandKeyDown={onCommandKeyDown}
/>
);
}
Expand All @@ -67,9 +71,16 @@ function composer(
richTextEnabled = true,
skills: ReadonlyArray<ServerProviderSkill> = [],
onChange?: ComposerPromptEditorProps["onChange"],
onCommandKeyDown?: ComposerPromptEditorProps["onCommandKeyDown"],
) {
return (
<Composer value={value} richTextEnabled={richTextEnabled} skills={skills} onChange={onChange} />
<Composer
value={value}
richTextEnabled={richTextEnabled}
skills={skills}
onChange={onChange}
onCommandKeyDown={onCommandKeyDown}
/>
);
}

Expand Down Expand Up @@ -254,6 +265,55 @@ it.each([
},
);

it.each([
["e", false, false],
["i", false, false],
["e", true, false],
["e", false, true],
])(
"leaves Mod+%s to an app command bound to it instead of formatting (composer command: %s, declined capture command: %s)",
async (key, composerClaimsKey, declinedCapture) => {
const openUsage = vi.fn((event: KeyboardEvent) => {
if (declinedCapture) return false;
event.preventDefault();
});
using _commands = {
[Symbol.dispose]: registerCommands(
{ "usage.open": openUsage },
{
keybindings: () => [
{
command: "usage.open",
shortcut: {
key,
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: true,
},
},
],
capture: declinedCapture,
},
),
};
const composerCommand = vi.fn(() => composerClaimsKey);
await using screen = await mountEditor("word", true, [], undefined, composerCommand);
const { editor } = screen;
await act(async () => {
editor.commands.focus();
editor.commands.setTextSelection({ from: 1, to: 5 });
});
await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom));
const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control";
await userEvent.keyboard(`{${modifier}>}${key}{/${modifier}}`);
expect(composerCommand).toHaveBeenCalled();
expect(openUsage).toHaveBeenCalledTimes(composerClaimsKey ? 0 : 1);
expect(serializeEditorDoc(editor.state.doc).value).toBe(declinedCapture ? "`word`" : "word");
},
);

it.each([false, true])(
"composes an umlaut without wrapping the preedit text (rich text: %s)",
async (richTextEnabled) => {
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ import {
} from "~/composer-undo-grouping";
import { collectInlineContextIds } from "~/lib/composerContextReferences";
import { resolveDiffThemeName } from "~/lib/diffRendering";
import { isRichTextFormattingShortcut } from "~/keybindings";
import { cn, isMacPlatform } from "~/lib/utils";
import { hasCommandForKeyDown } from "~/shortcuts/dispatcher";
import { basenameOfPath } from "~/pierre-icons";
import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon";
import { FileTagChipContent } from "./chat/FileTagChip";
Expand Down Expand Up @@ -1107,6 +1109,19 @@ function ComposerPromptEditorTiptapInner(
editable: !disabled,
editorProps: {
attributes: editorAttributes,
handleDOMEvents: {
// Composer commands keep precedence; otherwise true skips the editor's keymaps without
// preventDefault, so a bound app command still runs.
keydown: (_view, event) => {
if (!isRichTextFormattingShortcut(event)) return false;
if (readCallbacks().onCommandKeyDown?.(event.key, event)) {
event.preventDefault();
event.stopPropagation();
return true;
}
return hasCommandForKeyDown(event);
},
},
handleKeyDown: (view, event) => {
// In a right-to-left line the start is on the right.
const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft";
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,13 @@ export function isRichTextBoldShortcut(
);
}

/** Mod+B, Mod+I and Mod+E, the mark chords the rich-text composer binds. */
export function isRichTextFormattingShortcut(event: ShortcutEventLike): boolean {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return false;
const keys = resolveEventKeys(event);
return keys.has("b") || keys.has("i") || keys.has("e");
}

export function isTerminalClearShortcut(
event: ShortcutEventLike,
platform = navigator.platform,
Expand Down
25 changes: 21 additions & 4 deletions apps/web/src/shortcuts/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,21 @@ function isInsideConfirmation(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('[role="alertdialog"]') !== null;
}

function runScope(scope: CommandScope, event: KeyboardEvent): boolean {
function scopeCommandHandler(scope: CommandScope, event: KeyboardEvent) {
const { commands, options } = scope;
if (options.when && !options.when(event)) return false;
if (options.onKeyDown && options.onKeyDown(event) !== false) return true;
const command = resolveShortcutCommand(
event,
options.keybindings?.() ?? appAtomRegistry.get(primaryServerKeybindingsAtom),
{ context: { ...readFocusContext(event), ...options.context?.() } },
);
const handler = command === null ? undefined : commands[command];
return command === null ? undefined : commands[command];
}

function runScope(scope: CommandScope, event: KeyboardEvent): boolean {
const { options } = scope;
if (options.when && !options.when(event)) return false;
if (options.onKeyDown && options.onKeyDown(event) !== false) return true;
const handler = scopeCommandHandler(scope, event);
if (handler === undefined) return false;
if (isInsideConfirmation(event.target)) {
event.preventDefault();
Expand All @@ -75,6 +80,18 @@ function runScope(scope: CommandScope, event: KeyboardEvent): boolean {
return handler(event) !== false;
}

/**
* Whether a bubble-phase command is bound to this keydown, for widgets that would otherwise
* consume it. Capture scopes have already run (and declined) by the time a widget sees it.
*/
export function hasCommandForKeyDown(event: KeyboardEvent): boolean {
return globalScopes.some(
(scope) =>
(!scope.options.when || scope.options.when(event)) &&
scopeCommandHandler(scope, event) !== undefined,
);
}

function runScopes(scopes: readonly CommandScope[], event: KeyboardEvent): boolean {
for (let index = scopes.length - 1; index >= 0; index -= 1) {
if (event.defaultPrevented) return true;
Expand Down
Loading