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