| author | |
| committer | |
| log | 53042f47f85a986257573773d9854cf59b957514 |
| tree | 3059cf9a7a943eafe656570a8ea7ae83b50d3449 |
| parent | 6ce6f678b5d70a8d2b7230ec45cbde04d54f80dc |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 100 insertions(+), 16 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+2-1| ... | ... | @@ -10,6 +10,7 @@ import type { |
| 10 | 10 | import { |
| 11 | 11 | detectComposerTrigger, |
| 12 | 12 | replaceTextRange, |
| 13 | serializeComposerMentionPath, | |
| 13 | 14 | type ComposerTrigger, |
| 14 | 15 | } from "@t3tools/shared/composerTrigger"; |
| 15 | 16 | import { TextInputWrapper } from "expo-paste-input"; |
| ... | ... | @@ -408,7 +409,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 408 | 409 | |
| 409 | 410 | let replacement = ""; |
| 410 | 411 | if (item.type === "path") { |
| 411 | replacement = `@${item.path} `; | |
| 412 | replacement = `@${serializeComposerMentionPath(item.path)} `; | |
| 412 | 413 | } else if (item.type === "skill") { |
| 413 | 414 | replacement = `$${item.skill.name} `; |
| 414 | 415 | } else if (item.type === "slash-command") { |
apps/web/src/components/ComposerPromptEditor.tsx+2-1| ... | ... | @@ -6,6 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; |
| 6 | 6 | import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; |
| 7 | 7 | import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; |
| 8 | 8 | import { type ServerProviderSkill } from "@t3tools/contracts"; |
| 9 | import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger"; | |
| 9 | 10 | import { |
| 10 | 11 | $applyNodeReplacement, |
| 11 | 12 | $createRangeSelection, |
| ... | ... | @@ -198,7 +199,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> { |
| 198 | 199 | } |
| 199 | 200 | |
| 200 | 201 | override getTextContent(): string { |
| 201 | return `@${this.__path}`; | |
| 202 | return `@${serializeComposerMentionPath(this.__path)}`; | |
| 202 | 203 | } |
| 203 | 204 | |
| 204 | 205 | override isInline(): true { |
apps/web/src/components/chat/ChatComposer.tsx+2-1| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 18 | 18 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger"; | |
| 20 | 21 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 21 | 22 | import { |
| 22 | 23 | memo, |
| ... | ... | @@ -1471,7 +1472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1471 | 1472 | const { snapshot, trigger } = resolveActiveComposerTrigger(); |
| 1472 | 1473 | if (!trigger) return; |
| 1473 | 1474 | if (item.type === "path") { |
| 1474 | const replacement = `@${item.path} `; | |
| 1475 | const replacement = `@${serializeComposerMentionPath(item.path)} `; | |
| 1475 | 1476 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| 1476 | 1477 | snapshot.value, |
| 1477 | 1478 | trigger.rangeEnd, |
apps/web/src/composer-editor-mentions.test.ts+26| ... | ... | @@ -29,6 +29,22 @@ describe("splitPromptIntoComposerSegments", () => { |
| 29 | 29 | ]); |
| 30 | 30 | }); |
| 31 | 31 | |
| 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 | ||
| 32 | 48 | it("splits skill tokens followed by whitespace into skill segments", () => { |
| 33 | 49 | expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([ |
| 34 | 50 | { type: "text", text: "Use " }, |
| ... | ... | @@ -125,4 +141,14 @@ describe("selectionTouchesMentionBoundary", () => { |
| 125 | 141 | ), |
| 126 | 142 | ).toBe(true); |
| 127 | 143 | }); |
| 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 | }); | |
| 128 | 154 | }); |
apps/web/src/composer-editor-mentions.ts+19-11| ... | ... | @@ -21,8 +21,8 @@ export type ComposerPromptSegment = |
| 21 | 21 | context: TerminalContextDraft | null; |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; | |
| 25 | 24 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; |
| 25 | const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; | |
| 26 | 26 | |
| 27 | 27 | function rangeIncludesIndex(start: number, end: number, index: number): boolean { |
| 28 | 28 | return start <= index && index < end; |
| ... | ... | @@ -52,13 +52,18 @@ type InlineTokenMatch = |
| 52 | 52 | end: number; |
| 53 | 53 | }; |
| 54 | 54 | |
| 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[] = []; | |
| 57 | 59 | |
| 58 | 60 | for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { |
| 59 | 61 | const fullMatch = match[0]; |
| 60 | 62 | 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 ?? ""); | |
| 62 | 67 | const matchIndex = match.index ?? 0; |
| 63 | 68 | const start = matchIndex + prefix.length; |
| 64 | 69 | const end = start + fullMatch.length - prefix.length; |
| ... | ... | @@ -67,6 +72,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] { |
| 67 | 72 | } |
| 68 | 73 | } |
| 69 | 74 | |
| 75 | return matches; | |
| 76 | } | |
| 77 | ||
| 78 | function collectInlineTokenMatches(text: string): InlineTokenMatch[] { | |
| 79 | const matches: InlineTokenMatch[] = collectMentionTokenMatches(text); | |
| 80 | ||
| 70 | 81 | for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { |
| 71 | 82 | const fullMatch = match[0]; |
| 72 | 83 | const prefix = match[1] ?? ""; |
| ... | ... | @@ -148,10 +159,10 @@ function forEachPromptTextSlice( |
| 148 | 159 | |
| 149 | 160 | function forEachMentionMatch( |
| 150 | 161 | prompt: string, |
| 151 | visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void, | |
| 162 | visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void, | |
| 152 | 163 | ): boolean { |
| 153 | 164 | return forEachPromptTextSlice(prompt, (text, promptOffset) => { |
| 154 | for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { | |
| 165 | for (const match of collectMentionTokenMatches(text)) { | |
| 155 | 166 | if (visitor(match, promptOffset) === true) { |
| 156 | 167 | return true; |
| 157 | 168 | } |
| ... | ... | @@ -203,11 +214,8 @@ export function selectionTouchesMentionBoundary( |
| 203 | 214 | } |
| 204 | 215 | |
| 205 | 216 | 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; | |
| 211 | 219 | const beforeMentionIndex = mentionStart - 1; |
| 212 | 220 | const afterMentionIndex = mentionEnd; |
| 213 | 221 |
apps/web/src/composer-logic.test.ts+20| ... | ... | @@ -157,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => { |
| 157 | 157 | ); |
| 158 | 158 | }); |
| 159 | 159 | |
| 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 | ||
| 160 | 170 | it("allows path trigger detection to close after selecting a mention", () => { |
| 161 | 171 | const text = "what's in my @AGENTS.md "; |
| 162 | 172 | const collapsedCursorAfterMention = "what's in my ".length + 2; |
| ... | ... | @@ -191,6 +201,16 @@ describe("collapseExpandedComposerCursor", () => { |
| 191 | 201 | ); |
| 192 | 202 | }); |
| 193 | 203 | |
| 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 | ||
| 194 | 214 | it("keeps replacement cursors aligned when another mention already exists earlier", () => { |
| 195 | 215 | const text = "open @AGENTS.md then @src/index.ts "; |
| 196 | 216 | const expandedCursor = text.length; |
apps/web/src/composer-logic.ts+3-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger"; | |
| 1 | 2 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | 3 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 3 | 4 | |
| ... | ... | @@ -54,7 +55,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 54 | 55 | |
| 55 | 56 | for (const segment of segments) { |
| 56 | 57 | if (segment.type === "mention") { |
| 57 | const expandedLength = segment.path.length + 1; | |
| 58 | const expandedLength = serializeComposerMentionPath(segment.path).length + 1; | |
| 58 | 59 | if (remaining <= 1) { |
| 59 | 60 | return expandedCursor + (remaining === 0 ? 0 : expandedLength); |
| 60 | 61 | } |
| ... | ... | @@ -142,7 +143,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number |
| 142 | 143 | |
| 143 | 144 | for (const segment of segments) { |
| 144 | 145 | if (segment.type === "mention") { |
| 145 | const expandedLength = segment.path.length + 1; | |
| 146 | const expandedLength = serializeComposerMentionPath(segment.path).length + 1; | |
| 146 | 147 | if (remaining === 0) { |
| 147 | 148 | return collapsedCursor; |
| 148 | 149 | } |
packages/shared/src/composerTrigger.test.ts created+17| ... | ... | @@ -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+9| ... | ... | @@ -8,6 +8,15 @@ export interface ComposerTrigger { |
| 8 | 8 | rangeEnd: number; |
| 9 | 9 | } |
| 10 | 10 | |
| 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 | ||
| 11 | 20 | function clampCursor(text: string, cursor: number): number { |
| 12 | 21 | if (!Number.isFinite(cursor)) return text.length; |
| 13 | 22 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); |