authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 01:07:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 11:04:40-08:00
log325c0a3687d4dbd6ce536b059c7c2b7ad0c4964a
tree8ec47b0f95db2f88d8bce30a1e81ee8448aabe78
parent7c7fcf46c2307a0fa0348d8af9012791eb9ebb2d

Limit composer mention arrow jumps to adjacent mention boundaries

- 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 cases

3 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";
5252
53import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";
53import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";54import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
54import { cn } from "~/lib/utils";55import { cn } from "~/lib/utils";
55import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";56import { 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";
3import {3import {
4 detectComposerTrigger,4 detectComposerTrigger,
5 expandCollapsedComposerCursor,5 expandCollapsedComposerCursor,
6 isCollapsedCursorAdjacentToMention,
6 replaceTextRange,7 replaceTextRange,
7} from "./composer-logic";8} from "./composer-logic";
89
...@@ -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
82describe("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}
6060
61function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number {
62 return segment.type === "mention" ? 1 : segment.text.length;
63}
64
65function 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
79export 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
61export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {107export 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;