1import type { ClientSettings } from "@t3tools/contracts/settings";
2import type { AssistantCitation, ResolvedKeybindingsConfig } from "@t3tools/contracts";
3import {
4 serializeAssistantCitation,
5 withAssistantCitationComment,
6} from "@t3tools/shared/assistantCitations";
7import {
8 splitPromptIntoComposerSegments,
9 type ComposerPromptSegment,
10} from "./composer-editor-mentions";
11
12import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings";
13
14export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill" | "emoji";
15export type ComposerSlashCommand = "model" | "plan" | "default";
16export type ComposerSubmissionIntent = "foreground" | "background" | "alternate";
17
18export interface ComposerTrigger {
19 kind: ComposerTriggerKind;
20 query: string;
21 rangeStart: number;
22 rangeEnd: number;
23}
24
25export function formatAssistantCitationForComposer(citation: AssistantCitation, comment = "") {
26 return `${serializeAssistantCitation(withAssistantCitationComment(citation, comment))} `;
27}
28
29function composerRequiresModifier(
30 sendShortcut: ClientSettings["sendShortcut"] | undefined,
31 prompt: string,
32) {
33 return (
34 sendShortcut === "mod-enter" ||
35 (sendShortcut === "mod-enter-multiline" && /[\r\n]/.test(prompt))
36 );
37}
38
39export function composerSubmissionIntentForKey(input: {
40 event: ShortcutEventLike & { isComposing?: boolean; keyCode?: number; repeat?: boolean };
41 keybindings: ResolvedKeybindingsConfig;
42 platform?: string;
43 isMobileViewport: boolean;
44 isDraftThread: boolean;
45 isRunning?: boolean;
46 sendShortcut?: ClientSettings["sendShortcut"];
47 prompt?: string;
48}): ComposerSubmissionIntent | null {
49 const { event } = input;
50 if (input.isMobileViewport || event.isComposing || event.keyCode === 229 || event.repeat)
51 return null;
52 const command = resolveShortcutCommand(event, input.keybindings, {
53 ...(input.platform === undefined ? {} : { platform: input.platform }),
54 context: {
55 composerFocus: true,
56 draftThreadRoute: input.isDraftThread,
57 turnRunning: input.isRunning === true,
58 },
59 });
60 if (command === "composer.sendAlternate" && input.isRunning) return "alternate";
61 if (command === "composer.sendBackground" && input.isDraftThread) return "background";
62 if (command === "composer.sendAndNewThread" && !input.isDraftThread) return "background";
63 if (command !== null || event.key !== "Enter" || event.shiftKey || event.altKey) return null;
64 if (
65 composerRequiresModifier(input.sendShortcut, input.prompt ?? "") &&
66 !event.metaKey &&
67 !event.ctrlKey
68 )
69 return null;
70 return "foreground";
71}
72
73const isInlineTokenSegment = (segment: ComposerPromptSegment): boolean => segment.type !== "text";
74
75function clampCursor(text: string, cursor: number): number {
76 if (!Number.isFinite(cursor)) return text.length;
77 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
78}
79
80function isWhitespace(char: string): boolean {
81 return char === " " || char === "\n" || char === "\t" || char === "\r";
82}
83
84function tokenStartForCursor(text: string, cursor: number): number {
85 let index = cursor - 1;
86 while (index >= 0 && !isWhitespace(text[index] ?? "")) {
87 index -= 1;
88 }
89 return index + 1;
90}
91
92export function expandCollapsedComposerCursor(
93 text: string,
94 cursorInput: number,
95 literalText = false,
96): number {
97 if (literalText) return clampCursor(text, cursorInput);
98 const collapsedCursor = clampCursor(text, cursorInput);
99 const segments = splitPromptIntoComposerSegments(text);
100 if (segments.length === 0) {
101 return collapsedCursor;
102 }
103
104 let remaining = collapsedCursor;
105 let expandedCursor = 0;
106
107 for (const segment of segments) {
108 if (
109 segment.type === "mention" ||
110 segment.type === "citation" ||
111 segment.type === "context-reference"
112 ) {
113 const expandedLength = segment.source.length;
114 if (remaining <= 1) {
115 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
116 }
117 remaining -= 1;
118 expandedCursor += expandedLength;
119 continue;
120 }
121 if (segment.type === "skill") {
122 const expandedLength = segment.source.length;
123 if (remaining <= 1) {
124 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
125 }
126 remaining -= 1;
127 expandedCursor += expandedLength;
128 continue;
129 }
130
131 const segmentLength = segment.text.length;
132 if (remaining <= segmentLength) {
133 return expandedCursor + remaining;
134 }
135 remaining -= segmentLength;
136 expandedCursor += segmentLength;
137 }
138
139 return expandedCursor;
140}
141
142function collapsedSegmentLength(segment: ComposerPromptSegment): number {
143 if (segment.type === "text") {
144 return segment.text.length;
145 }
146 return 1;
147}
148
149function clampCollapsedComposerCursorForSegments(
150 segments: ReadonlyArray<ComposerPromptSegment>,
151 cursorInput: number,
152): number {
153 const collapsedLength = segments.reduce(
154 (total, segment) => total + collapsedSegmentLength(segment),
155 0,
156 );
157 if (!Number.isFinite(cursorInput)) {
158 return collapsedLength;
159 }
160 return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));
161}
162
163export function clampCollapsedComposerCursor(
164 text: string,
165 cursorInput: number,
166 literalText = false,
167): number {
168 if (literalText) return clampCursor(text, cursorInput);
169 return clampCollapsedComposerCursorForSegments(
170 splitPromptIntoComposerSegments(text),
171 cursorInput,
172 );
173}
174
175export function collapseExpandedComposerCursor(
176 text: string,
177 cursorInput: number,
178 literalText = false,
179): number {
180 if (literalText) return clampCursor(text, cursorInput);
181 const expandedCursor = clampCursor(text, cursorInput);
182 const segments = splitPromptIntoComposerSegments(text);
183 if (segments.length === 0) {
184 return expandedCursor;
185 }
186
187 let remaining = expandedCursor;
188 let collapsedCursor = 0;
189
190 for (const segment of segments) {
191 if (
192 segment.type === "mention" ||
193 segment.type === "citation" ||
194 segment.type === "context-reference"
195 ) {
196 const expandedLength = segment.source.length;
197 if (remaining === 0) {
198 return collapsedCursor;
199 }
200 if (remaining <= expandedLength) {
201 return collapsedCursor + 1;
202 }
203 remaining -= expandedLength;
204 collapsedCursor += 1;
205 continue;
206 }
207 if (segment.type === "skill") {
208 const expandedLength = segment.source.length;
209 if (remaining === 0) {
210 return collapsedCursor;
211 }
212 if (remaining <= expandedLength) {
213 return collapsedCursor + 1;
214 }
215 remaining -= expandedLength;
216 collapsedCursor += 1;
217 continue;
218 }
219
220 const segmentLength = segment.text.length;
221 if (remaining <= segmentLength) {
222 return collapsedCursor + remaining;
223 }
224 remaining -= segmentLength;
225 collapsedCursor += segmentLength;
226 }
227
228 return collapsedCursor;
229}
230
231export function isCollapsedCursorAdjacentToInlineToken(
232 text: string,
233 cursorInput: number,
234 direction: "left" | "right",
235): boolean {
236 const segments = splitPromptIntoComposerSegments(text);
237 if (!segments.some(isInlineTokenSegment)) {
238 return false;
239 }
240
241 const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput);
242 let collapsedOffset = 0;
243
244 for (const segment of segments) {
245 if (isInlineTokenSegment(segment)) {
246 if (direction === "left" && cursor === collapsedOffset + 1) {
247 return true;
248 }
249 if (direction === "right" && cursor === collapsedOffset) {
250 return true;
251 }
252 }
253 collapsedOffset += collapsedSegmentLength(segment);
254 }
255
256 return false;
257}
258
259function isEmojiQueryInCode(text: string, cursor: number): boolean {
260 let fence: string | null = null;
261 let codeDelimiter: string | null = null;
262
263 for (const line of text.slice(0, cursor).split("\n")) {
264 const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
265 if (fenceMatch) {
266 const delimiter = fenceMatch[1]!;
267 if (fence === null) {
268 fence = delimiter;
269 } else if (
270 delimiter[0] === fence[0] &&
271 delimiter.length >= fence.length &&
272 fenceMatch[2]!.trim() === ""
273 ) {
274 fence = null;
275 }
276 codeDelimiter = null;
277 continue;
278 }
279 if (fence !== null) continue;
280
281 for (let index = 0; index < line.length; index += 1) {
282 if (line[index] === "\\" && codeDelimiter === null) {
283 index += 1;
284 continue;
285 }
286 if (line[index] !== "`") continue;
287 const delimiter = /^`+/.exec(line.slice(index))![0];
288 if (codeDelimiter === null) codeDelimiter = delimiter;
289 else if (codeDelimiter === delimiter) codeDelimiter = null;
290 index += delimiter.length - 1;
291 }
292 }
293
294 return fence !== null || codeDelimiter !== null;
295}
296
297export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
298 const cursor = clampCursor(text, cursorInput);
299 const emojiMatch = /(?:^|[\s([{])(:([a-z_+-][a-z0-9_+-]*))$/i.exec(text.slice(0, cursor));
300 if (emojiMatch && !isEmojiQueryInCode(text, cursor)) {
301 const shortcode = emojiMatch[1]!;
302 let rangeEnd = cursor;
303 while (rangeEnd < text.length && /[a-z0-9_+-]/i.test(text[rangeEnd]!)) rangeEnd += 1;
304 if (text[rangeEnd] === ":") rangeEnd += 1;
305 return {
306 kind: "emoji",
307 query: emojiMatch[2]!,
308 rangeStart: cursor - shortcode.length,
309 rangeEnd,
310 };
311 }
312
313 const tokenStart = tokenStartForCursor(text, cursor);
314 const token = text.slice(tokenStart, cursor);
315 if (token.startsWith("/")) {
316 return {
317 kind: "slash-command",
318 query: token.slice(1),
319 rangeStart: tokenStart,
320 rangeEnd: cursor,
321 };
322 }
323 const pullRequestMatch = /^#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token);
324 if (pullRequestMatch) {
325 return {
326 kind: "pull-request",
327 query: pullRequestMatch[1] ?? "",
328 rangeStart: tokenStart,
329 rangeEnd: cursor,
330 };
331 }
332 const skillPrefix = /^\p{Sc}/u.exec(token);
333 if (skillPrefix) {
334 return {
335 kind: "skill",
336 query: token.slice(skillPrefix[0].length),
337 rangeStart: tokenStart,
338 rangeEnd: cursor,
339 };
340 }
341 if (!token.startsWith("@")) {
342 return null;
343 }
344
345 return {
346 kind: "path",
347 query: token.slice(1),
348 rangeStart: tokenStart,
349 rangeEnd: cursor,
350 };
351}
352
353/** Caret and trigger after replacing composer text and continuing at the end. */
354export function composerStateAtPromptEnd(
355 text: string,
356 literalText = false,
357): {
358 cursor: number;
359 trigger: ComposerTrigger | null;
360} {
361 const cursor = collapseExpandedComposerCursor(text, text.length, literalText);
362 return {
363 cursor,
364 trigger: literalText
365 ? null
366 : detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)),
367 };
368}
369
370export function parseStandaloneComposerSlashCommand(
371 text: string,
372): Exclude<ComposerSlashCommand, "model"> | null {
373 const match = /^\/(plan|default)\s*$/i.exec(text.trim());
374 if (!match) {
375 return null;
376 }
377 const command = match[1]?.toLowerCase();
378 if (command === "plan") return "plan";
379 return "default";
380}
381
382export function replaceTextRange(
383 text: string,
384 rangeStart: number,
385 rangeEnd: number,
386 replacement: string,
387): { text: string; cursor: number } {
388 const safeStart = Math.max(0, Math.min(text.length, rangeStart));
389 const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd));
390 const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
391 return { text: nextText, cursor: safeStart + replacement.length };
392}