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 {
5050 type Ref,
5151} from "react";
5252
53import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";
5354import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
5455import { cn } from "~/lib/utils";
5556import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";
......@@ -469,16 +470,25 @@ function ComposerMentionArrowPlugin() {
469470 useEffect(() => {
470471 const unregisterLeft = editor.registerCommand(
471472 KEY_ARROW_LEFT_COMMAND,
472 () => {
473 let currentOffset = -1;
473 (event) => {
474 let nextOffset: number | null = null;
474475 editor.getEditorState().read(() => {
475476 const selection = $getSelection();
476477 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;
478485 });
479 if (currentOffset <= 0) return false;
486 if (nextOffset === null) return false;
487 const selectionOffset = nextOffset;
488 event?.preventDefault();
489 event?.stopPropagation();
480490 editor.update(() => {
481 $setSelectionAtComposerOffset(currentOffset - 1);
491 $setSelectionAtComposerOffset(selectionOffset);
482492 });
483493 return true;
484494 },
......@@ -486,18 +496,26 @@ function ComposerMentionArrowPlugin() {
486496 );
487497 const unregisterRight = editor.registerCommand(
488498 KEY_ARROW_RIGHT_COMMAND,
489 () => {
490 let currentOffset = -1;
491 let composerLength = 0;
499 (event) => {
500 let nextOffset: number | null = null;
492501 editor.getEditorState().read(() => {
493502 const selection = $getSelection();
494503 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
495 composerLength = $getComposerRootLength();
496 currentOffset = $readSelectionOffsetFromEditorState(0);
504 const 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;
497512 });
498 if (currentOffset < 0 || currentOffset >= composerLength) return false;
513 if (nextOffset === null) return false;
514 const selectionOffset = nextOffset;
515 event?.preventDefault();
516 event?.stopPropagation();
499517 editor.update(() => {
500 $setSelectionAtComposerOffset(currentOffset + 1);
518 $setSelectionAtComposerOffset(selectionOffset);
501519 });
502520 return true;
503521 },
apps/web/src/composer-logic.test.ts+28
......@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33import {
44 detectComposerTrigger,
55 expandCollapsedComposerCursor,
6 isCollapsedCursorAdjacentToMention,
67 replaceTextRange,
78} from "./composer-logic";
89
......@@ -77,3 +78,30 @@ describe("expandCollapsedComposerCursor", () => {
7778 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
7879 });
7980});
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)
5858 return expandedCursor;
5959}
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
61107export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
62108 const cursor = clampCursor(text, cursorInput);
63109 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;