Skip to content
Source
Sign inSign up

composerTrigger.ts

182 lines5.4 KB

1export type ComposerTriggerKind =
2 | "path"
3 | "pull-request"
4 | "slash-command"
5 | "slash-model"
6 | "skill";
7export type ComposerSlashCommand = "model" | "plan" | "default";
8​
9export interface ComposerTrigger {
10 kind: ComposerTriggerKind;
11 query: string;
12 rangeStart: number;
13 rangeEnd: number;
15​
16function composerFileLinkBasename(path: string): string {
17 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
18 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
20​
21function escapeMarkdownLinkLabel(label: string): string {
22 return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
24​
25function encodeMarkdownLinkDestination(path: string): string {
26 return encodeURI(path)
27 .replaceAll("(", "%28")
28 .replaceAll(")", "%29")
29 .replaceAll("#", "%23")
30 .replaceAll("?", "%3F")
31 .replaceAll("\\", "%5C");
33​
34export function serializeComposerFileLink(path: string): string {
35 const label = escapeMarkdownLinkLabel(composerFileLinkBasename(path));
36 return `[${label}](${encodeMarkdownLinkDestination(path)})`;
38​
39function 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)));
43​
44function isWhitespace(char: string): boolean {
45 return char === " " || char === "\n" || char === "\t" || char === "\r";
47​
48/** A second slash or backslash makes the whole word a path, even when the caret precedes it. */
49export function isPathWord(
50 text: string,
51 wordStart: number,
52 cursor: number,
53 isWhitespaceChar: (char: string) => boolean,
54): boolean {
55 let wordEnd = cursor;
56 while (wordEnd < text.length && !isWhitespaceChar(text[wordEnd] ?? "")) {
57 wordEnd += 1;
58 }
59 return /[/\\]/.test(text.slice(wordStart + 1, wordEnd));
61​
62/**
63 * `$5`, `€9.99` and `$10k` are money, not skills. Matches the amounts the inline skill
64 * token regexes already refuse to render as skills, plus the punctuation prose puts after
65 * one (`$10k.`, `($5)`).
66 */
67export function isCurrencyAmountQuery(query: string): boolean {
68 return /^\.?[0-9][0-9,._]*(?:[kKmMbBtT]|[eE][0-9]+)?[.,;:!?)]*$/.test(query);
70​
71export function detectComposerTrigger(
72 text: string,
73 cursorInput: number,
74 isWhitespaceChar?: (char: string) => boolean,
75): ComposerTrigger | null {
76 const cursor = clampCursor(text, cursorInput);
77 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
78 const linePrefix = text.slice(lineStart, cursor);
79​
80 if (linePrefix.startsWith("/")) {
81 const commandMatch = /^\/(\S*)$/.exec(linePrefix);
82 if (commandMatch && isPathWord(text, lineStart, cursor, isWhitespaceChar ?? isWhitespace)) {
83 return null;
84 }
85 if (commandMatch) {
86 const commandQuery = commandMatch[1] ?? "";
87 if (commandQuery.toLowerCase() === "model") {
88 return {
89 kind: "slash-model",
90 query: "",
91 rangeStart: lineStart,
92 rangeEnd: cursor,
93 };
94 }
95 return {
96 kind: "slash-command",
97 query: commandQuery,
98 rangeStart: lineStart,
99 rangeEnd: cursor,
100 };
101 }
102​
103 const modelMatch = /^\/model(?:\s+(.*))?$/i.exec(linePrefix);
104 if (modelMatch) {
105 return {
106 kind: "slash-model",
107 query: (modelMatch[1] ?? "").trim(),
108 rangeStart: lineStart,
109 rangeEnd: cursor,
110 };
111 }
112 }
113​
114 const wsCheck = isWhitespaceChar ?? isWhitespace;
115 let tokenIdx = cursor - 1;
116 while (tokenIdx >= 0 && !wsCheck(text[tokenIdx] ?? "")) {
117 tokenIdx -= 1;
118 }
119 const tokenStart = tokenIdx + 1;
120​
121 const token = text.slice(tokenStart, cursor);
122 if (token.startsWith("/")) {
123 if (isPathWord(text, tokenStart, cursor, wsCheck)) return null;
124 return {
125 kind: "slash-command",
126 query: token.slice(1),
127 rangeStart: tokenStart,
128 rangeEnd: cursor,
129 };
130 }
131 const pullRequestMatch = /^#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token);
132 if (pullRequestMatch)
133 return {
134 kind: "pull-request",
135 query: pullRequestMatch[1] ?? "",
136 rangeStart: tokenStart,
137 rangeEnd: cursor,
138 };
139 const skillPrefix = /^\p{Sc}/u.exec(token);
140 if (skillPrefix) {
141 if (isCurrencyAmountQuery(token.slice(skillPrefix[0].length))) return null;
142 return {
143 kind: "skill",
144 query: token.slice(skillPrefix[0].length),
145 rangeStart: tokenStart,
146 rangeEnd: cursor,
147 };
148 }
149 if (!token.startsWith("@")) {
150 return null;
151 }
152​
153 return {
154 kind: "path",
155 query: token.slice(1),
156 rangeStart: tokenStart,
157 rangeEnd: cursor,
158 };
160​
161export function replaceTextRange(
162 text: string,
163 rangeStart: number,
164 rangeEnd: number,
165 replacement: string,
166): { text: string; cursor: number } {
167 const safeStart = Math.max(0, Math.min(text.length, rangeStart));
168 const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd));
169 const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
170 return { text: nextText, cursor: safeStart + replacement.length };
172​
173/** `/clear` as the whole message arms a fresh session instead of sending. */
174export function isClearComposerCommand(text: string): boolean {
175 return /^\/clear\s*$/i.test(text.trim());
177​
178/** `/side [prompt]`, or its alias `/btw [prompt]`, as the whole message. */
179export function parseSideChatCommand(text: string): { prompt: string } | null {
180 const match = /^\/(?:side|btw)(?:\s+([\s\S]*))?$/i.exec(text.trim());
181 return match ? { prompt: match[1]?.trim() ?? "" } : null;