| author | |
| committer | |
| log | 65d797c13e21223ba7f44e16630000db6fd4671d |
| tree | b03bdf4c0cbcef01a875dce8512d28a247acf1d3 |
| parent | 7c0849fe2e449c25ff43a1d5993f6f75b2be3a59 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 917 | 917 | ||
| 918 | async 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 | |||
| 974 | async 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 | |||
| 990 | async 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 | |||
| 1001 | async 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 | |||
| 1081 | async 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 | |||
| 918 | async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> { | 1095 | async 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 | |||
| 925 | async function waitForSendButton(): Promise<HTMLButtonElement> { | 1101 | async 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 | }); |
| 2338 | 2514 | ||
| 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(); |
| 2341 | 2713 |
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"; |
| 61 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | 62 | import { |
| 63 | selectionTouchesMentionBoundary, | ||
| 64 | splitPromptIntoComposerSegments, | ||
| 65 | } from "~/composer-editor-mentions"; | ||
| 62 | import { | 66 | import { |
| 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 { |
| 73 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; | 77 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 74 | 78 | ||
| 75 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; | 79 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; |
| 80 | const SURROUND_SYMBOLS: [string, string][] = [ | ||
| 81 | ["(", ")"], | ||
| 82 | ["[", "]"], | ||
| 83 | ["{", "}"], | ||
| 84 | ["'", "'"], | ||
| 85 | ['"', '"'], | ||
| 86 | ["“", "”"], | ||
| 87 | ["`", "`"], | ||
| 88 | ["<", ">"], | ||
| 89 | ["«", "»"], | ||
| 90 | ["*", "*"], | ||
| 91 | ["_", "_"], | ||
| 92 | ]; | ||
| 93 | const SURROUND_SYMBOLS_MAP = new Map<string, string>(SURROUND_SYMBOLS); | ||
| 94 | const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; | ||
| 76 | 95 | ||
| 77 | type SerializedComposerMentionNode = Spread< | 96 | type 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 | } |
| 555 | 574 | ||
| 575 | function $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 | |||
| 598 | function 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 | |||
| 615 | function $selectionTouchesInlineToken(selection: ReturnType<typeof $getSelection>): boolean { | ||
| 616 | if (!$isRangeSelection(selection)) { | ||
| 617 | return false; | ||
| 618 | } | ||
| 619 | return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); | ||
| 620 | } | ||
| 621 | |||
| 556 | function $readSelectionOffsetFromEditorState(fallback: number): number { | 622 | function $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 | } |
| 880 | 946 | ||
| 947 | function 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 | |||
| 881 | function ComposerPromptEditorInner({ | 1210 | function 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 3 | import { |
| 4 | selectionTouchesMentionBoundary, | ||
| 5 | splitPromptIntoComposerSegments, | ||
| 6 | } from "./composer-editor-mentions"; | ||
| 4 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | 7 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 5 | 8 | ||
| 6 | describe("splitPromptIntoComposerSegments", () => { | 9 | describe("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 | |||
| 58 | describe("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 = |
| 19 | 19 | ||
| 20 | const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; | 20 | const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; |
| 21 | 21 | ||
| 22 | function rangeIncludesIndex(start: number, end: number, index: number): boolean { | ||
| 23 | return start <= index && index < end; | ||
| 24 | } | ||
| 25 | |||
| 22 | function pushTextSegment(segments: ComposerPromptSegment[], text: string): void { | 26 | function 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 | } |
| 31 | 35 | ||
| 36 | function 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 | |||
| 88 | function 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 | |||
| 100 | function 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 | |||
| 32 | function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { | 114 | function 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 | } |
| 66 | 148 | ||
| 149 | export 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 | |||
| 67 | export function splitPromptIntoComposerSegments( | 186 | export 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 | } |
| 74 | 193 | ||
| 75 | const segments: ComposerPromptSegment[] = []; | 194 | const segments: ComposerPromptSegment[] = []; |
| 76 | let textCursor = 0; | ||
| 77 | let terminalContextIndex = 0; | 195 | let terminalContextIndex = 0; |
| 78 | 196 | 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 | } |
| 83 | 201 | ||
| 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 | } | ||
| 98 | 209 | ||
| 99 | return segments; | 210 | return segments; |
| 100 | } | 211 | } |