diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 372fe57d0d..637c6874a1 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -2,7 +2,7 @@ * Plain-text reference scan (the plain-text-reference decision; * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): * a `/name` or `@name` token whose name is on the trigger's lexicon, and - * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's + * syntax-recognizable `@path` file and folder tokens. Pure derivation — the editor's * text-ref entity transform consumes these ranges; editing the text out of * match shape simply drops the range next scan. */ @@ -10,9 +10,9 @@ /** * One plain-text reference range (the plain-text-reference decision; * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): - * a `/name` or `@name` token - * whose name is on the trigger's lexicon. Pure derivation — editing the text - * out of match shape simply drops the range next scan. + * a `/name` or `@name` token whose name is on the trigger's lexicon, or an + * `@path` file or folder token. Pure derivation — editing the text out of + * match shape simply drops the range next scan. */ export interface TextRefRange { readonly start: number @@ -23,30 +23,40 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g /** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */ -const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]+))/g +const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g /** - * What may follow an unquoted or open-quoted folder token's slash: trailing - * punctuation, then whitespace or the draft end. The class matches the - * punctuation that `folderReferenceRule` in ui-reference strips from a sent token. + * Punctuation that ends prose rather than a path: the characters the + * sent-message file and folder rules in ui-reference strip from the end of an + * unquoted token. */ -const FOLDER_END_RE = /[.,;:!?,。;:!?]*(?:\s|$)/y +const TRAILING_PUNCTUATION = new Set('.,;:!?,。;:!?') +/** An unquoted token names a path only with a separator or an extension; a bare `@name` stays plain until a lexicon claims it. */ +const PATH_LIKE_RE = /^@.*[/\\.]/u /** - * End of the whole `@dir/` folder token at `start`, so `@src/a.ts` and - * `@"a/b c.md"` never match their directory prefix. A closed quote ends the - * token when it directly follows the slash; an unquoted or open-quoted token - * (a drill in progress) ends at its last slash. + * End of the whole `@path` file or folder token at `start`, so a path is never + * cut at its directory prefix. A closed quote ends at its quote and an + * unquoted token with a separator or extension drops trailing punctuation, + * the spans the sent-message rules render. An open quote (a folder drill in + * progress) ends at its last slash when only trailing punctuation and + * whitespace or the draft end follow it. * @param draft - draft text. * @param start - offset of the token's `@`. * @param token - the path token at `start`. - * @returns the folder token's end offset, or undefined for a non-folder token. + * @returns the token's end offset, or undefined when it names no path. */ -function folderEnd(draft: string, start: number, token: string): number | undefined { - if (token.startsWith('@"') && token.endsWith('/"')) return start + token.length +function pathEnd(draft: string, start: number, token: string): number | undefined { + if (!token.startsWith('@"')) { + let length = token.length + while (TRAILING_PUNCTUATION.has(token.charAt(length - 1))) length -= 1 + return PATH_LIKE_RE.test(token.slice(0, length)) ? start + length : undefined + } + if (token.length > 3 && token.endsWith('"')) return start + token.length const slash = token.lastIndexOf('/') - if (slash < 0 || token.endsWith('"')) return undefined - FOLDER_END_RE.lastIndex = start + slash + 1 - return FOLDER_END_RE.test(draft) ? start + slash + 1 : undefined + if (slash < 0) return undefined + let next = start + slash + 1 + while (TRAILING_PUNCTUATION.has(draft.charAt(next))) next += 1 + return next === draft.length || /\s/u.test(draft.charAt(next)) ? start + slash + 1 : undefined } /** * What may follow a `/name` token: whitespace or the draft end, the boundary @@ -85,7 +95,7 @@ export function scanTextRefs( let path: RegExpExecArray | null while ((path = PATH_TOKEN_RE.exec(draft)) !== null) { const start = path.index + (path[1]?.length ?? 0) - const end = folderEnd(draft, start, path[2] ?? '') + const end = pathEnd(draft, start, path[2] ?? '') if (end !== undefined && !out.some(range => range.start < end && range.end > start)) { out.push({ start, end, trigger: '@' }) } diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index d637f58ddd..cffced410d 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -812,10 +812,14 @@ describe('plain-text reference catalogs', () => { ]) }) - it('matches whole folder tokens, never the directory prefix of a pasted file path', () => { - expect(scanTextRefs('@src/deep/file.txt @"/a/nv/SM100 ISA.html" 看 @"a/ b.md" @src/.env @src/de', new Map())).toEqual([]) - const text = '@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x' - expect(scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))) + it('matches whole file and folder path tokens, never a directory prefix', () => { + const slices = (text: string) => scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end)) + expect(slices('@src/deep/file.txt, @"/a/nv/SM100 ISA.html" 看 @"a/ b.md"。 @src/.env @C:\\src\\a.ts @src/de')) + .toEqual(['@src/deep/file.txt', '@"/a/nv/SM100 ISA.html"', '@"a/ b.md"', '@src/.env', '@C:\\src\\a.ts', '@src/de']) + expect(slices('@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x')) .toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"open dir/']) + expect(slices('@person @"" @"a/b')).toEqual([]) + expect(slices('@"open @')).toEqual([]) + expect(slices('drill @"open dir/。')).toEqual(['@"open dir/']) }) }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index fa01087a3b..0112d60abb 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1875,13 +1875,13 @@ describe('decorations', () => { expect(shell.snapshot.draft).toBe('see @src/components/') }) - it('a pasted file path stays plain: only a whole folder token decorates', async () => { + it('a pasted path decorates as one whole file or folder token', async () => { const { view, shell, textarea } = bench() const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent) const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first' fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } }) await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) }) - expect(marks()).toEqual(['@src/deep/']) + expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/']) }) it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {