authorgravatar for maxtheyoung@gmail.comMaxwell Young <maxtheyoung@gmail.com> 2026-07-23 01:57:39+12:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 15:57:39+02:00
logc7b21ff172f8e64318ec681e8839ee60ca660fa4
tree574a417219253d1d7df9d2dd84147096bea1e870
parent4e09cddb40eb1bb1e111a0374b46e73b38ffbb29
signature Signed by PGP key B5690EEEBB952194

[codex] keep scoped package references as text (#4167)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

5 files changed, 169 insertions(+), 5 deletions(-)

apps/web/src/components/ComposerPromptEditor.test.ts+79
...@@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => {...@@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => {
70 "<mention:.changeset/improve-deploy-error-logging.md> ",70 "<mention:.changeset/improve-deploy-error-logging.md> ",
71 );71 );
72 });72 });
73
74 it.each([
75 "yarn expo install @expo/ui",
76 "npm install @jane/foo.js",
77 "import '@scope/pkg/sub/path'",
78 ])("leaves scoped package command %s to the plain-text paste fallback", (command) => {
79 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
80 const editor = createEditor();
81 const plainTextFallback = vi.fn((event: ClipboardEvent) => {
82 const selection = $getSelection();
83 if (!$isRangeSelection(selection)) return false;
84 selection.insertText(event.clipboardData?.getData("text/plain") ?? "");
85 return true;
86 });
87
88 editor.update(
89 () => {
90 const paragraph = $createParagraphNode();
91 $getRoot().append(paragraph);
92 paragraph.selectEnd();
93 },
94 { discrete: true },
95 );
96 registerComposerInlineTokenPaste(editor, {
97 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
98 getExpandedAbsoluteOffsetForPoint: () => 0,
99 });
100 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
101
102 const event = new TestClipboardEvent(command);
103 let handled = false;
104 editor.update(
105 () => {
106 handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
107 },
108 { discrete: true },
109 );
110
111 expect(handled).toBe(true);
112 expect(plainTextFallback).toHaveBeenCalledOnce();
113 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command);
114 });
115
116 it("pastes a canonical scoped folder link as a mention", () => {
117 vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
118 const editor = createEditor();
119 const mention = "[sub](@scope/pkg/sub)";
120 const plainTextFallback = vi.fn(() => true);
121
122 editor.update(
123 () => {
124 const paragraph = $createParagraphNode();
125 $getRoot().append(paragraph);
126 paragraph.selectEnd();
127 },
128 { discrete: true },
129 );
130 registerComposerInlineTokenPaste(editor, {
131 createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
132 getExpandedAbsoluteOffsetForPoint: () => 0,
133 });
134 editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);
135
136 const event = new TestClipboardEvent(mention);
137 let handled = false;
138 editor.update(
139 () => {
140 handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
141 },
142 { discrete: true },
143 );
144
145 expect(handled).toBe(true);
146 expect(plainTextFallback).not.toHaveBeenCalled();
147 expect(event.defaultPrevented).toBe(true);
148 expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
149 "<mention:@scope/pkg/sub> ",
150 );
151 });
73});152});
apps/web/src/composer-editor-mentions.test.ts+27-2
...@@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => {
22 });22 });
2323
24 it("keeps newlines around mention tokens", () => {24 it("keeps newlines around mention tokens", () => {
25 expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([25 expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([
26 { type: "text", text: "one\n" },26 { type: "text", text: "one\n" },
27 { type: "mention", path: "src/index.ts", source: "@src/index.ts" },27 { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
28 { type: "text", text: " \ntwo" },28 { type: "text", text: " \ntwo" },
29 ]);29 ]);
30 });30 });
...@@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => {
71 ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]);71 ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]);
72 });72 });
7373
74 it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
75 "does not turn scoped package reference %s into file mention segments",
76 (reference) => {
77 const prompt = `Install ${reference} next`;
78 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
79 },
80 );
81
82 it("keeps IME-composed text containing a scoped package reference as text", () => {
83 const prompt = "入力 @expo/ui を追加";
84 expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
85 });
86
87 it("turns canonical scoped folder links into file mention segments", () => {
88 expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([
89 { type: "text", text: "Inspect " },
90 {
91 type: "mention",
92 path: "@scope/pkg/sub",
93 source: "[sub](@scope/pkg/sub)",
94 },
95 { type: "text", text: " next" },
96 ]);
97 });
98
74 it("decodes reserved path characters from generated links", () => {99 it("decodes reserved path characters from generated links", () => {
75 expect(100 expect(
76 splitPromptIntoComposerSegments(101 splitPromptIntoComposerSegments(
apps/web/src/composer-logic.test.ts+11-2
...@@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => {...@@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => {
246 );246 );
247 });247 });
248248
249 it("keeps replacement cursors aligned when another mention already exists earlier", () => {249 it("keeps package-like text expanded when another mention already exists earlier", () => {
250 const text = "open @AGENTS.md then @src/index.ts ";250 const text = "open @AGENTS.md then @src/index.ts ";
251 const expandedCursor = text.length;251 const expandedCursor = text.length;
252 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);252 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);
253253
254 expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2);254 expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length);
255 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
256 });
257
258 it("collapses only genuine mentions when package-like text exists earlier", () => {
259 const text = "install @scope/pkg then @README.md ";
260 const expandedCursor = text.length;
261 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);
262
263 expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length);
255 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);264 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
256 });265 });
257266
packages/shared/src/composerInlineTokens.test.ts+48
...@@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => {...@@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => {
81 it("ignores normal web links", () => {81 it("ignores normal web links", () => {
82 expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]);82 expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]);
83 });83 });
84
85 it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
86 "keeps scoped package reference %s as plain text",
87 (reference) => {
88 expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]);
89 },
90 );
91
92 it("keeps scoped package references plain across incomplete input and IME whitespace", () => {
93 expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]);
94 expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]);
95 });
96
97 it("keeps bare non-scoped file paths as mentions", () => {
98 expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([
99 {
100 type: "mention",
101 value: "README.md",
102 source: "@README.md",
103 start: 8,
104 end: 18,
105 },
106 ]);
107 });
108
109 it("keeps canonical file links for scoped paths as mentions", () => {
110 expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([
111 {
112 type: "mention",
113 value: "@scope/pkg/sub",
114 source: "[sub](@scope/pkg/sub)",
115 start: 8,
116 end: 29,
117 },
118 ]);
119 });
120
121 it("allows ambiguous scoped paths through explicit quoted mentions", () => {
122 expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([
123 {
124 type: "mention",
125 value: "expo/ui",
126 source: '@"expo/ui"',
127 start: 8,
128 end: 18,
129 },
130 ]);
131 });
84});132});
packages/shared/src/composerInlineTokens.ts+4-1
...@@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;...@@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
23const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;23const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;
24const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;24const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
25const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;25const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
26// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package.
27const SCOPED_PACKAGE_REFERENCE_REGEX =
28 /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/;
2629
27function collectMentionTokens(text: string): ComposerInlineToken[] {30function collectMentionTokens(text: string): ComposerInlineToken[] {
28 const matches: ComposerInlineToken[] = [];31 const matches: ComposerInlineToken[] = [];
...@@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] {...@@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] {
60 const prefix = match[1] ?? "";63 const prefix = match[1] ?? "";
61 const quotedPath = match[2];64 const quotedPath = match[2];
62 const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? "");65 const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? "");
63 if (!path) {66 if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) {
64 continue;67 continue;
65 }68 }
66 const start = (match.index ?? 0) + prefix.length;69 const start = (match.index ?? 0) + prefix.length;