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
Merges `pingdotgg/t3code` into the fork: 60 upstream commits, `1bbca0e78` → `5623089ae`. 388 files landed against 386 upstream changed in the range; the gap reconciles (three fork doc files landed that are not in the range; `apps/server/src/cli/pair.ts` is in the range but not landed — upstream modified a file the fork deliberately deletes, and the modify/delete conflict was resolved by keeping the deletion). Fork delta against upstream is 777 files. 14 conflicts, each resolved with the verdict `preflight.mjs` printed for it. The dominant theme was `d81278aa6 revert(web): remove the compact sidebar (pingdotgg#11685)`, which deleted the base-level anchors five fork gates sat beside. The fork's side of each conflict looked richer, but most of that was upstream's own code inherited from the merge base. Each was resolved by checking ownership per line against `git show <merge-base>:<path>`, taking upstream wholesale, and re-applying only genuinely fork-authored deltas. Details in `docs/fork/upstream-merge-log.md`. ## Usable as-is Features the fork can expose without Moatless backend or deployment work. - **Compact sidebar reverted** (pingdotgg#11685) — upstream removed the compact variant. The five fork gates that sat beside its anchors were re-applied to the restored layout. - **Custom snooze dates and durations** (pingdotgg#11800) — verified client-side only; no new RPC. - **Composer and PR-number shortcuts** (pingdotgg#11615), and **copy-PR-link keybinding discoverability** (pingdotgg#11826). - **Project monogram icons** (pingdotgg#11572, pingdotgg#11806). - **Video attachment thumbnails** (pingdotgg#11734), and **large image previews no longer stalling the composer** (pingdotgg#11324). - **Per-thread panel width** (pingdotgg#11310). - **Consistent PR section toggles** (pingdotgg#11763). - **Android agent activity card** (pingdotgg#11645). - **OTLP protocol and header environment variables** (pingdotgg#11224, pingdotgg#11218). ## Unsupported in Moatless / needs implementation Client, contract, RPC, auth, or deployment assumptions Moatless does not serve. Each is an entry in `docs/fork/gaps.md` with the check that retires it. - **Background repository cloning** (pingdotgg#11762 web, pingdotgg#11774 mobile) — `projectClone.start`, `.cancel`, `.retry` and the `subscribeProjectClones` push stream turn "add a project from a remote" into a tracked job: the row appears immediately and progress streams into a toast (`ProjectCloneToastCoordinator.tsx`, `apps/web/src/state/projectClones.ts`) rather than blocking the palette. Nothing new is lost — the whole flow hangs off `action:add-project`, which `FEATURES.projectManagement` already drops, and the stream is additionally gated on `capabilities.projectCloneTracking`, which a Moatless handshake omits. The four union entries are the only stand-in and close with the rest of that bullet. - **Worktree setup behind a progress stream** (pingdotgg#11372, grown by pingdotgg#11832) — `subscribeWorktreeSetup`, `worktreeSetup.cancel`, surfaced by `WorktreeSetupCard`. A Moatless thread gets a sandbox, not a worktree; `FEATURES.worktreeSelection` keeps the composer out of `worktree` send mode, so `baseBranchForWorktree` stays null in `ChatView.tsx`. - **Clerk device-authorization-grant headless connect login** (pingdotgg#11794). - **T3 Connect Clerk profile page** (pingdotgg#11765) and **Clerk stack bump** (pingdotgg#11764). - **Disabling the local environment** (pingdotgg#9194). - **Self-contained CLI installs and release archives** (pingdotgg#11607, pingdotgg#11659, pingdotgg#11451, pingdotgg#11510, pingdotgg#11318, pingdotgg#11317, pingdotgg#11316). - **Device-host testing across environments** (pingdotgg#11699, pingdotgg#11698) — behind `FEATURES.deviceHub`. Contract effect: **6 `UnsupportedMethodError` union entries added, 0 dropped** (`projectClone.start`/`.cancel`/`.retry`, `subscribeProjectClones`, `subscribeWorktreeSetup`, `worktreeSetup.cancel`). 99 of 157 methods now declare the error. ## Backend behavior to consider reproducing in Moatless Upstream server behavior worth having even though the fork cannot use the implementation directly. All seven are now entries under _Runtime fixes upstream made to its own server_ in `docs/fork/gaps.md`. - **Thread titles generated from user intent** (pingdotgg#10720) — ships with an evaluation harness at `apps/server/scripts/evaluate-thread-titles.ts`. - **Title-link resolution via `SourceControlProvider`** (pingdotgg#11844, tidied by pingdotgg#11847). - **`async: false` setup scripts** (pingdotgg#11832) — a setup script can be marked to finish before the agent's first turn. The client side was carried: it rides as `waitForSetup` in the editor's form and maps to `async: false` (`apps/web/src/projectScripts.ts`), so the flag is written through `project.meta.update` today and does nothing until the backend honours it. - **Setup-script color probe suppressed** (pingdotgg#11843) — `NO_COLOR=1` / `FORCE_COLOR=0` in `ProjectSetupScriptRunner.ts`, because setup may run before a terminal client attaches to answer the probe. - **Provider refresh on `subscribeConfig`** (pingdotgg#11811, `apps/server/src/ws.ts`). - **Terminal output send window** (pingdotgg#11407, `apps/server/src/terminal/OutputProtocol.ts`) — 8 chunks / 64 KiB, rather than acking per chunk. - **Tight-list streaming one item at a time** (pingdotgg#11833, `ProviderRuntimeIngestion.ts`). ## Owned-surface sweep 74 new upstream files, 14 of them inside a fork-owned concern. All 14 accepted unmodified — **no new `FEATURES` flag was needed**. The reachability was traced rather than assumed: `projectCloneTracking`, `action:add-project`, and `sendEnvMode === "worktree"` each already drop their surface. ## Verification `verify.mjs`'s 7 non-test checks pass — duplicated adds, tripwires, resolutions against both parents, the unsupported-method derivation, format, lint, types. All 13 workspace packages' test suites run, 12 fully green. Two caveats, both environmental: - **`@t3tools/desktop` — `scripts/browser-secret-native.test.mjs` fails on missing `libsecret-1`.** 106 of 108 test files pass, 1363 tests pass, and the failing file's own 10 tests are all skipped: the failure is suite-level setup calling `pkg-config --cflags --libs libsecret-1`, which the sandbox cannot satisfy. Pre-existing, reproduces on `HEAD^1`, and already a standing entry in `docs/fork/gaps.md`. - **The full `verify.mjs` pass could not be run as one command.** Two consecutive attempts were killed by sandbox evictions during the parallel test phase, losing their logs. Verification was completed instead as `--fast` plus the 13 packages run sequentially via `--only test --package`, which the script documents as the supported route and which bounds peak memory. Every check ran; none were skipped. ## Post-merge action `desktop-macos-preview-publish.yml` and `release-desktop.yml` are new upstream workflows that need disabling in this fork. `moat gh workflow disable` returns HTTP 404 for both — GitHub only registers a workflow once it lands on the default branch — so this cannot be done until after this PR merges. Recorded in the tracker entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Moatless task: https://moatless.soaplabstest.com/tasks/b4c32213-a797-4017-9713-eacb4faa3ee7



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.