Skip to content

Commit 7e8a0db

Browse files
fix(composer): support spaces in file mentions
1 parent 348a914 commit 7e8a0db

9 files changed

Lines changed: 100 additions & 16 deletions

File tree

apps/mobile/src/features/threads/ThreadComposer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import {
1111
detectComposerTrigger,
1212
replaceTextRange,
13+
serializeComposerMentionPath,
1314
type ComposerTrigger,
1415
} from "@t3tools/shared/composerTrigger";
1516
import { TextInputWrapper } from "expo-paste-input";
@@ -408,7 +409,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
408409

409410
let replacement = "";
410411
if (item.type === "path") {
411-
replacement = `@${item.path} `;
412+
replacement = `@${serializeComposerMentionPath(item.path)} `;
412413
} else if (item.type === "skill") {
413414
replacement = `$${item.skill.name} `;
414415
} else if (item.type === "slash-command") {

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
66
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
77
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
88
import { type ServerProviderSkill } from "@t3tools/contracts";
9+
import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
910
import {
1011
$applyNodeReplacement,
1112
$createRangeSelection,
@@ -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
@@ -17,6 +17,7 @@ import {
1717
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1818
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1919
} from "@t3tools/contracts";
20+
import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
2021
import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2122
import {
2223
memo,
@@ -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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ describe("splitPromptIntoComposerSegments", () => {
2929
]);
3030
});
3131

32+
it("splits quoted mention tokens containing whitespace", () => {
33+
expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
34+
{ type: "text", text: "Inspect " },
35+
{ type: "mention", path: "My File.md" },
36+
{ type: "text", text: " please" },
37+
]);
38+
});
39+
40+
it("unescapes quoted mention token content", () => {
41+
expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
42+
{ type: "text", text: "Inspect " },
43+
{ type: "mention", path: 'docs/My "File".md' },
44+
{ type: "text", text: " please" },
45+
]);
46+
});
47+
3248
it("splits skill tokens followed by whitespace into skill segments", () => {
3349
expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
3450
{ type: "text", text: "Use " },
@@ -125,4 +141,14 @@ describe("selectionTouchesMentionBoundary", () => {
125141
),
126142
).toBe(true);
127143
});
144+
145+
it("returns true when selection includes whitespace after a quoted mention", () => {
146+
expect(
147+
selectionTouchesMentionBoundary(
148+
'hi @"My File.md" there',
149+
'hi @"My File.md"'.length,
150+
'hi @"My File.md" there'.length,
151+
),
152+
).toBe(true);
153+
});
128154
});

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ 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;
2626

2727
function rangeIncludesIndex(start: number, end: number, index: number): boolean {
2828
return start <= index && index < end;
@@ -52,13 +52,18 @@ type InlineTokenMatch =
5252
end: number;
5353
};
5454

55-
function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
56-
const matches: InlineTokenMatch[] = [];
55+
type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
56+
57+
function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
58+
const matches: MentionTokenMatch[] = [];
5759

5860
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
5961
const fullMatch = match[0];
6062
const prefix = match[1] ?? "";
61-
const path = match[2] ?? "";
63+
const quotedPath = match[2];
64+
const unquotedPath = match[3];
65+
const path =
66+
quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? "");
6267
const matchIndex = match.index ?? 0;
6368
const start = matchIndex + prefix.length;
6469
const end = start + fullMatch.length - prefix.length;
@@ -67,6 +72,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
6772
}
6873
}
6974

75+
return matches;
76+
}
77+
78+
function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
79+
const matches: InlineTokenMatch[] = collectMentionTokenMatches(text);
80+
7081
for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
7182
const fullMatch = match[0];
7283
const prefix = match[1] ?? "";
@@ -148,10 +159,10 @@ function forEachPromptTextSlice(
148159

149160
function forEachMentionMatch(
150161
prompt: string,
151-
visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,
162+
visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void,
152163
): boolean {
153164
return forEachPromptTextSlice(prompt, (text, promptOffset) => {
154-
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
165+
for (const match of collectMentionTokenMatches(text)) {
155166
if (visitor(match, promptOffset) === true) {
156167
return true;
157168
}
@@ -203,11 +214,8 @@ export function selectionTouchesMentionBoundary(
203214
}
204215

205216
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;
217+
const mentionStart = promptOffset + match.start;
218+
const mentionEnd = promptOffset + match.end;
211219
const beforeMentionIndex = mentionStart - 1;
212220
const afterMentionIndex = mentionEnd;
213221

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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
12
import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
23
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
34

@@ -54,7 +55,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
5455

5556
for (const segment of segments) {
5657
if (segment.type === "mention") {
57-
const expandedLength = segment.path.length + 1;
58+
const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
5859
if (remaining <= 1) {
5960
return expandedCursor + (remaining === 0 ? 0 : expandedLength);
6061
}
@@ -142,7 +143,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
142143

143144
for (const segment of segments) {
144145
if (segment.type === "mention") {
145-
const expandedLength = segment.path.length + 1;
146+
const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
146147
if (remaining === 0) {
147148
return collapsedCursor;
148149
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { serializeComposerMentionPath } from "./composerTrigger.ts";
4+
5+
describe("serializeComposerMentionPath", () => {
6+
it("keeps simple mention paths unquoted", () => {
7+
expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts");
8+
});
9+
10+
it("quotes mention paths containing whitespace", () => {
11+
expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"');
12+
});
13+
14+
it("escapes quoted mention path content", () => {
15+
expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');
16+
});
17+
});

packages/shared/src/composerTrigger.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ export interface ComposerTrigger {
88
rangeEnd: number;
99
}
1010

11+
const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/;
12+
13+
export function serializeComposerMentionPath(path: string): string {
14+
if (SIMPLE_MENTION_PATH_REGEX.test(path)) {
15+
return path;
16+
}
17+
return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
18+
}
19+
1120
function clampCursor(text: string, cursor: number): number {
1221
if (!Number.isFinite(cursor)) return text.length;
1322
return Math.max(0, Math.min(text.length, Math.floor(cursor)));

0 commit comments

Comments
 (0)