| 1 | import type { ClientSettings } from "@t3tools/contracts/settings"; |
| 2 | import type { AssistantCitation, ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 3 | import { |
| 4 | serializeAssistantCitation, |
| 5 | withAssistantCitationComment, |
| 6 | } from "@t3tools/shared/assistantCitations"; |
| 7 | import { |
| 8 | splitPromptIntoComposerSegments, |
| 9 | type ComposerPromptSegment, |
| 10 | } from "./composer-editor-mentions"; |
| 11 | |
| 12 | import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; |
| 13 | |
| 14 | export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill" | "emoji"; |
| 15 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 16 | export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; |
| 17 | |
| 18 | export interface ComposerTrigger { |
| 19 | kind: ComposerTriggerKind; |
| 20 | query: string; |
| 21 | rangeStart: number; |
| 22 | rangeEnd: number; |
| 23 | } |
| 24 | |
| 25 | export function formatAssistantCitationForComposer(citation: AssistantCitation, comment = "") { |
| 26 | return `${serializeAssistantCitation(withAssistantCitationComment(citation, comment))} `; |
| 27 | } |
| 28 | |
| 29 | function 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 | |
| 39 | export 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 | |
| 73 | const isInlineTokenSegment = (segment: ComposerPromptSegment): boolean => segment.type !== "text"; |
| 74 | |
| 75 | function 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 | |
| 80 | function isWhitespace(char: string): boolean { |
| 81 | return char === " " || char === "\n" || char === "\t" || char === "\r"; |
| 82 | } |
| 83 | |
| 84 | function 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 | |
| 92 | export 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 | |
| 142 | function collapsedSegmentLength(segment: ComposerPromptSegment): number { |
| 143 | if (segment.type === "text") { |
| 144 | return segment.text.length; |
| 145 | } |
| 146 | return 1; |
| 147 | } |
| 148 | |
| 149 | function 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 | |
| 163 | export 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 | |
| 175 | export 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 | |
| 231 | export 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 | |
| 259 | function 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 | |
| 297 | export 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. */ |
| 354 | export 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 | |
| 370 | export 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 | |
| 382 | export 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 | } |