| author | |
| committer | |
| log | 325c0a3687d4dbd6ce536b059c7c2b7ad0c4964a |
| tree | 8ec47b0f95db2f88d8bce30a1e81ee8448aabe78 |
| parent | 7c7fcf46c2307a0fa0348d8af9012791eb9ebb2d |
- Only intercept left/right arrow keys when cursor is directly next to a mention
- Prevent default arrow behavior only for those mention-boundary moves
- Add `isCollapsedCursorAdjacentToMention` logic with coverage for left/right adjacency cases3 files changed, 104 insertions(+), 12 deletions(-)
apps/web/src/components/ComposerPromptEditor.tsx+30-12| ... | @@ -50,6 +50,7 @@ import { | ... | @@ -50,6 +50,7 @@ import { |
| 50 | type Ref, | 50 | type Ref, |
| 51 | } from "react"; | 51 | } from "react"; |
| 52 | 52 | ||
| 53 | import { isCollapsedCursorAdjacentToMention } from "~/composer-logic"; | ||
| 53 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | 54 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 54 | import { cn } from "~/lib/utils"; | 55 | import { cn } from "~/lib/utils"; |
| 55 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; | 56 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; |
| ... | @@ -469,16 +470,25 @@ function ComposerMentionArrowPlugin() { | ... | @@ -469,16 +470,25 @@ function ComposerMentionArrowPlugin() { |
| 469 | useEffect(() => { | 470 | useEffect(() => { |
| 470 | const unregisterLeft = editor.registerCommand( | 471 | const unregisterLeft = editor.registerCommand( |
| 471 | KEY_ARROW_LEFT_COMMAND, | 472 | KEY_ARROW_LEFT_COMMAND, |
| 472 | () => { | 473 | (event) => { |
| 473 | let currentOffset = -1; | 474 | let nextOffset: number | null = null; |
| 474 | editor.getEditorState().read(() => { | 475 | editor.getEditorState().read(() => { |
| 475 | const selection = $getSelection(); | 476 | const selection = $getSelection(); |
| 476 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; | 477 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 477 | currentOffset = $readSelectionOffsetFromEditorState(0); | 478 | const currentOffset = $readSelectionOffsetFromEditorState(0); |
| 479 | if (currentOffset <= 0) return; | ||
| 480 | const promptValue = $getRoot().getTextContent(); | ||
| 481 | if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) { | ||
| 482 | return; | ||
| 483 | } | ||
| 484 | nextOffset = currentOffset - 1; | ||
| 478 | }); | 485 | }); |
| 479 | if (currentOffset <= 0) return false; | 486 | if (nextOffset === null) return false; |
| 487 | const selectionOffset = nextOffset; | ||
| 488 | event?.preventDefault(); | ||
| 489 | event?.stopPropagation(); | ||
| 480 | editor.update(() => { | 490 | editor.update(() => { |
| 481 | $setSelectionAtComposerOffset(currentOffset - 1); | 491 | $setSelectionAtComposerOffset(selectionOffset); |
| 482 | }); | 492 | }); |
| 483 | return true; | 493 | return true; |
| 484 | }, | 494 | }, |
| ... | @@ -486,18 +496,26 @@ function ComposerMentionArrowPlugin() { | ... | @@ -486,18 +496,26 @@ function ComposerMentionArrowPlugin() { |
| 486 | ); | 496 | ); |
| 487 | const unregisterRight = editor.registerCommand( | 497 | const unregisterRight = editor.registerCommand( |
| 488 | KEY_ARROW_RIGHT_COMMAND, | 498 | KEY_ARROW_RIGHT_COMMAND, |
| 489 | () => { | 499 | (event) => { |
| 490 | let currentOffset = -1; | 500 | let nextOffset: number | null = null; |
| 491 | let composerLength = 0; | ||
| 492 | editor.getEditorState().read(() => { | 501 | editor.getEditorState().read(() => { |
| 493 | const selection = $getSelection(); | 502 | const selection = $getSelection(); |
| 494 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; | 503 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 495 | composerLength = $getComposerRootLength(); | 504 | const currentOffset = $readSelectionOffsetFromEditorState(0); |
| 496 | currentOffset = $readSelectionOffsetFromEditorState(0); | 505 | const composerLength = $getComposerRootLength(); |
| 506 | if (currentOffset >= composerLength) return; | ||
| 507 | const promptValue = $getRoot().getTextContent(); | ||
| 508 | if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) { | ||
| 509 | return; | ||
| 510 | } | ||
| 511 | nextOffset = currentOffset + 1; | ||
| 497 | }); | 512 | }); |
| 498 | if (currentOffset < 0 || currentOffset >= composerLength) return false; | 513 | if (nextOffset === null) return false; |
| 514 | const selectionOffset = nextOffset; | ||
| 515 | event?.preventDefault(); | ||
| 516 | event?.stopPropagation(); | ||
| 499 | editor.update(() => { | 517 | editor.update(() => { |
| 500 | $setSelectionAtComposerOffset(currentOffset + 1); | 518 | $setSelectionAtComposerOffset(selectionOffset); |
| 501 | }); | 519 | }); |
| 502 | return true; | 520 | return true; |
| 503 | }, | 521 | }, |
apps/web/src/composer-logic.test.ts+28| ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | detectComposerTrigger, | 4 | detectComposerTrigger, |
| 5 | expandCollapsedComposerCursor, | 5 | expandCollapsedComposerCursor, |
| 6 | isCollapsedCursorAdjacentToMention, | ||
| 6 | replaceTextRange, | 7 | replaceTextRange, |
| 7 | } from "./composer-logic"; | 8 | } from "./composer-logic"; |
| 8 | 9 | ||
| ... | @@ -77,3 +78,30 @@ describe("expandCollapsedComposerCursor", () => { | ... | @@ -77,3 +78,30 @@ describe("expandCollapsedComposerCursor", () => { |
| 77 | expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); | 78 | expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); |
| 78 | }); | 79 | }); |
| 79 | }); | 80 | }); |
| 81 | |||
| 82 | describe("isCollapsedCursorAdjacentToMention", () => { | ||
| 83 | it("returns false when no mention exists", () => { | ||
| 84 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); | ||
| 85 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "right")).toBe(false); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("detects left adjacency only when cursor is directly after a mention", () => { | ||
| 89 | const text = "open @AGENTS.md next"; | ||
| 90 | const mentionStart = "open ".length; | ||
| 91 | const mentionEnd = mentionStart + 1; | ||
| 92 | |||
| 93 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "left")).toBe(true); | ||
| 94 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "left")).toBe(false); | ||
| 95 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd + 1, "left")).toBe(false); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("detects right adjacency only when cursor is directly before a mention", () => { | ||
| 99 | const text = "open @AGENTS.md next"; | ||
| 100 | const mentionStart = "open ".length; | ||
| 101 | const mentionEnd = mentionStart + 1; | ||
| 102 | |||
| 103 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "right")).toBe(true); | ||
| 104 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "right")).toBe(false); | ||
| 105 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); | ||
| 106 | }); | ||
| 107 | }); |
apps/web/src/composer-logic.ts+46| ... | @@ -58,6 +58,52 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) | ... | @@ -58,6 +58,52 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 58 | return expandedCursor; | 58 | return expandedCursor; |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { | ||
| 62 | return segment.type === "mention" ? 1 : segment.text.length; | ||
| 63 | } | ||
| 64 | |||
| 65 | function clampCollapsedComposerCursor( | ||
| 66 | segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, | ||
| 67 | cursorInput: number, | ||
| 68 | ): number { | ||
| 69 | const collapsedLength = segments.reduce( | ||
| 70 | (total, segment) => total + collapsedSegmentLength(segment), | ||
| 71 | 0, | ||
| 72 | ); | ||
| 73 | if (!Number.isFinite(cursorInput)) { | ||
| 74 | return collapsedLength; | ||
| 75 | } | ||
| 76 | return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); | ||
| 77 | } | ||
| 78 | |||
| 79 | export function isCollapsedCursorAdjacentToMention( | ||
| 80 | text: string, | ||
| 81 | cursorInput: number, | ||
| 82 | direction: "left" | "right", | ||
| 83 | ): boolean { | ||
| 84 | const segments = splitPromptIntoComposerSegments(text); | ||
| 85 | if (!segments.some((segment) => segment.type === "mention")) { | ||
| 86 | return false; | ||
| 87 | } | ||
| 88 | |||
| 89 | const cursor = clampCollapsedComposerCursor(segments, cursorInput); | ||
| 90 | let collapsedOffset = 0; | ||
| 91 | |||
| 92 | for (const segment of segments) { | ||
| 93 | if (segment.type === "mention") { | ||
| 94 | if (direction === "left" && cursor === collapsedOffset + 1) { | ||
| 95 | return true; | ||
| 96 | } | ||
| 97 | if (direction === "right" && cursor === collapsedOffset) { | ||
| 98 | return true; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | collapsedOffset += collapsedSegmentLength(segment); | ||
| 102 | } | ||
| 103 | |||
| 104 | return false; | ||
| 105 | } | ||
| 106 | |||
| 61 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { | 107 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { |
| 62 | const cursor = clampCursor(text, cursorInput); | 108 | const cursor = clampCursor(text, cursorInput); |
| 63 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | 109 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; |