Limit composer arrow-key mention jumps to adjacent mention boundaries#169
Conversation
- 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
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
| (event) => { | ||
| let nextOffset: number | null = null; | ||
| editor.getEditorState().read(() => { |
There was a problem hiding this comment.
🟡 Medium components/ComposerPromptEditor.tsx:473
When the user presses Shift + ArrowLeft or Shift + ArrowRight next to a mention, the ComposerMentionArrowPlugin consumes the event and moves the cursor instead of allowing Lexical to extend the selection. This makes mention nodes impossible to select via keyboard. Consider adding a check for event?.shiftKey and returning false early when the shift modifier is held, so standard selection behavior is preserved.
(event) => {
+ if (event?.shiftKey) return false;
let nextOffset: number | null = null;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ComposerPromptEditor.tsx around lines 473-475:
When the user presses `Shift + ArrowLeft` or `Shift + ArrowRight` next to a mention, the `ComposerMentionArrowPlugin` consumes the event and moves the cursor instead of allowing Lexical to extend the selection. This makes mention nodes impossible to select via keyboard. Consider adding a check for `event?.shiftKey` and returning `false` early when the shift modifier is held, so standard selection behavior is preserved.
Evidence trail:
apps/web/src/components/ComposerPromptEditor.tsx lines 472-519 at REVIEWED_COMMIT. The KEY_ARROW_LEFT_COMMAND handler (lines 472-495) and KEY_ARROW_RIGHT_COMMAND handler (lines 496-519) both check `!selection.isCollapsed()` but neither checks `event?.shiftKey`. When adjacent to a mention with collapsed selection, both handlers call `event?.preventDefault()`, `event?.stopPropagation()`, and return `true`, consuming the event regardless of whether Shift is held.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Regex-based mention detection mismatches Lexical node tree
- Replaced the regex-based isCollapsedCursorAdjacentToMention (which required trailing whitespace and operated in raw-text coordinates) with a Lexical node tree-based $isCursorAdjacentToMentionInDirection that inspects adjacent ComposerMentionNode siblings directly, eliminating both the regex detection gap and the coordinate system mismatch.
Or push these changes by commenting:
@cursor push 083714b064
Preview (083714b064)
diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx
--- a/apps/web/src/components/ComposerPromptEditor.tsx
+++ b/apps/web/src/components/ComposerPromptEditor.tsx
@@ -50,7 +50,6 @@
type Ref,
} from "react";
-import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";
import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
import { cn } from "~/lib/utils";
import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";
@@ -477,10 +476,7 @@
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
const currentOffset = $readSelectionOffsetFromEditorState(0);
if (currentOffset <= 0) return;
- const promptValue = $getRoot().getTextContent();
- if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) {
- return;
- }
+ if (!$isCursorAdjacentToMentionInDirection("left")) return;
nextOffset = currentOffset - 1;
});
if (nextOffset === null) return false;
@@ -504,10 +500,7 @@
const currentOffset = $readSelectionOffsetFromEditorState(0);
const composerLength = $getComposerRootLength();
if (currentOffset >= composerLength) return;
- const promptValue = $getRoot().getTextContent();
- if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) {
- return;
- }
+ if (!$isCursorAdjacentToMentionInDirection("right")) return;
nextOffset = currentOffset + 1;
});
if (nextOffset === null) return false;
@@ -559,25 +552,40 @@
}
function $isCursorAdjacentToMention(): boolean {
+ return $isCursorAdjacentToMentionInDirection("left");
+}
+
+function $isCursorAdjacentToMentionInDirection(direction: "left" | "right"): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
const anchorNode = selection.anchor.getNode();
+ const anchorOffset = selection.anchor.offset;
if (anchorNode instanceof ComposerMentionNode) return true;
- if ($isElementNode(anchorNode)) {
- const childIndex = selection.anchor.offset - 1;
- if (childIndex >= 0) {
- const child = anchorNode.getChildAtIndex(childIndex);
+ if (direction === "left") {
+ if ($isElementNode(anchorNode)) {
+ const childIndex = anchorOffset - 1;
+ if (childIndex >= 0) {
+ const child = anchorNode.getChildAtIndex(childIndex);
+ if (child instanceof ComposerMentionNode) return true;
+ }
+ }
+ if ($isTextNode(anchorNode) && anchorOffset === 0) {
+ const prev = anchorNode.getPreviousSibling();
+ if (prev instanceof ComposerMentionNode) return true;
+ }
+ } else {
+ if ($isElementNode(anchorNode)) {
+ const child = anchorNode.getChildAtIndex(anchorOffset);
if (child instanceof ComposerMentionNode) return true;
}
+ if ($isTextNode(anchorNode) && anchorOffset === anchorNode.getTextContentSize()) {
+ const next = anchorNode.getNextSibling();
+ if (next instanceof ComposerMentionNode) return true;
+ }
}
- if ($isTextNode(anchorNode) && selection.anchor.offset === 0) {
- const prev = anchorNode.getPreviousSibling();
- if (prev instanceof ComposerMentionNode) return true;
- }
-
return false;
}| } | ||
|
|
||
| return false; | ||
| } |
There was a problem hiding this comment.
Regex-based mention detection mismatches Lexical node tree
Medium Severity
isCollapsedCursorAdjacentToMention re-detects mentions from raw text using splitPromptIntoComposerSegments, whose regex requires trailing whitespace ((?=\s)). But the Lexical editor identifies mentions by node type (ComposerMentionNode). When a mention lacks trailing whitespace (e.g., at end of text, or user deleted the space), the regex won't find it, so the collapsed cursor offset from Lexical (where mention = 1 unit) gets interpreted in a mismatched coordinate system (where the full @path text counts at its actual length). The arrow key override silently fails to activate. The old code avoided this by always jumping in Lexical's collapsed space without relying on regex re-detection.
Additional Locations (2)
- replace Lexical-node cursor adjacency check with text/cursor helper - prevent `@query` typing from being treated as mention-pill adjacent - add regression test for non-pill `@pac` input



Summary
ComposerPromptEditorso it only overrides navigation when the collapsed cursor is directly adjacent to a mention boundary.isCollapsedCursorAdjacentToMentionto composer logic, including cursor clamping across collapsed mention/text segments.Testing
apps/web/src/composer-logic.test.ts: added tests forisCollapsedCursorAdjacentToMention(no mention, left adjacency, right adjacency).bun lintbun typecheckNote
Medium Risk
Changes keyboard navigation and cursor-adjacency detection around mention pills, which is UX-critical and can regress selection behavior in edge cases. Scope is contained to composer editor logic with added unit coverage.
Overview
Arrow-key handling in the composer editor is now gated by mention-boundary adjacency.
ComposerMentionArrowPluginonly prevents default left/right navigation and performs the custom offset jump when the collapsed cursor is directly adjacent to a mention (otherwise Lexical handles the key normally).Adds
isCollapsedCursorAdjacentToMentionincomposer-logic.ts(with cursor clamping over collapsed mention/text segments) and updatesComposerPromptEditor’sonChangecallback to computecursorAdjacentToMentionvia this shared logic. Includes new unit tests for adjacency behavior incomposer-logic.test.ts.Written by Cursor Bugbot for commit 05dbf05. This will update automatically on new commits. Configure here.
Note
Limit left/right arrow handling in the Composer prompt editor to move the cursor only when adjacent to a mention to constrain mention boundary jumps
Update
ComposerMentionArrowPluginto intercept left/right keys only whenisCollapsedCursorAdjacentToMentiondetects adjacency, adjust selection by ±1, and prevent default; addisCollapsedCursorAdjacentToMentionwith collapsed-segment logic and tests; update the onChange pipeline to pass adjacency based on text-derived checks. See ComposerPromptEditor.tsx, composer-logic.ts, and composer-logic.test.ts.📍Where to Start
Start with the left/right key handlers in
ComposerMentionArrowPluginin ComposerPromptEditor.tsx, then reviewisCollapsedCursorAdjacentToMentionin composer-logic.ts and its tests in composer-logic.test.ts.Macroscope summarized 05dbf05.