Skip to content
Source
Sign inSign up

composer-logic.ts

336 lines9.7 KB

1import type { ClientSettings } from "@t3tools/contracts/settings";
2import type {
3 AssistantCitation,
4 EnvironmentId,
5 ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
7import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads";
8import {
9 serializeAssistantCitation,
10 withAssistantCitationComment,
11} from "@t3tools/shared/assistantCitations";
12import { isCurrencyAmountQuery, isPathWord } from "@t3tools/shared/composerTrigger";
13import {
14 splitPromptIntoComposerSegments,
15 type ComposerPromptSegment,
16} from "./composer-editor-mentions";
17​
18import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings";
19​
20export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill";
21export type ComposerSlashCommand = "model" | "side" | "btw" | "clear";
22export type ComposerSubmissionIntent = "foreground" | "background" | "alternate";
23​
24export interface ComposerTrigger {
25 kind: ComposerTriggerKind;
26 query: string;
27 rangeStart: number;
28 rangeEnd: number;
30​
31export function isPathSuggestionCurrent(
32 searchTarget: ComposerPathSearchTarget,
33 trigger: ComposerTrigger | null,
34 environmentId: EnvironmentId | null,
35 cwd: string | null,
36) {
37 return (
38 trigger?.kind === "path" &&
39 searchTarget.environmentId === environmentId &&
40 searchTarget.cwd === cwd &&
41 searchTarget.query === trigger.query.trim()
42 );
44​
45export function formatAssistantCitationForComposer(citation: AssistantCitation, comment = "") {
46 return `${serializeAssistantCitation(withAssistantCitationComment(citation, comment))} `;
48​
49export function composerRequiresModifier(
50 sendShortcut: ClientSettings["sendShortcut"] | undefined,
51 prompt: string,
52) {
53 return (
54 sendShortcut === "mod-enter" ||
55 (sendShortcut === "mod-enter-multiline" && /[\r\n]/.test(prompt))
56 );
58​
59export function composerSubmissionIntentForKey(input: {
60 event: ShortcutEventLike & { isComposing?: boolean; keyCode?: number; repeat?: boolean };
61 keybindings: ResolvedKeybindingsConfig;
62 platform?: string;
63 isSoftwareKeyboardOpen: boolean;
64 isDraftThread: boolean;
65 isRunning?: boolean;
66 sendShortcut?: ClientSettings["sendShortcut"];
67 prompt?: string;
68}): ComposerSubmissionIntent | null {
69 const { event } = input;
70 if (event.isComposing || event.keyCode === 229 || event.repeat) return null;
71 if (input.isSoftwareKeyboardOpen && event.key === "Enter" && !event.metaKey && !event.ctrlKey)
72 return null;
73 const command = resolveShortcutCommand(event, input.keybindings, {
74 ...(input.platform === undefined ? {} : { platform: input.platform }),
75 context: {
76 composerFocus: true,
77 draftThreadRoute: input.isDraftThread,
78 turnRunning: input.isRunning === true,
79 composerRequiresModifier: composerRequiresModifier(input.sendShortcut, input.prompt ?? ""),
80 },
81 });
82 if (command === "composer.sendAlternate" && input.isRunning) return "alternate";
83 if (command === "composer.sendBackground" && input.isDraftThread) return "background";
84 if (command === "composer.sendAndNewThread" && !input.isDraftThread) return "background";
85 if (command !== null || event.key !== "Enter" || event.shiftKey || event.altKey) return null;
86 if (
87 composerRequiresModifier(input.sendShortcut, input.prompt ?? "") &&
88 !event.metaKey &&
89 !event.ctrlKey
90 )
91 return null;
92 return "foreground";
94​
95function clampCursor(text: string, cursor: number): number {
96 if (!Number.isFinite(cursor)) return text.length;
97 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
99​
100function isWhitespace(char: string): boolean {
101 return char === " " || char === "\n" || char === "\t" || char === "\r";
103​
104function tokenStartForCursor(text: string, cursor: number): number {
105 let index = cursor - 1;
106 while (index >= 0 && !isWhitespace(text[index] ?? "")) {
107 index -= 1;
108 }
109 return index + 1;
111​
112export function expandCollapsedComposerCursor(
113 text: string,
114 cursorInput: number,
115 literalText = false,
116): number {
117 if (literalText) return clampCursor(text, cursorInput);
118 const collapsedCursor = clampCursor(text, cursorInput);
119 const segments = splitPromptIntoComposerSegments(text);
120 if (segments.length === 0) {
121 return collapsedCursor;
122 }
123​
124 let remaining = collapsedCursor;
125 let expandedCursor = 0;
126​
127 for (const segment of segments) {
128 if (
129 segment.type === "mention" ||
130 segment.type === "citation" ||
131 segment.type === "context-reference"
132 ) {
133 const expandedLength = segment.source.length;
134 if (remaining <= 1) {
135 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
136 }
137 remaining -= 1;
138 expandedCursor += expandedLength;
139 continue;
140 }
141 if (segment.type === "skill" || segment.type === "model") {
142 const expandedLength = segment.source.length;
143 if (remaining <= 1) {
144 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
145 }
146 remaining -= 1;
147 expandedCursor += expandedLength;
148 continue;
149 }
150​
151 const segmentLength = segment.text.length;
152 if (remaining <= segmentLength) {
153 return expandedCursor + remaining;
154 }
155 remaining -= segmentLength;
156 expandedCursor += segmentLength;
157 }
158​
159 return expandedCursor;
161​
162function collapsedSegmentLength(segment: ComposerPromptSegment): number {
163 if (segment.type === "text") {
164 return segment.text.length;
165 }
166 return 1;
168​
169function clampCollapsedComposerCursorForSegments(
170 segments: ReadonlyArray<ComposerPromptSegment>,
171 cursorInput: number,
172): number {
173 const collapsedLength = segments.reduce(
174 (total, segment) => total + collapsedSegmentLength(segment),
175 0,
176 );
177 if (!Number.isFinite(cursorInput)) {
178 return collapsedLength;
179 }
180 return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));
182​
183export function clampCollapsedComposerCursor(
184 text: string,
185 cursorInput: number,
186 literalText = false,
187): number {
188 if (literalText) return clampCursor(text, cursorInput);
189 return clampCollapsedComposerCursorForSegments(
190 splitPromptIntoComposerSegments(text),
191 cursorInput,
192 );
194​
195export function collapseExpandedComposerCursor(
196 text: string,
197 cursorInput: number,
198 literalText = false,
199): number {
200 if (literalText) return clampCursor(text, cursorInput);
201 const expandedCursor = clampCursor(text, cursorInput);
202 const segments = splitPromptIntoComposerSegments(text);
203 if (segments.length === 0) {
204 return expandedCursor;
205 }
206​
207 let remaining = expandedCursor;
208 let collapsedCursor = 0;
209​
210 for (const segment of segments) {
211 if (
212 segment.type === "mention" ||
213 segment.type === "citation" ||
214 segment.type === "context-reference"
215 ) {
216 const expandedLength = segment.source.length;
217 if (remaining === 0) {
218 return collapsedCursor;
219 }
220 if (remaining <= expandedLength) {
221 return collapsedCursor + 1;
222 }
223 remaining -= expandedLength;
224 collapsedCursor += 1;
225 continue;
226 }
227 if (segment.type === "skill" || segment.type === "model") {
228 const expandedLength = segment.source.length;
229 if (remaining === 0) {
230 return collapsedCursor;
231 }
232 if (remaining <= expandedLength) {
233 return collapsedCursor + 1;
234 }
235 remaining -= expandedLength;
236 collapsedCursor += 1;
237 continue;
238 }
239​
240 const segmentLength = segment.text.length;
241 if (remaining <= segmentLength) {
242 return collapsedCursor + remaining;
243 }
244 remaining -= segmentLength;
245 collapsedCursor += segmentLength;
246 }
247​
248 return collapsedCursor;
250​
251export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
252 const cursor = clampCursor(text, cursorInput);
253 const tokenStart = tokenStartForCursor(text, cursor);
254 const token = text.slice(tokenStart, cursor);
255 if (token.startsWith("/")) {
256 if (isPathWord(text, tokenStart, cursor, isWhitespace)) return null;
257 return {
258 kind: "slash-command",
259 query: token.slice(1),
260 rangeStart: tokenStart,
261 rangeEnd: cursor,
262 };
263 }
264 const pullRequestMatch = /^#([\p{L}\p{N}][\p{L}\p{N}_-]*)?$/u.exec(token);
265 if (pullRequestMatch) {
266 return {
267 kind: "pull-request",
268 query: pullRequestMatch[1] ?? "",
269 rangeStart: tokenStart,
270 rangeEnd: cursor,
271 };
272 }
273 const skillPrefix = /^\p{Sc}/u.exec(token);
274 if (skillPrefix) {
275 if (isCurrencyAmountQuery(token.slice(skillPrefix[0].length))) return null;
276 return {
277 kind: "skill",
278 query: token.slice(skillPrefix[0].length),
279 rangeStart: tokenStart,
280 rangeEnd: cursor,
281 };
282 }
283 if (!token.startsWith("@")) {
284 return null;
285 }
286​
287 return {
288 kind: "path",
289 query: token.slice(1),
290 rangeStart: tokenStart,
291 rangeEnd: cursor,
292 };
294​
295export function pastedPathQueryLength(
296 lineBefore: string,
297 pasted: string,
298 pathQueryActive = true,
299): number {
300 if (!pathQueryActive) return 0;
301 if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0;
302 return /^\S*/.exec(pasted)![0].length;
304​
305export function composerStateAtPromptEnd(
306 text: string,
307 literalText = false,
308): {
309 cursor: number;
310 trigger: ComposerTrigger | null;
311} {
312 const cursor = collapseExpandedComposerCursor(text, text.length, literalText);
313 return {
314 cursor,
315 trigger: literalText
316 ? null
317 : detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)),
318 };
320​
321/**
322 * A replacement ending in a space swallows one space already after the range, so inserting
323 * `@file ` before existing text does not leave a double space.
324 */
325export function extendReplacementRangeForTrailingSpace(
326 text: string,
327 rangeEnd: number,
328 replacement: string,
329): number {
330 if (!replacement.endsWith(" ")) {
331 return rangeEnd;
332 }
333 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
335​
336export { replaceTextRange } from "@t3tools/shared/composerTrigger";