| 1 | export type ComposerTriggerKind = |
| 2 | | "path" |
| 3 | | "pull-request" |
| 4 | | "slash-command" |
| 5 | | "slash-model" |
| 6 | | "skill"; |
| 7 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 8 | |
| 9 | export interface ComposerTrigger { |
| 10 | kind: ComposerTriggerKind; |
| 11 | query: string; |
| 12 | rangeStart: number; |
| 13 | rangeEnd: number; |
| 14 | } |
| 15 | |
| 16 | function composerFileLinkBasename(path: string): string { |
| 17 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); |
| 18 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; |
| 19 | } |
| 20 | |
| 21 | function escapeMarkdownLinkLabel(label: string): string { |
| 22 | return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); |
| 23 | } |
| 24 | |
| 25 | function encodeMarkdownLinkDestination(path: string): string { |
| 26 | return encodeURI(path) |
| 27 | .replaceAll("(", "%28") |
| 28 | .replaceAll(")", "%29") |
| 29 | .replaceAll("#", "%23") |
| 30 | .replaceAll("?", "%3F") |
| 31 | .replaceAll("\\", "%5C"); |
| 32 | } |
| 33 | |
| 34 | export function serializeComposerFileLink(path: string): string { |
| 35 | const label = escapeMarkdownLinkLabel(composerFileLinkBasename(path)); |
| 36 | return `[${label}](${encodeMarkdownLinkDestination(path)})`; |
| 37 | } |
| 38 | |
| 39 | function clampCursor(text: string, cursor: number): number { |
| 40 | if (!Number.isFinite(cursor)) return text.length; |
| 41 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); |
| 42 | } |
| 43 | |
| 44 | function isWhitespace(char: string): boolean { |
| 45 | return char === " " || char === "\n" || char === "\t" || char === "\r"; |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * Detect an active trigger (@path, $skill, /command) at the cursor position. |
| 50 | * |
| 51 | * Accepts an optional `isWhitespaceChar` override so callers with inline |
| 52 | * placeholder characters (e.g. terminal context chips on web) can treat |
| 53 | * those as token boundaries. |
| 54 | */ |
| 55 | export function detectComposerTrigger( |
| 56 | text: string, |
| 57 | cursorInput: number, |
| 58 | isWhitespaceChar?: (char: string) => boolean, |
| 59 | ): ComposerTrigger | null { |
| 60 | const cursor = clampCursor(text, cursorInput); |
| 61 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; |
| 62 | const linePrefix = text.slice(lineStart, cursor); |
| 63 | |
| 64 | if (linePrefix.startsWith("/")) { |
| 65 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); |
| 66 | if (commandMatch) { |
| 67 | const commandQuery = commandMatch[1] ?? ""; |
| 68 | if (commandQuery.toLowerCase() === "model") { |
| 69 | return { |
| 70 | kind: "slash-model", |
| 71 | query: "", |
| 72 | rangeStart: lineStart, |
| 73 | rangeEnd: cursor, |
| 74 | }; |
| 75 | } |
| 76 | return { |
| 77 | kind: "slash-command", |
| 78 | query: commandQuery, |
| 79 | rangeStart: lineStart, |
| 80 | rangeEnd: cursor, |
| 81 | }; |
| 82 | } |
| 83 | |
| 84 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); |
| 85 | if (modelMatch) { |
| 86 | return { |
| 87 | kind: "slash-model", |
| 88 | query: (modelMatch[1] ?? "").trim(), |
| 89 | rangeStart: lineStart, |
| 90 | rangeEnd: cursor, |
| 91 | }; |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | const wsCheck = isWhitespaceChar ?? isWhitespace; |
| 96 | let tokenIdx = cursor - 1; |
| 97 | while (tokenIdx >= 0 && !wsCheck(text[tokenIdx] ?? "")) { |
| 98 | tokenIdx -= 1; |
| 99 | } |
| 100 | const tokenStart = tokenIdx + 1; |
| 101 | |
| 102 | const token = text.slice(tokenStart, cursor); |
| 103 | if (token.startsWith("/")) { |
| 104 | return { |
| 105 | kind: "slash-command", |
| 106 | query: token.slice(1), |
| 107 | rangeStart: tokenStart, |
| 108 | rangeEnd: cursor, |
| 109 | }; |
| 110 | } |
| 111 | const pullRequestMatch = /^#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token); |
| 112 | if (pullRequestMatch) |
| 113 | return { |
| 114 | kind: "pull-request", |
| 115 | query: pullRequestMatch[1] ?? "", |
| 116 | rangeStart: tokenStart, |
| 117 | rangeEnd: cursor, |
| 118 | }; |
| 119 | const skillPrefix = /^\p{Sc}/u.exec(token); |
| 120 | if (skillPrefix) { |
| 121 | return { |
| 122 | kind: "skill", |
| 123 | query: token.slice(skillPrefix[0].length), |
| 124 | rangeStart: tokenStart, |
| 125 | rangeEnd: cursor, |
| 126 | }; |
| 127 | } |
| 128 | if (!token.startsWith("@")) { |
| 129 | return null; |
| 130 | } |
| 131 | |
| 132 | return { |
| 133 | kind: "path", |
| 134 | query: token.slice(1), |
| 135 | rangeStart: tokenStart, |
| 136 | rangeEnd: cursor, |
| 137 | }; |
| 138 | } |
| 139 | |
| 140 | export function replaceTextRange( |
| 141 | text: string, |
| 142 | rangeStart: number, |
| 143 | rangeEnd: number, |
| 144 | replacement: string, |
| 145 | ): { text: string; cursor: number } { |
| 146 | const safeStart = Math.max(0, Math.min(text.length, rangeStart)); |
| 147 | const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); |
| 148 | const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; |
| 149 | return { text: nextText, cursor: safeStart + replacement.length }; |
| 150 | } |