Skip to content

Commit affff43

Browse files
fix(web): support spaces in composer file mentions
1 parent b3e8c03 commit affff43

6 files changed

Lines changed: 98 additions & 16 deletions

File tree

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
} from "~/composer-logic";
5757
import {
5858
selectionTouchesMentionBoundary,
59+
serializeComposerMentionPath,
5960
splitPromptIntoComposerSegments,
6061
} from "~/composer-editor-mentions";
6162
import {
@@ -198,7 +199,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
198199
}
199200

200201
override getTextContent(): string {
201-
return `@${this.__path}`;
202+
return `@${serializeComposerMentionPath(this.__path)}`;
202203
}
203204

204205
override isInline(): true {

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
useRef,
2929
useState,
3030
} from "react";
31+
import { serializeComposerMentionPath } from "~/composer-editor-mentions";
3132
import {
3233
clampCollapsedComposerCursor,
3334
type ComposerTrigger,
@@ -1471,7 +1472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14711472
const { snapshot, trigger } = resolveActiveComposerTrigger();
14721473
if (!trigger) return;
14731474
if (item.type === "path") {
1474-
const replacement = `@${item.path} `;
1475+
const replacement = `@${serializeComposerMentionPath(item.path)} `;
14751476
const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
14761477
snapshot.value,
14771478
trigger.rangeEnd,

apps/web/src/composer-editor-mentions.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22

33
import {
44
selectionTouchesMentionBoundary,
5+
serializeComposerMentionPath,
56
splitPromptIntoComposerSegments,
67
} from "./composer-editor-mentions";
78
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
@@ -29,6 +30,22 @@ describe("splitPromptIntoComposerSegments", () => {
2930
]);
3031
});
3132

33+
it("splits quoted mention tokens containing whitespace", () => {
34+
expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
35+
{ type: "text", text: "Inspect " },
36+
{ type: "mention", path: "My File.md" },
37+
{ type: "text", text: " please" },
38+
]);
39+
});
40+
41+
it("unescapes quoted mention token content", () => {
42+
expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
43+
{ type: "text", text: "Inspect " },
44+
{ type: "mention", path: 'docs/My "File".md' },
45+
{ type: "text", text: " please" },
46+
]);
47+
});
48+
3249
it("splits skill tokens followed by whitespace into skill segments", () => {
3350
expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
3451
{ type: "text", text: "Use " },
@@ -84,6 +101,20 @@ describe("splitPromptIntoComposerSegments", () => {
84101
});
85102
});
86103

104+
describe("serializeComposerMentionPath", () => {
105+
it("keeps simple paths unquoted", () => {
106+
expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts");
107+
});
108+
109+
it("quotes paths containing whitespace", () => {
110+
expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"');
111+
});
112+
113+
it("escapes quoted path content", () => {
114+
expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');
115+
});
116+
});
117+
87118
describe("selectionTouchesMentionBoundary", () => {
88119
it("returns true when selection includes the whitespace after a mention", () => {
89120
expect(
@@ -125,4 +156,14 @@ describe("selectionTouchesMentionBoundary", () => {
125156
),
126157
).toBe(true);
127158
});
159+
160+
it("returns true when selection includes whitespace after a quoted mention", () => {
161+
expect(
162+
selectionTouchesMentionBoundary(
163+
'hi @"My File.md" there',
164+
'hi @"My File.md"'.length,
165+
'hi @"My File.md" there'.length,
166+
),
167+
).toBe(true);
168+
});
128169
});

apps/web/src/composer-editor-mentions.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,16 @@ export type ComposerPromptSegment =
2121
context: TerminalContextDraft | null;
2222
};
2323

24-
const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
2524
const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
25+
const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
26+
const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/;
27+
28+
export function serializeComposerMentionPath(path: string): string {
29+
if (SIMPLE_MENTION_PATH_REGEX.test(path)) {
30+
return path;
31+
}
32+
return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
33+
}
2634

2735
function rangeIncludesIndex(start: number, end: number, index: number): boolean {
2836
return start <= index && index < end;
@@ -52,13 +60,18 @@ type InlineTokenMatch =
5260
end: number;
5361
};
5462

55-
function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
56-
const matches: InlineTokenMatch[] = [];
63+
type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
64+
65+
function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
66+
const matches: MentionTokenMatch[] = [];
5767

5868
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
5969
const fullMatch = match[0];
6070
const prefix = match[1] ?? "";
61-
const path = match[2] ?? "";
71+
const quotedPath = match[2];
72+
const unquotedPath = match[3];
73+
const path =
74+
quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? "");
6275
const matchIndex = match.index ?? 0;
6376
const start = matchIndex + prefix.length;
6477
const end = start + fullMatch.length - prefix.length;
@@ -67,6 +80,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
6780
}
6881
}
6982

83+
return matches;
84+
}
85+
86+
function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
87+
const matches: InlineTokenMatch[] = collectMentionTokenMatches(text);
88+
7089
for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
7190
const fullMatch = match[0];
7291
const prefix = match[1] ?? "";
@@ -148,10 +167,10 @@ function forEachPromptTextSlice(
148167

149168
function forEachMentionMatch(
150169
prompt: string,
151-
visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,
170+
visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void,
152171
): boolean {
153172
return forEachPromptTextSlice(prompt, (text, promptOffset) => {
154-
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
173+
for (const match of collectMentionTokenMatches(text)) {
155174
if (visitor(match, promptOffset) === true) {
156175
return true;
157176
}
@@ -203,11 +222,8 @@ export function selectionTouchesMentionBoundary(
203222
}
204223

205224
return forEachMentionMatch(prompt, (match, promptOffset) => {
206-
const fullMatch = match[0];
207-
const prefix = match[1] ?? "";
208-
const matchIndex = match.index ?? 0;
209-
const mentionStart = promptOffset + matchIndex + prefix.length;
210-
const mentionEnd = mentionStart + fullMatch.length - prefix.length;
225+
const mentionStart = promptOffset + match.start;
226+
const mentionEnd = promptOffset + match.end;
211227
const beforeMentionIndex = mentionStart - 1;
212228
const afterMentionIndex = mentionEnd;
213229

apps/web/src/composer-logic.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {
157157
);
158158
});
159159

160+
it("maps collapsed quoted mention cursor to expanded text cursor", () => {
161+
const text = 'what is in @"My File.md" please';
162+
const collapsedCursorAfterMention = "what is in ".length + 2;
163+
const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
164+
165+
expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
166+
expandedCursorAfterMention,
167+
);
168+
});
169+
160170
it("allows path trigger detection to close after selecting a mention", () => {
161171
const text = "what's in my @AGENTS.md ";
162172
const collapsedCursorAfterMention = "what's in my ".length + 2;
@@ -191,6 +201,16 @@ describe("collapseExpandedComposerCursor", () => {
191201
);
192202
});
193203

204+
it("maps expanded quoted mention cursor back to collapsed cursor", () => {
205+
const text = 'what is in @"My File.md" please';
206+
const collapsedCursorAfterMention = "what is in ".length + 2;
207+
const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
208+
209+
expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
210+
collapsedCursorAfterMention,
211+
);
212+
});
213+
194214
it("keeps replacement cursors aligned when another mention already exists earlier", () => {
195215
const text = "open @AGENTS.md then @src/index.ts ";
196216
const expandedCursor = text.length;

apps/web/src/composer-logic.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
1+
import {
2+
serializeComposerMentionPath,
3+
splitPromptIntoComposerSegments,
4+
} from "./composer-editor-mentions";
25
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
36

47
export type ComposerTriggerKind = "path" | "slash-command" | "skill";
@@ -54,7 +57,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5457

5558
for (const segment of segments) {
5659
if (segment.type === "mention") {
57-
const expandedLength = segment.path.length + 1;
60+
const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
5861
if (remaining <= 1) {
5962
return expandedCursor + (remaining === 0 ? 0 : expandedLength);
6063
}
@@ -142,7 +145,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
142145

143146
for (const segment of segments) {
144147
if (segment.type === "mention") {
145-
const expandedLength = segment.path.length + 1;
148+
const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
146149
if (remaining === 0) {
147150
return collapsedCursor;
148151
}

0 commit comments

Comments
 (0)