1import type { AssistantCitation } from "@t3tools/contracts";
2import { collectAssistantCitations } from "@t3tools/shared/assistantCitations";
3import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
4import {
5 collectComposerInlineTokens,
6 type ComposerInlineToken,
7} from "@t3tools/shared/composerInlineTokens";
8
9export type ComposerPromptSegment =
10 | {
11 type: "text";
12 text: string;
13 }
14 | {
15 type: "mention";
16 path: string;
17 source: string;
18 }
19 | {
20 type: "skill";
21 name: string;
22 source: string;
23 }
24 | {
25 type: "citation";
26 citation: AssistantCitation;
27 source: string;
28 }
29 | {
30 type: "context-reference";
31 kind: string;
32 contextId: string;
33 label: string;
34 source: string;
35 };
36
37function rangeIncludesIndex(start: number, end: number, index: number): boolean {
38 return start <= index && index < end;
39}
40
41function pushTextSegment(segments: ComposerPromptSegment[], text: string): void {
42 if (!text) return;
43 const last = segments[segments.length - 1];
44 if (last && last.type === "text") {
45 last.text += text;
46 return;
47 }
48 segments.push({ type: "text", text });
49}
50
51function forEachPromptTextSlice(
52 prompt: string,
53 visitor: (text: string, promptOffset: number) => boolean | void,
54): boolean {
55 return prompt.length > 0 && visitor(prompt, 0) === true;
56}
57
58function forEachMentionMatch(
59 prompt: string,
60 visitor: (
61 match: Extract<ComposerInlineToken, { type: "mention" }>,
62 promptOffset: number,
63 ) => boolean | void,
64): boolean {
65 return forEachPromptTextSlice(prompt, (text, promptOffset) => {
66 for (const match of collectComposerPromptInlineTokens(text)) {
67 if (match.type !== "mention") {
68 continue;
69 }
70 if (visitor(match, promptOffset) === true) {
71 return true;
72 }
73 }
74 return false;
75 });
76}
77
78export function collectComposerPromptInlineTokens(text: string) {
79 const tokens = collectComposerInlineTokens(text);
80 const citations = collectAssistantCitations(text);
81 const references = collectComposerContextReferences(text);
82 if (citations.length === 0 && references.length === 0) return tokens;
83
84 // An unfinished @ mention can otherwise consume the start of a link label.
85 const links = [
86 ...citations.map((match) => ({ ...match, type: "citation" as const })),
87 ...references.map((match) => ({ ...match, type: "context-reference" as const })),
88 ];
89 return [
90 ...tokens.filter(
91 (token) => !links.some((link) => token.start < link.end && token.end > link.start),
92 ),
93 ...links,
94 ].sort((left, right) => left.start - right.start);
95}
96
97function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] {
98 const segments: ComposerPromptSegment[] = [];
99 if (!text) {
100 return segments;
101 }
102
103 const tokenMatches = collectComposerPromptInlineTokens(text);
104 let cursor = 0;
105 for (const match of tokenMatches) {
106 if (match.start < cursor) {
107 continue;
108 }
109
110 if (match.start > cursor) {
111 pushTextSegment(segments, text.slice(cursor, match.start));
112 }
113
114 if (match.type === "citation") {
115 segments.push({ type: "citation", citation: match.citation, source: match.source });
116 } else if (match.type === "context-reference") {
117 segments.push({
118 type: "context-reference",
119 kind: match.kind,
120 contextId: match.contextId,
121 label: match.label,
122 source: match.source,
123 });
124 } else if (match.type === "mention") {
125 segments.push({
126 type: "mention",
127 path: match.value,
128 source: match.source,
129 });
130 } else {
131 segments.push({ type: "skill", name: match.value, source: match.source });
132 }
133
134 cursor = match.end;
135 }
136
137 if (cursor < text.length) {
138 pushTextSegment(segments, text.slice(cursor));
139 }
140
141 return segments;
142}
143
144export function selectionTouchesMentionBoundary(
145 prompt: string,
146 start: number,
147 end: number,
148): boolean {
149 if (!prompt || start >= end) {
150 return false;
151 }
152
153 return forEachMentionMatch(prompt, (match, promptOffset) => {
154 const mentionStart = promptOffset + match.start;
155 const mentionEnd = promptOffset + match.end;
156 const beforeMentionIndex = mentionStart - 1;
157 const afterMentionIndex = mentionEnd;
158
159 if (
160 beforeMentionIndex >= 0 &&
161 /\s/.test(prompt[beforeMentionIndex] ?? "") &&
162 rangeIncludesIndex(start, end, beforeMentionIndex)
163 ) {
164 return true;
165 }
166
167 if (
168 afterMentionIndex < prompt.length &&
169 /\s/.test(prompt[afterMentionIndex] ?? "") &&
170 rangeIncludesIndex(start, end, afterMentionIndex)
171 ) {
172 return true;
173 }
174 return false;
175 });
176}
177
178export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptSegment[] {
179 return splitPromptTextIntoComposerSegments(prompt);
180}