Unified · Split
| @@ -915,13 +915,189 @@ async function waitForComposerEditor(): Promise<HTMLElement> { · 914 unmodified lines | ||
| 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 | 1095 | async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> { |
| 919 | 1096 | return waitForElement( |
| 920 | 1097 | () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`), |
| 921 | 1098 | `Unable to find composer menu item "${itemId}".`, |
| 922 | 1099 | ); |
| 923 | 1100 | } |
| 924 | − | |
| 925 | 1101 | async function waitForSendButton(): Promise<HTMLButtonElement> { |
| 926 | 1102 | return waitForElement( |
| 927 | 1103 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), |
| @@ -2336,6 +2512,202 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1408 unmodified lines | ||
| 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 | 2711 | it("shows runtime mode descriptions in the desktop composer access select", async () => { |
| 2340 | 2712 | setDraftThreadWithoutWorktree(); |
| 2341 | 2713 | |
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | COMMAND_PRIORITY_HIGH, |
| 27 | 27 | KEY_BACKSPACE_COMMAND, |
| 28 | 28 | $getRoot, |
| 29 | + HISTORY_MERGE_TAG, | |
| 29 | 30 | DecoratorNode, |
| 30 | 31 | type ElementNode, |
| 31 | 32 | type LexicalNode, |
| @@ -58,7 +59,10 @@ import { · 26 unmodified lines | ||
| 58 | 59 | expandCollapsedComposerCursor, |
| 59 | 60 | isCollapsedCursorAdjacentToInlineToken, |
| 60 | 61 | } from "~/composer-logic"; |
| 61 | −import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | |
| 62 | +import { | |
| 63 | + selectionTouchesMentionBoundary, | |
| 64 | + splitPromptIntoComposerSegments, | |
| 65 | +} from "~/composer-editor-mentions"; | |
| 62 | 66 | import { |
| 63 | 67 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, |
| 64 | 68 | type TerminalContextDraft, |
| @@ -73,6 +77,21 @@ import { · 8 unmodified lines | ||
| 73 | 77 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 74 | 78 | |
| 75 | 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 | 96 | type SerializedComposerMentionNode = Spread< |
| 78 | 97 | { |
| @@ -553,6 +572,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { · 474 unmodified lines | ||
| 553 | 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 | 622 | function $readSelectionOffsetFromEditorState(fallback: number): number { |
| 557 | 623 | const selection = $getSelection(); |
| 558 | 624 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { |
| @@ -878,6 +944,269 @@ function ComposerInlineTokenBackspacePlugin() { · 319 unmodified lines | ||
| 878 | 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 | 1210 | function ComposerPromptEditorInner({ |
| 882 | 1211 | value, |
| 883 | 1212 | cursor, |
| @@ -1113,6 +1442,7 @@ function ComposerPromptEditorInner({ · 229 unmodified lines | ||
| 1113 | 1442 | /> |
| 1114 | 1443 | <OnChangePlugin onChange={handleEditorChange} /> |
| 1115 | 1444 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> |
| 1445 | + <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} /> | |
| 1116 | 1446 | <ComposerInlineTokenArrowPlugin /> |
| 1117 | 1447 | <ComposerInlineTokenSelectionNormalizePlugin /> |
| 1118 | 1448 | <ComposerInlineTokenBackspacePlugin /> |
| @@ -1,6 +1,9 @@ | ||
| 1 | 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 | 7 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 5 | 8 | |
| 6 | 9 | describe("splitPromptIntoComposerSegments", () => { |
| @@ -38,4 +41,59 @@ describe("splitPromptIntoComposerSegments", () => { · 31 unmodified lines | ||
| 38 | 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 | }); |
| @@ -19,6 +19,10 @@ export type ComposerPromptSegment = · 18 unmodified lines | ||
| 19 | 19 | |
| 20 | 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 | 26 | function pushTextSegment(segments: ComposerPromptSegment[], text: string): void { |
| 23 | 27 | if (!text) return; |
| 24 | 28 | const last = segments[segments.length - 1]; |
| @@ -29,6 +33,84 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void · 4 unmodified lines | ||
| 29 | 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 | 114 | function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { |
| 33 | 115 | const segments: ComposerPromptSegment[] = []; |
| 34 | 116 | if (!text) { |
| @@ -64,6 +146,43 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen · 29 unmodified lines | ||
| 64 | 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 | 186 | export function splitPromptIntoComposerSegments( |
| 68 | 187 | prompt: string, |
| 69 | 188 | terminalContexts: ReadonlyArray<TerminalContextDraft> = [], |
| @@ -73,28 +192,20 @@ export function splitPromptIntoComposerSegments( · 3 unmodified lines | ||
| 73 | 192 | } |
| 74 | 193 | |
| 75 | 194 | const segments: ComposerPromptSegment[] = []; |
| 76 | − let textCursor = 0; | |
| 77 | 195 | let terminalContextIndex = 0; |
| 78 | − | |
| 79 | − for (let index = 0; index < prompt.length; index += 1) { | |
| 80 | − if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) { | |
| 81 | − continue; | |
| 196 | + forEachPromptSegmentSlice(prompt, (slice) => { | |
| 197 | + if (slice.type === "text") { | |
| 198 | + segments.push(...splitPromptTextIntoComposerSegments(slice.text)); | |
| 199 | + return false; | |
| 82 | 200 | } |
| 83 | 201 | |
| 84 | − if (index > textCursor) { | |
| 85 | − segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor, index))); | |
| 86 | − } | |
| 87 | 202 | segments.push({ |
| 88 | 203 | type: "terminal-context", |
| 89 | 204 | context: terminalContexts[terminalContextIndex] ?? null, |
| 90 | 205 | }); |
| 91 | 206 | terminalContextIndex += 1; |
| 92 | − textCursor = index + 1; | |
| 93 | − } | |
| 94 | − | |
| 95 | − if (textCursor < prompt.length) { | |
| 96 | − segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor))); | |
| 97 | − } | |
| 207 | + return false; | |
| 208 | + }); | |
| 98 | 209 | |
| 99 | 210 | return segments; |
| 100 | 211 | } |