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;
14}
15
16function composerFileLinkBasename(path: string): string {
17 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
18 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
19}
20
21function escapeMarkdownLinkLabel(label: string): string {
22 return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
23}
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");
32}
33
34export function serializeComposerFileLink(path: string): string {
35 const label = escapeMarkdownLinkLabel(composerFileLinkBasename(path));
36 return `[${label}](${encodeMarkdownLinkDestination(path)})`;
37}
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)));
42}
43
44function 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 */
55export 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
140export 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}