authorgravatar for magnus.buvarp@gmail.comMagnus Buvarp <magnus.buvarp@gmail.com> 2026-04-10 02:53:06+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 00:53:06+00:00
log65d797c13e21223ba7f44e16630000db6fd4671d
treeb03bdf4c0cbcef01a875dce8512d28a247acf1d3
parent7c0849fe2e449c25ff43a1d5993f6f75b2be3a59
signature Signed by PGP key B5690EEEBB952194

feat(web): add surround selection in composer (#1502)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 888 insertions(+), 17 deletions(-)

apps/web/src/components/ChatView.browser.tsx+373-1
...@@ -915,13 +915,189 @@ async function waitForComposerEditor(): Promise<HTMLElement> {...@@ -915,13 +915,189 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
915 );915 );
916}916}
917917
918async function pressComposerKey(key: string): Promise<void> {
919 const composerEditor = await waitForComposerEditor();
920 composerEditor.focus();
921 const keydownEvent = new KeyboardEvent("keydown", {
922 key,
923 bubbles: true,
924 cancelable: true,
925 });
926 composerEditor.dispatchEvent(keydownEvent);
927 if (keydownEvent.defaultPrevented) {
928 await waitForLayout();
929 return;
930 }
931
932 const beforeInputEvent = new InputEvent("beforeinput", {
933 data: key,
934 inputType: "insertText",
935 bubbles: true,
936 cancelable: true,
937 });
938 composerEditor.dispatchEvent(beforeInputEvent);
939 if (beforeInputEvent.defaultPrevented) {
940 await waitForLayout();
941 return;
942 }
943
944 if (
945 typeof document.execCommand === "function" &&
946 document.execCommand("insertText", false, key)
947 ) {
948 await waitForLayout();
949 return;
950 }
951
952 const selection = window.getSelection();
953 if (!selection || selection.rangeCount === 0) {
954 throw new Error("Unable to resolve composer selection for text input.");
955 }
956 const range = selection.getRangeAt(0);
957 range.deleteContents();
958 const textNode = document.createTextNode(key);
959 range.insertNode(textNode);
960 range.setStartAfter(textNode);
961 range.collapse(true);
962 selection.removeAllRanges();
963 selection.addRange(range);
964 composerEditor.dispatchEvent(
965 new InputEvent("input", {
966 data: key,
967 inputType: "insertText",
968 bubbles: true,
969 }),
970 );
971 await waitForLayout();
972}
973
974async function pressComposerUndo(): Promise<void> {
975 const composerEditor = await waitForComposerEditor();
976 const useMetaForMod = isMacPlatform(navigator.platform);
977 composerEditor.focus();
978 composerEditor.dispatchEvent(
979 new KeyboardEvent("keydown", {
980 key: "z",
981 metaKey: useMetaForMod,
982 ctrlKey: !useMetaForMod,
983 bubbles: true,
984 cancelable: true,
985 }),
986 );
987 await waitForLayout();
988}
989
990async function waitForComposerText(expectedText: string): Promise<void> {
991 await vi.waitFor(
992 () => {
993 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(
994 expectedText,
995 );
996 },
997 { timeout: 8_000, interval: 16 },
998 );
999}
1000
1001async function setComposerSelectionByTextOffsets(options: {
1002 start: number;
1003 end: number;
1004 direction?: "forward" | "backward";
1005}): Promise<void> {
1006 const composerEditor = await waitForComposerEditor();
1007 composerEditor.focus();
1008 const resolvePoint = (targetOffset: number) => {
1009 const traversedRef = { value: 0 };
1010
1011 const visitNode = (node: Node): { node: Node; offset: number } | null => {
1012 if (node.nodeType === Node.TEXT_NODE) {
1013 const textLength = node.textContent?.length ?? 0;
1014 if (targetOffset <= traversedRef.value + textLength) {
1015 return {
1016 node,
1017 offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)),
1018 };
1019 }
1020 traversedRef.value += textLength;
1021 return null;
1022 }
1023
1024 if (node instanceof HTMLBRElement) {
1025 const parent = node.parentNode;
1026 if (!parent) {
1027 return null;
1028 }
1029 const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node);
1030 if (targetOffset <= traversedRef.value) {
1031 return { node: parent, offset: siblingIndex };
1032 }
1033 if (targetOffset <= traversedRef.value + 1) {
1034 return { node: parent, offset: siblingIndex + 1 };
1035 }
1036 traversedRef.value += 1;
1037 return null;
1038 }
1039
1040 if (node instanceof Element || node instanceof DocumentFragment) {
1041 for (const child of node.childNodes) {
1042 const point = visitNode(child);
1043 if (point) {
1044 return point;
1045 }
1046 }
1047 }
1048
1049 return null;
1050 };
1051
1052 return (
1053 visitNode(composerEditor) ?? {
1054 node: composerEditor,
1055 offset: composerEditor.childNodes.length,
1056 }
1057 );
1058 };
1059
1060 const startPoint = resolvePoint(options.start);
1061 const endPoint = resolvePoint(options.end);
1062 const selection = window.getSelection();
1063 if (!selection) {
1064 throw new Error("Unable to resolve window selection.");
1065 }
1066 selection.removeAllRanges();
1067
1068 if (options.direction === "backward" && "setBaseAndExtent" in selection) {
1069 selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset);
1070 await waitForLayout();
1071 return;
1072 }
1073
1074 const range = document.createRange();
1075 range.setStart(startPoint.node, startPoint.offset);
1076 range.setEnd(endPoint.node, endPoint.offset);
1077 selection.addRange(range);
1078 await waitForLayout();
1079}
1080
1081async function selectAllComposerContent(): Promise<void> {
1082 const composerEditor = await waitForComposerEditor();
1083 composerEditor.focus();
1084 const selection = window.getSelection();
1085 if (!selection) {
1086 throw new Error("Unable to resolve window selection.");
1087 }
1088 selection.removeAllRanges();
1089 const range = document.createRange();
1090 range.selectNodeContents(composerEditor);
1091 selection.addRange(range);
1092 await waitForLayout();
1093}
1094
918async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> {1095async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> {
919 return waitForElement(1096 return waitForElement(
920 () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`),1097 () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`),
921 `Unable to find composer menu item "${itemId}".`,1098 `Unable to find composer menu item "${itemId}".`,
922 );1099 );
923}1100}
924
925async function waitForSendButton(): Promise<HTMLButtonElement> {1101async function waitForSendButton(): Promise<HTMLButtonElement> {
926 return waitForElement(1102 return waitForElement(
927 () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),1103 () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
...@@ -2336,6 +2512,202 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2336,6 +2512,202 @@ describe("ChatView timeline estimator parity (full app)", () => {
2336 }2512 }
2337 });2513 });
23382514
2515 it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {
2516 const mounted = await mountChatView({
2517 viewport: DEFAULT_VIEWPORT,
2518 snapshot: createSnapshotForTargetUser({
2519 targetMessageId: "msg-user-surround-basic" as MessageId,
2520 targetText: "surround basic",
2521 }),
2522 });
2523
2524 try {
2525 useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected");
2526 await waitForComposerText("selected");
2527 await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length });
2528 await pressComposerKey("(");
2529 await waitForComposerText("(selected)");
2530
2531 await pressComposerKey("[");
2532 await waitForComposerText("([selected])");
2533 } finally {
2534 await mounted.cleanup();
2535 }
2536 });
2537
2538 it("leaves collapsed-caret typing unchanged for surround symbols", async () => {
2539 useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected");
2540
2541 const mounted = await mountChatView({
2542 viewport: DEFAULT_VIEWPORT,
2543 snapshot: createSnapshotForTargetUser({
2544 targetMessageId: "msg-user-surround-collapsed" as MessageId,
2545 targetText: "surround collapsed",
2546 }),
2547 });
2548
2549 try {
2550 await waitForComposerText("selected");
2551 await setComposerSelectionByTextOffsets({
2552 start: "selected".length,
2553 end: "selected".length,
2554 });
2555 await pressComposerKey("(");
2556 await waitForComposerText("selected(");
2557 } finally {
2558 await mounted.cleanup();
2559 }
2560 });
2561
2562 it("supports symmetric and backward-selection surrounds", async () => {
2563 useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward");
2564
2565 const mounted = await mountChatView({
2566 viewport: DEFAULT_VIEWPORT,
2567 snapshot: createSnapshotForTargetUser({
2568 targetMessageId: "msg-user-surround-backward" as MessageId,
2569 targetText: "surround backward",
2570 }),
2571 });
2572
2573 try {
2574 await waitForComposerText("backward");
2575 await setComposerSelectionByTextOffsets({
2576 start: 0,
2577 end: "backward".length,
2578 direction: "backward",
2579 });
2580 await pressComposerKey("*");
2581 await waitForComposerText("*backward*");
2582 } finally {
2583 await mounted.cleanup();
2584 }
2585 });
2586
2587 it("supports option-produced surround symbols like guillemets", async () => {
2588 useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted");
2589
2590 const mounted = await mountChatView({
2591 viewport: DEFAULT_VIEWPORT,
2592 snapshot: createSnapshotForTargetUser({
2593 targetMessageId: "msg-user-surround-guillemet" as MessageId,
2594 targetText: "surround guillemet",
2595 }),
2596 });
2597
2598 try {
2599 await waitForComposerText("quoted");
2600 await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length });
2601 await pressComposerKey("«");
2602 await waitForComposerText("«quoted»");
2603 } finally {
2604 await mounted.cleanup();
2605 }
2606 });
2607
2608 it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => {
2609 useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted");
2610
2611 const mounted = await mountChatView({
2612 viewport: DEFAULT_VIEWPORT,
2613 snapshot: createSnapshotForTargetUser({
2614 targetMessageId: "msg-user-surround-dead-quote" as MessageId,
2615 targetText: "surround dead quote",
2616 }),
2617 });
2618
2619 try {
2620 await waitForComposerText("quoted");
2621 await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length });
2622 const composerEditor = await waitForComposerEditor();
2623 composerEditor.focus();
2624 composerEditor.dispatchEvent(
2625 new KeyboardEvent("keydown", {
2626 key: "Dead",
2627 bubbles: true,
2628 cancelable: true,
2629 }),
2630 );
2631 composerEditor.dispatchEvent(
2632 new InputEvent("beforeinput", {
2633 data: "'",
2634 inputType: "insertCompositionText",
2635 bubbles: true,
2636 cancelable: true,
2637 }),
2638 );
2639 const resolvedInputEvent = new InputEvent("beforeinput", {
2640 data: "'",
2641 inputType: "insertText",
2642 bubbles: true,
2643 cancelable: true,
2644 });
2645 composerEditor.dispatchEvent(resolvedInputEvent);
2646 expect(resolvedInputEvent.defaultPrevented).toBe(true);
2647 await waitForComposerText("'quoted'");
2648 await pressComposerUndo();
2649 await waitForComposerText("quoted");
2650 } finally {
2651 await mounted.cleanup();
2652 }
2653 });
2654
2655 it("surrounds text after a mention using the correct expanded offsets", async () => {
2656 useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there");
2657
2658 const mounted = await mountChatView({
2659 viewport: DEFAULT_VIEWPORT,
2660 snapshot: createSnapshotForTargetUser({
2661 targetMessageId: "msg-user-surround-after-mention" as MessageId,
2662 targetText: "surround after mention",
2663 }),
2664 });
2665
2666 try {
2667 await vi.waitFor(
2668 () => {
2669 expect(document.body.textContent).toContain("package.json");
2670 },
2671 { timeout: 8_000, interval: 16 },
2672 );
2673 await waitForComposerText("hi @package.json there");
2674 await setComposerSelectionByTextOffsets({
2675 start: "hi package.json ".length,
2676 end: "hi package.json there".length,
2677 });
2678 await pressComposerKey("(");
2679 await waitForComposerText("hi @package.json (there)");
2680 } finally {
2681 await mounted.cleanup();
2682 }
2683 });
2684
2685 it("falls back to normal replacement when the selection includes a mention token", async () => {
2686 useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there ");
2687
2688 const mounted = await mountChatView({
2689 viewport: DEFAULT_VIEWPORT,
2690 snapshot: createSnapshotForTargetUser({
2691 targetMessageId: "msg-user-surround-token" as MessageId,
2692 targetText: "surround token",
2693 }),
2694 });
2695
2696 try {
2697 await vi.waitFor(
2698 () => {
2699 expect(document.body.textContent).toContain("package.json");
2700 },
2701 { timeout: 8_000, interval: 16 },
2702 );
2703 await selectAllComposerContent();
2704 await pressComposerKey("(");
2705 await waitForComposerText("(");
2706 } finally {
2707 await mounted.cleanup();
2708 }
2709 });
2710
2339 it("shows runtime mode descriptions in the desktop composer access select", async () => {2711 it("shows runtime mode descriptions in the desktop composer access select", async () => {
2340 setDraftThreadWithoutWorktree();2712 setDraftThreadWithoutWorktree();
23412713
apps/web/src/components/ComposerPromptEditor.tsx+331-1
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 COMMAND_PRIORITY_HIGH,26 COMMAND_PRIORITY_HIGH,
27 KEY_BACKSPACE_COMMAND,27 KEY_BACKSPACE_COMMAND,
28 $getRoot,28 $getRoot,
29 HISTORY_MERGE_TAG,
29 DecoratorNode,30 DecoratorNode,
30 type ElementNode,31 type ElementNode,
31 type LexicalNode,32 type LexicalNode,
...@@ -58,7 +59,10 @@ import {...@@ -58,7 +59,10 @@ import {
58 expandCollapsedComposerCursor,59 expandCollapsedComposerCursor,
59 isCollapsedCursorAdjacentToInlineToken,60 isCollapsedCursorAdjacentToInlineToken,
60} from "~/composer-logic";61} from "~/composer-logic";
61import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";62import {
63 selectionTouchesMentionBoundary,
64 splitPromptIntoComposerSegments,
65} from "~/composer-editor-mentions";
62import {66import {
63 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,67 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
64 type TerminalContextDraft,68 type TerminalContextDraft,
...@@ -73,6 +77,21 @@ import {...@@ -73,6 +77,21 @@ import {
73import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";77import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
7478
75const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;79const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
80const SURROUND_SYMBOLS: [string, string][] = [
81 ["(", ")"],
82 ["[", "]"],
83 ["{", "}"],
84 ["'", "'"],
85 ['"', '"'],
86 ["“", "”"],
87 ["`", "`"],
88 ["<", ">"],
89 ["«", "»"],
90 ["*", "*"],
91 ["_", "_"],
92];
93const SURROUND_SYMBOLS_MAP = new Map<string, string>(SURROUND_SYMBOLS);
94const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null;
7695
77type SerializedComposerMentionNode = Spread<96type SerializedComposerMentionNode = Spread<
78 {97 {
...@@ -553,6 +572,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void {...@@ -553,6 +572,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void {
553 $setSelection(selection);572 $setSelection(selection);
554}573}
555574
575function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void {
576 const root = $getRoot();
577 const composerLength = $getComposerRootLength();
578 const boundedStart = Math.max(0, Math.min(startOffset, composerLength));
579 const boundedEnd = Math.max(0, Math.min(endOffset, composerLength));
580 const anchorRemainingRef = { value: boundedStart };
581 const focusRemainingRef = { value: boundedEnd };
582 const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? {
583 key: root.getKey(),
584 offset: root.getChildren().length,
585 type: "element" as const,
586 };
587 const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? {
588 key: root.getKey(),
589 offset: root.getChildren().length,
590 type: "element" as const,
591 };
592 const selection = $createRangeSelection();
593 selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type);
594 selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type);
595 $setSelection(selection);
596}
597
598function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType<typeof $getSelection>): {
599 start: number;
600 end: number;
601} | null {
602 if (!$isRangeSelection(selection)) {
603 return null;
604 }
605 const anchorNode = selection.anchor.getNode();
606 const focusNode = selection.focus.getNode();
607 const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
608 const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset);
609 return {
610 start: Math.min(anchorOffset, focusOffset),
611 end: Math.max(anchorOffset, focusOffset),
612 };
613}
614
615function $selectionTouchesInlineToken(selection: ReturnType<typeof $getSelection>): boolean {
616 if (!$isRangeSelection(selection)) {
617 return false;
618 }
619 return selection.getNodes().some((node) => isComposerInlineTokenNode(node));
620}
621
556function $readSelectionOffsetFromEditorState(fallback: number): number {622function $readSelectionOffsetFromEditorState(fallback: number): number {
557 const selection = $getSelection();623 const selection = $getSelection();
558 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {624 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
...@@ -878,6 +944,269 @@ function ComposerInlineTokenBackspacePlugin() {...@@ -878,6 +944,269 @@ function ComposerInlineTokenBackspacePlugin() {
878 return null;944 return null;
879}945}
880946
947function ComposerSurroundSelectionPlugin(props: {
948 terminalContexts: ReadonlyArray<TerminalContextDraft>;
949}) {
950 const [editor] = useLexicalComposerContext();
951 const terminalContextsRef = useRef(props.terminalContexts);
952 const pendingSurroundSelectionRef = useRef<{
953 value: string;
954 expandedStart: number;
955 expandedEnd: number;
956 } | null>(null);
957 const pendingDeadKeySelectionRef = useRef<{
958 value: string;
959 expandedStart: number;
960 expandedEnd: number;
961 } | null>(null);
962
963 useEffect(() => {
964 terminalContextsRef.current = props.terminalContexts;
965 }, [props.terminalContexts]);
966
967 const applySurroundInsertion = useCallback(
968 (inputData: string): boolean => {
969 const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
970 const pendingSurroundSelection = pendingSurroundSelectionRef.current;
971 if (!surroundCloseSymbol) {
972 pendingSurroundSelectionRef.current = null;
973 return false;
974 }
975
976 let handled = false;
977 editor.update(() => {
978 const selectionSnapshot =
979 pendingSurroundSelection ??
980 (() => {
981 const selection = $getSelection();
982 if (!$isRangeSelection(selection) || selection.isCollapsed()) {
983 return null;
984 }
985 if ($selectionTouchesInlineToken(selection)) {
986 return null;
987 }
988 const range = getSelectionRangeForExpandedComposerOffsets(selection);
989 if (!range || range.start === range.end) {
990 return null;
991 }
992 const value = $getRoot().getTextContent();
993 if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
994 return null;
995 }
996 return {
997 value,
998 expandedStart: range.start,
999 expandedEnd: range.end,
1000 };
1001 })();
1002
1003 if (!selectionSnapshot || !surroundCloseSymbol) {
1004 return;
1005 }
1006
1007 const selectedText = selectionSnapshot.value.slice(
1008 selectionSnapshot.expandedStart,
1009 selectionSnapshot.expandedEnd,
1010 );
1011 const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1012 $setComposerEditorPrompt(nextValue, terminalContextsRef.current);
1013 const selectionStart = collapseExpandedComposerCursor(
1014 nextValue,
1015 selectionSnapshot.expandedStart,
1016 );
1017 $setSelectionRangeAtComposerOffsets(
1018 selectionStart + inputData.length,
1019 selectionStart + inputData.length + selectedText.length,
1020 );
1021 handled = true;
1022 pendingSurroundSelectionRef.current = null;
1023 });
1024
1025 return handled;
1026 },
1027 [editor],
1028 );
1029
1030 useEffect(() => {
1031 const onKeyDown = (event: KeyboardEvent) => {
1032 if (pendingDeadKeySelectionRef.current) {
1033 if (event.key === "Dead" || event.key === " " || event.code === "Space") {
1034 return;
1035 }
1036 pendingDeadKeySelectionRef.current = null;
1037 }
1038
1039 if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) {
1040 pendingSurroundSelectionRef.current = null;
1041 pendingDeadKeySelectionRef.current = null;
1042 return;
1043 }
1044
1045 editor.getEditorState().read(() => {
1046 const selection = $getSelection();
1047 if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1048 pendingSurroundSelectionRef.current = null;
1049 pendingDeadKeySelectionRef.current = null;
1050 return;
1051 }
1052 if ($selectionTouchesInlineToken(selection)) {
1053 pendingSurroundSelectionRef.current = null;
1054 pendingDeadKeySelectionRef.current = null;
1055 return;
1056 }
1057 const range = getSelectionRangeForExpandedComposerOffsets(selection);
1058 if (!range || range.start === range.end) {
1059 pendingSurroundSelectionRef.current = null;
1060 pendingDeadKeySelectionRef.current = null;
1061 return;
1062 }
1063 const value = $getRoot().getTextContent();
1064 if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1065 pendingSurroundSelectionRef.current = null;
1066 pendingDeadKeySelectionRef.current = null;
1067 return;
1068 }
1069 const snapshot = {
1070 value,
1071 expandedStart: range.start,
1072 expandedEnd: range.end,
1073 };
1074 pendingSurroundSelectionRef.current = snapshot;
1075 pendingDeadKeySelectionRef.current = null;
1076 });
1077 };
1078
1079 const onBeforeInput = (event: InputEvent) => {
1080 if (
1081 event.inputType === "insertCompositionText" &&
1082 event.data === "`" &&
1083 BACKTICK_SURROUND_CLOSE_SYMBOL !== null &&
1084 pendingSurroundSelectionRef.current
1085 ) {
1086 pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current;
1087 return;
1088 }
1089
1090 if (pendingDeadKeySelectionRef.current) {
1091 return;
1092 }
1093
1094 if (event.inputType === "insertCompositionText") {
1095 return;
1096 }
1097
1098 if (typeof event.data !== "string") {
1099 pendingSurroundSelectionRef.current = null;
1100 return;
1101 }
1102 const inputData = event.inputType === "insertText" ? event.data : null;
1103 if (!inputData || inputData.length !== 1) {
1104 pendingSurroundSelectionRef.current = null;
1105 return;
1106 }
1107 if (!applySurroundInsertion(inputData)) {
1108 return;
1109 }
1110
1111 event.preventDefault();
1112 event.stopPropagation();
1113 event.stopImmediatePropagation();
1114 };
1115
1116 const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => {
1117 queueMicrotask(() => {
1118 editor.update(
1119 () => {
1120 const pendingDeadKeySelection = pendingDeadKeySelectionRef.current;
1121 if (!pendingDeadKeySelection) {
1122 return;
1123 }
1124
1125 const currentValue = $getRoot().getTextContent();
1126 const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL;
1127 if (backtickCloseSymbol === null) {
1128 pendingDeadKeySelectionRef.current = null;
1129 return;
1130 }
1131
1132 const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`;
1133 if (currentValue !== expectedResolvedValue) {
1134 if (options?.finalAttempt) {
1135 pendingSurroundSelectionRef.current = null;
1136 pendingDeadKeySelectionRef.current = null;
1137 }
1138 return;
1139 }
1140
1141 const selectedText = pendingDeadKeySelection.value.slice(
1142 pendingDeadKeySelection.expandedStart,
1143 pendingDeadKeySelection.expandedEnd,
1144 );
1145 const replacementStart = collapseExpandedComposerCursor(
1146 currentValue,
1147 pendingDeadKeySelection.expandedStart,
1148 );
1149 $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1);
1150 const replacementSelection = $getSelection();
1151 if (!$isRangeSelection(replacementSelection)) {
1152 pendingSurroundSelectionRef.current = null;
1153 pendingDeadKeySelectionRef.current = null;
1154 return;
1155 }
1156 replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`);
1157 $setSelectionRangeAtComposerOffsets(
1158 replacementStart + 1,
1159 replacementStart + 1 + selectedText.length,
1160 );
1161 pendingSurroundSelectionRef.current = null;
1162 pendingDeadKeySelectionRef.current = null;
1163 },
1164 { tag: HISTORY_MERGE_TAG },
1165 );
1166 });
1167 };
1168
1169 const onInput = (event: Event) => {
1170 const inputEvent = event as InputEvent;
1171 if (
1172 inputEvent.inputType === "insertText" ||
1173 inputEvent.inputType === "insertCompositionText"
1174 ) {
1175 tryApplyDeadKeyBacktickSurround();
1176 }
1177 };
1178
1179 const onCompositionEnd = () => {
1180 tryApplyDeadKeyBacktickSurround({ finalAttempt: true });
1181 };
1182
1183 let activeRootElement: HTMLElement | null = null;
1184 const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
1185 prevRootElement?.removeEventListener("keydown", onKeyDown);
1186 prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true);
1187 prevRootElement?.removeEventListener("input", onInput);
1188 prevRootElement?.removeEventListener("compositionend", onCompositionEnd);
1189 rootElement?.addEventListener("keydown", onKeyDown);
1190 rootElement?.addEventListener("beforeinput", onBeforeInput, true);
1191 rootElement?.addEventListener("input", onInput);
1192 rootElement?.addEventListener("compositionend", onCompositionEnd);
1193 activeRootElement = rootElement;
1194 });
1195
1196 return () => {
1197 if (activeRootElement) {
1198 activeRootElement.removeEventListener("keydown", onKeyDown);
1199 activeRootElement.removeEventListener("beforeinput", onBeforeInput, true);
1200 activeRootElement.removeEventListener("input", onInput);
1201 activeRootElement.removeEventListener("compositionend", onCompositionEnd);
1202 }
1203 unregisterRootListener();
1204 };
1205 }, [applySurroundInsertion, editor]);
1206
1207 return null;
1208}
1209
881function ComposerPromptEditorInner({1210function ComposerPromptEditorInner({
882 value,1211 value,
883 cursor,1212 cursor,
...@@ -1113,6 +1442,7 @@ function ComposerPromptEditorInner({...@@ -1113,6 +1442,7 @@ function ComposerPromptEditorInner({
1113 />1442 />
1114 <OnChangePlugin onChange={handleEditorChange} />1443 <OnChangePlugin onChange={handleEditorChange} />
1115 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />1444 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
1445 <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} />
1116 <ComposerInlineTokenArrowPlugin />1446 <ComposerInlineTokenArrowPlugin />
1117 <ComposerInlineTokenSelectionNormalizePlugin />1447 <ComposerInlineTokenSelectionNormalizePlugin />
1118 <ComposerInlineTokenBackspacePlugin />1448 <ComposerInlineTokenBackspacePlugin />
apps/web/src/composer-editor-mentions.test.ts+59-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";3import {
4 selectionTouchesMentionBoundary,
5 splitPromptIntoComposerSegments,
6} from "./composer-editor-mentions";
4import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";7import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
58
6describe("splitPromptIntoComposerSegments", () => {9describe("splitPromptIntoComposerSegments", () => {
...@@ -38,4 +41,59 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -38,4 +41,59 @@ describe("splitPromptIntoComposerSegments", () => {
38 { type: "text", text: " please" },41 { type: "text", text: " please" },
39 ]);42 ]);
40 });43 });
44
45 it("preserves consecutive terminal context placeholders without dropping positions", () => {
46 expect(
47 splitPromptIntoComposerSegments(
48 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}tail`,
49 ),
50 ).toEqual([
51 { type: "terminal-context", context: null },
52 { type: "terminal-context", context: null },
53 { type: "text", text: "tail" },
54 ]);
55 });
56});
57
58describe("selectionTouchesMentionBoundary", () => {
59 it("returns true when selection includes the whitespace after a mention", () => {
60 expect(
61 selectionTouchesMentionBoundary(
62 "hi @package.json there",
63 "hi @package.json".length,
64 "hi @package.json there".length,
65 ),
66 ).toBe(true);
67 });
68
69 it("returns true when selection includes the whitespace before a mention", () => {
70 expect(
71 selectionTouchesMentionBoundary(
72 "hi there @package.json later",
73 "hi there".length,
74 "hi there ".length,
75 ),
76 ).toBe(true);
77 });
78
79 it("returns false when selection starts after the mention boundary whitespace", () => {
80 expect(
81 selectionTouchesMentionBoundary(
82 "hi @package.json there",
83 "hi @package.json ".length,
84 "hi @package.json there".length,
85 ),
86 ).toBe(false);
87 });
88
89 it("returns true when selection includes whitespace after a mention following a terminal placeholder", () => {
90 const prompt = `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}@AGENTS.md there`;
91 expect(
92 selectionTouchesMentionBoundary(
93 prompt,
94 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}@AGENTS.md`.length,
95 prompt.length,
96 ),
97 ).toBe(true);
98 });
41});99});
apps/web/src/composer-editor-mentions.ts+125-14
...@@ -19,6 +19,10 @@ export type ComposerPromptSegment =...@@ -19,6 +19,10 @@ export type ComposerPromptSegment =
1919
20const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;20const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
2121
22function rangeIncludesIndex(start: number, end: number, index: number): boolean {
23 return start <= index && index < end;
24}
25
22function pushTextSegment(segments: ComposerPromptSegment[], text: string): void {26function pushTextSegment(segments: ComposerPromptSegment[], text: string): void {
23 if (!text) return;27 if (!text) return;
24 const last = segments[segments.length - 1];28 const last = segments[segments.length - 1];
...@@ -29,6 +33,84 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void...@@ -29,6 +33,84 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void
29 segments.push({ type: "text", text });33 segments.push({ type: "text", text });
30}34}
3135
36function forEachPromptSegmentSlice(
37 prompt: string,
38 visitor: (
39 slice:
40 | {
41 type: "text";
42 text: string;
43 promptOffset: number;
44 }
45 | {
46 type: "terminal-context";
47 promptOffset: number;
48 },
49 ) => boolean | void,
50): boolean {
51 let textCursor = 0;
52
53 for (let index = 0; index < prompt.length; index += 1) {
54 if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
55 continue;
56 }
57
58 if (
59 index > textCursor &&
60 visitor({
61 type: "text",
62 text: prompt.slice(textCursor, index),
63 promptOffset: textCursor,
64 }) === true
65 ) {
66 return true;
67 }
68 if (visitor({ type: "terminal-context", promptOffset: index }) === true) {
69 return true;
70 }
71 textCursor = index + 1;
72 }
73
74 if (
75 textCursor < prompt.length &&
76 visitor({
77 type: "text",
78 text: prompt.slice(textCursor),
79 promptOffset: textCursor,
80 }) === true
81 ) {
82 return true;
83 }
84
85 return false;
86}
87
88function forEachPromptTextSlice(
89 prompt: string,
90 visitor: (text: string, promptOffset: number) => boolean | void,
91): boolean {
92 return forEachPromptSegmentSlice(prompt, (slice) => {
93 if (slice.type !== "text") {
94 return false;
95 }
96 return visitor(slice.text, slice.promptOffset);
97 });
98}
99
100function forEachMentionMatch(
101 prompt: string,
102 visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,
103): boolean {
104 return forEachPromptTextSlice(prompt, (text, promptOffset) => {
105 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
106 if (visitor(match, promptOffset) === true) {
107 return true;
108 }
109 }
110 return false;
111 });
112}
113
32function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] {114function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] {
33 const segments: ComposerPromptSegment[] = [];115 const segments: ComposerPromptSegment[] = [];
34 if (!text) {116 if (!text) {
...@@ -64,6 +146,43 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen...@@ -64,6 +146,43 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
64 return segments;146 return segments;
65}147}
66148
149export function selectionTouchesMentionBoundary(
150 prompt: string,
151 start: number,
152 end: number,
153): boolean {
154 if (!prompt || start >= end) {
155 return false;
156 }
157
158 return forEachMentionMatch(prompt, (match, promptOffset) => {
159 const fullMatch = match[0];
160 const prefix = match[1] ?? "";
161 const matchIndex = match.index ?? 0;
162 const mentionStart = promptOffset + matchIndex + prefix.length;
163 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
164 const beforeMentionIndex = mentionStart - 1;
165 const afterMentionIndex = mentionEnd;
166
167 if (
168 beforeMentionIndex >= 0 &&
169 /\s/.test(prompt[beforeMentionIndex] ?? "") &&
170 rangeIncludesIndex(start, end, beforeMentionIndex)
171 ) {
172 return true;
173 }
174
175 if (
176 afterMentionIndex < prompt.length &&
177 /\s/.test(prompt[afterMentionIndex] ?? "") &&
178 rangeIncludesIndex(start, end, afterMentionIndex)
179 ) {
180 return true;
181 }
182 return false;
183 });
184}
185
67export function splitPromptIntoComposerSegments(186export function splitPromptIntoComposerSegments(
68 prompt: string,187 prompt: string,
69 terminalContexts: ReadonlyArray<TerminalContextDraft> = [],188 terminalContexts: ReadonlyArray<TerminalContextDraft> = [],
...@@ -73,28 +192,20 @@ export function splitPromptIntoComposerSegments(...@@ -73,28 +192,20 @@ export function splitPromptIntoComposerSegments(
73 }192 }
74193
75 const segments: ComposerPromptSegment[] = [];194 const segments: ComposerPromptSegment[] = [];
76 let textCursor = 0;
77 let terminalContextIndex = 0;195 let terminalContextIndex = 0;
78196 forEachPromptSegmentSlice(prompt, (slice) => {
79 for (let index = 0; index < prompt.length; index += 1) {197 if (slice.type === "text") {
80 if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {198 segments.push(...splitPromptTextIntoComposerSegments(slice.text));
81 continue;199 return false;
82 }200 }
83201
84 if (index > textCursor) {
85 segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor, index)));
86 }
87 segments.push({202 segments.push({
88 type: "terminal-context",203 type: "terminal-context",
89 context: terminalContexts[terminalContextIndex] ?? null,204 context: terminalContexts[terminalContextIndex] ?? null,
90 });205 });
91 terminalContextIndex += 1;206 terminalContextIndex += 1;
92 textCursor = index + 1;207 return false;
93 }208 });
94
95 if (textCursor < prompt.length) {
96 segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor)));
97 }
98209
99 return segments;210 return segments;
100}211}