1import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
2import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts";
3import {
4 collectAssistantCitations,
5 expandAssistantCitationsForProvider,
6 serializeAssistantCitation,
7} from "@t3tools/shared/assistantCitations";
8import {
9 DEFAULT_RESOLVED_KEYBINDINGS,
10 compileResolvedKeybindingsConfig,
11 mergeWithDefaultKeybindings,
12} from "@t3tools/shared/keybindings";
13import { describe, expect, it } from "vite-plus/test";
14β
15import {
16 clampCollapsedComposerCursor,
17 collapseExpandedComposerCursor,
18 composerSubmissionIntentForKey,
19 composerStateAtPromptEnd,
20 detectComposerTrigger,
21 expandCollapsedComposerCursor,
22 extendReplacementRangeForTrailingSpace,
23 formatAssistantCitationForComposer,
24 isPathSuggestionCurrent,
25 replaceTextRange,
26} from "./composer-logic";
27import { slashCommandItemsForPromptPosition } from "./components/chat/composerSlashCommandSearch";
28import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput";
29import { formatTerminalContextReference } from "./lib/terminalContext";
30β
31const terminalReference = formatTerminalContextReference({
32 id: "ctx-1",
33 terminalLabel: "Terminal 1",
34 lineStart: 3,
35 lineEnd: 4,
36});
37β
38const citation: AssistantCitation = {
39 version: 1,
40 environmentId: EnvironmentId.make("environment-1"),
41 threadId: ThreadId.make("thread-1"),
42 messageId: MessageId.make("message-1"),
43 text: "Keep Unicode π and punctuation (here).",
44 start: 3,
45 end: 40,
46 prefix: "ε: ",
47 suffix: " εΎ",
48};
49const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20");
50β
51describe("isPathSuggestionCurrent", () => {
52 const searchedTarget = {
53 environmentId: EnvironmentId.make("local"),
54 cwd: "/repo",
55 query: "alpha",
56 };
57β
58 it("allows a file suggestion matching the live search", () => {
59 expect(
60 isPathSuggestionCurrent(
61 searchedTarget,
62 detectComposerTrigger("@alpha", 6),
63 searchedTarget.environmentId,
64 searchedTarget.cwd,
65 ),
66 ).toBe(true);
67 });
68β
69 it.each([
70 ["query", { ...searchedTarget, query: "beta" }],
71 ["cwd", { ...searchedTarget, cwd: "/other-repo" }],
72 ["environment", { ...searchedTarget, environmentId: EnvironmentId.make("remote") }],
73 ] as const)("rejects a visible file suggestion after the live %s changes", (_change, current) => {
74 const prompt = `@${current.query}`;
75 expect(
76 isPathSuggestionCurrent(
77 searchedTarget,
78 detectComposerTrigger(prompt, prompt.length),
79 current.environmentId,
80 current.cwd,
81 ),
82 ).toBe(false);
83 });
84});
85β
86describe("formatAssistantCitationForComposer", () => {
87 it("binds a multiline comment to its citation without adding standalone prompt text", () => {
88 const comment = 'What does "keep" mean? π\n Please show an example.';
89 const text = formatAssistantCitationForComposer(citation, ` ${comment}\n`);
90 const boundCitation = { ...citation, comment };
91 expect(text).toBe(`${serializeAssistantCitation(boundCitation)} `);
92 expect(collectAssistantCitations(text).map((entry) => entry.citation)).toEqual([boundCitation]);
93 expect(expandAssistantCitationsForProvider(text)).toMatch(/^\[assistant-quote-1\] \n\n/);
94 });
95});
96β
97describe("composerSubmissionIntentForKey", () => {
98 const input = {
99 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
100 platform: "Linux",
101 isSoftwareKeyboardOpen: false,
102 isDraftThread: false,
103 };
104 const enter = { key: "Enter", metaKey: false, ctrlKey: false, altKey: false, shiftKey: false };
105β
106 it.each([
107 ["enter", "one line", false, "foreground"],
108 ["enter", "two\nlines", false, "foreground"],
109 ["mod-enter-multiline", "one line", false, "foreground"],
110 ["mod-enter-multiline", "two\nlines", false, null],
111 ["mod-enter-multiline", "two\nlines", true, "foreground"],
112 ["mod-enter", "one line", false, null],
113 ["mod-enter", "one line", true, "foreground"],
114 ] as const)("honors %s for %j", (sendShortcut, prompt, ctrlKey, expected) => {
115 expect(
116 composerSubmissionIntentForKey({
117 ...input,
118 event: { ...enter, ctrlKey },
119 sendShortcut,
120 prompt,
121 }),
122 ).toBe(expected);
123 });
124β
125 it.each([
126 ["mod-enter", "one line"],
127 ["mod-enter-multiline", "two\nlines"],
128 ] as const)(
129 "starts a new thread in the foreground on Mod+Enter with %s",
130 (sendShortcut, prompt) => {
131 const draft = { ...input, isDraftThread: true, sendShortcut, prompt };
132 expect(composerSubmissionIntentForKey({ ...draft, event: { ...enter, ctrlKey: true } })).toBe(
133 "foreground",
134 );
135 expect(
136 composerSubmissionIntentForKey({
137 ...draft,
138 event: { ...enter, ctrlKey: true, altKey: true },
139 }),
140 ).toBe("background");
141 },
142 );
143β
144 it("honors the send preference with an existing install's saved alternate binding", () => {
145 const keybindings = mergeWithDefaultKeybindings(
146 compileResolvedKeybindingsConfig([
147 {
148 key: "mod+enter",
149 command: "composer.sendAlternate",
150 when: "composerFocus && turnRunning",
151 },
152 ]),
153 );
154 const running = { ...input, keybindings, isRunning: true, sendShortcut: "mod-enter" as const };
155 expect(composerSubmissionIntentForKey({ ...running, event: { ...enter, ctrlKey: true } })).toBe(
156 "foreground",
157 );
158 expect(
159 composerSubmissionIntentForKey({
160 ...running,
161 event: { ...enter, ctrlKey: true, shiftKey: true },
162 }),
163 ).toBe("alternate");
164 });
165β
166 it.each(["enter", "mod-enter", "mod-enter-multiline"] as const)(
167 "uses remapped keys and removes the old action bindings with %s",
168 (sendShortcut) => {
169 const keybindings = mergeWithDefaultKeybindings(
170 compileResolvedKeybindingsConfig([
171 {
172 key: "alt+q",
173 command: "composer.sendAlternate",
174 when: "composerFocus && turnRunning",
175 },
176 {
177 key: "alt+b",
178 command: "composer.sendBackground",
179 when: "composerFocus && draftThreadRoute",
180 },
181 ]),
182 );
183 const custom = { ...input, keybindings, sendShortcut, prompt: "two\nlines" };
184 expect(
185 composerSubmissionIntentForKey({
186 ...custom,
187 isRunning: true,
188 event: { ...enter, key: "q", altKey: true },
189 }),
190 ).toBe("alternate");
191 expect(
192 composerSubmissionIntentForKey({
193 ...custom,
194 isDraftThread: true,
195 event: { ...enter, key: "b", altKey: true },
196 }),
197 ).toBe("background");
198 expect(
199 composerSubmissionIntentForKey({
200 ...custom,
201 isRunning: true,
202 event: { ...enter, ctrlKey: true },
203 }),
204 ).toBe("foreground");
205 expect(
206 composerSubmissionIntentForKey({
207 ...custom,
208 isDraftThread: true,
209 event: { ...enter, ctrlKey: true, shiftKey: true },
210 }),
211 ).toBeNull();
212 expect(
213 composerSubmissionIntentForKey({
214 ...custom,
215 isRunning: true,
216 event: { ...enter, ctrlKey: true, shiftKey: true },
217 }),
218 ).toBeNull();
219 expect(
220 composerSubmissionIntentForKey({ ...custom, event: { ...enter, key: "b", altKey: true } }),
221 ).toBeNull();
222 },
223 );
224β
225 it.each(["MacIntel", "Win32", "Linux"])(
226 "keeps modifier submission available with the software keyboard open on %s",
227 (platform) => {
228 const modEnter = {
229 ...enter,
230 metaKey: platform === "MacIntel",
231 ctrlKey: platform !== "MacIntel",
232 };
233 const keyboardOpen = { ...input, platform, isSoftwareKeyboardOpen: true };
234 expect(composerSubmissionIntentForKey({ ...keyboardOpen, event: modEnter })).toBe(
235 "foreground",
236 );
237 expect(
238 composerSubmissionIntentForKey({ ...keyboardOpen, isRunning: true, event: modEnter }),
239 ).toBe("alternate");
240 expect(
241 composerSubmissionIntentForKey({ ...keyboardOpen, isDraftThread: true, event: modEnter }),
242 ).toBe("background");
243 },
244 );
245β
246 it.each([
247 { isSoftwareKeyboardOpen: true },
248 { event: { ...enter, isComposing: true } },
249 { event: { ...enter, keyCode: 229 } },
250 { event: { ...enter, repeat: true } },
251 ])("does not submit with %j", (override) => {
252 expect(composerSubmissionIntentForKey({ ...input, event: enter, ...override })).toBeNull();
253 });
254});
255β
256describe("detectComposerTrigger", () => {
257 it.each([
258 ["Run /tmp/build.sh", "Run /tmp/build.sh".length],
259 ["/etc/hosts", "/etc/hosts".length],
260 ["Use /etc/hosts now", "Use /et".length],
261 ])("keeps the path in %j out of the slash menu", (text, cursor) => {
262 expect(detectComposerTrigger(text, cursor)).toBeNull();
263 });
264β
265 it.each(["Use ", "Use\t", "Use\n "])("detects inline slash skills after %j", (prefix) => {
266 const text = `${prefix}/skill:review later`;
267 const cursor = `${prefix}/skill:review`.length;
268 expect(detectComposerTrigger(text, cursor)).toEqual({
269 kind: "slash-command",
270 query: "skill:review",
271 rangeStart: prefix.length,
272 rangeEnd: cursor,
273 });
274 });
275β
276 it.each(["Use/review", "https://example.com/review", "Use /review "])(
277 "does not trigger for %j",
278 (text) => expect(detectComposerTrigger(text, text.length)).toBeNull(),
279 );
280β
281 it("does not keep a subcommand trigger active after /model arguments", () => {
282 const text = "/model spark";
283 const trigger = detectComposerTrigger(text, text.length);
284β
285 expect(trigger).toBeNull();
286 });
287β
288 it.each([
289 "It costs $5",
290 "budget $9.99",
291 "only $.99",
292 "about β¬20",
293 "raise $10k",
294 "raise $10k.",
295 "is it $5?",
296 ])("does not detect a skill trigger for the amount in %j", (text) => {
297 expect(detectComposerTrigger(text, text.length)).toBeNull();
298 });
299β
300 it("detects a pull request number at a token boundary", () => {
301 const text = "Compare this with #8737";
302β
303 expect(detectComposerTrigger(text, text.length)).toEqual({
304 kind: "pull-request",
305 query: "8737",
306 rangeStart: "Compare this with ".length,
307 rangeEnd: text.length,
308 });
309 });
310β
311 it("does not keep pull request completion active for headings or embedded hashes", () => {
312 expect(detectComposerTrigger("# Heading", "# Heading".length)).toBeNull();
313 expect(detectComposerTrigger("issue#123", "issue#123".length)).toBeNull();
314 });
315β
316 // regression: #936 @file autocomplete used the wrong cursor when typing mid-text
317 it("detects @path trigger in the middle of existing text", () => {
318 const text = "Please inspect @in this sentence";
319 const cursorAfterAt = "Please inspect @".length;
320β
321 const trigger = detectComposerTrigger(text, cursorAfterAt);
322 expect(trigger).toEqual({
323 kind: "path",
324 query: "",
325 rangeStart: "Please inspect ".length,
326 rangeEnd: cursorAfterAt,
327 });
328 });
329});
330β
331describe("filterComposerPullRequestMatches", () => {
332 const entries = [
333 {
334 number: 7,
335 projectId: "project-1",
336 repository: "t3tools/t3code",
337 updatedAt: "2026-09-01T12:00:00.000Z",
338 },
339 {
340 number: 8987,
341 projectId: "project-1",
342 repository: "T3Tools/T3Code",
343 updatedAt: "2026-09-03T12:00:00.000Z",
344 },
345 {
346 number: 27,
347 projectId: "project-1",
348 repository: "t3tools/t3code",
349 updatedAt: "2026-09-02T12:00:00.000Z",
350 },
351 {
352 number: 27,
353 projectId: "project-1",
354 repository: "t3tools/t3code",
355 updatedAt: "2026-09-01T13:00:00.000Z",
356 },
357 {
358 number: 70,
359 projectId: "project-2",
360 repository: "t3tools/other",
361 updatedAt: "2026-09-04T12:00:00.000Z",
362 },
363 ];
364β
365 it("matches and de-duplicates number fragments within one repository, exact match first", () => {
366 expect(
367 filterComposerPullRequestMatches({
368 entries,
369 projectId: "project-1",
370 repository: "t3tools/t3code",
371 query: "7",
372 limit: 10,
373 }).map((entry) => entry.number),
374 ).toEqual([7, 8987, 27]);
375 });
376β
377 it("keeps an older exact match when newer substring matches would fill the limit", () => {
378 const exact = {
379 number: 7,
380 projectId: "project-1",
381 repository: "t3tools/t3code",
382 updatedAt: "2020-01-01T00:00:00.000Z",
383 };
384 const newerSubstringMatches = Array.from({ length: 12 }, (_unused, index) => ({
385 number: 700 + index,
386 projectId: "project-1",
387 repository: "t3tools/t3code",
388 updatedAt: `2026-09-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`,
389 }));
390 const matches = filterComposerPullRequestMatches({
391 entries: [...newerSubstringMatches, exact],
392 projectId: "project-1",
393 repository: "t3tools/t3code",
394 query: "7",
395 limit: 10,
396 });
397 expect(matches[0]?.number).toBe(7);
398 expect(matches).toHaveLength(10);
399 });
400});
401β
402describe("expandCollapsedComposerCursor", () => {
403 it("maps collapsed mention cursor to expanded text cursor", () => {
404 const text = "what's in my @AGENTS.md fsfdas";
405 const collapsedCursorAfterMention = "what's in my ".length + 2;
406 const expandedCursorAfterMention = "what's in my @AGENTS.md ".length;
407β
408 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
409 expandedCursorAfterMention,
410 );
411 });
412β
413 // regression: #2625 cursor mapping broke for quoted mentions containing spaces
414 it("maps collapsed quoted mention cursor to expanded text cursor", () => {
415 const text = 'what is in @"My File.md" please';
416 const collapsedCursorAfterMention = "what is in ".length + 2;
417 const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
418β
419 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
420 expandedCursorAfterMention,
421 );
422 });
423β
424 it("maps collapsed markdown file links to their expanded source offsets", () => {
425 const text = "what's in [AGENTS.md](AGENTS.md) please";
426 const collapsedCursorAfterMention = "what's in ".length + 2;
427 const expandedCursorAfterMention = "what's in [AGENTS.md](AGENTS.md) ".length;
428β
429 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
430 expandedCursorAfterMention,
431 );
432 });
433β
434 it("allows path trigger detection to close after selecting a mention", () => {
435 const text = "what's in my @AGENTS.md ";
436 const collapsedCursorAfterMention = "what's in my ".length + 2;
437 const expandedCursor = expandCollapsedComposerCursor(text, collapsedCursorAfterMention);
438β
439 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
440 });
441});
442β
443describe("composerStateAtPromptEnd", () => {
444 it("collapses mention chips so the next keystroke lands after the draft", () => {
445 const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please");
446β
447 expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length);
448 expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0);
449 expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length);
450 });
451β
452 it("keeps a trailing mention trigger when the restored draft ends with @", () => {
453 const prompt = "look at @";
454β
455 expect(composerStateAtPromptEnd(prompt)).toEqual({
456 cursor: prompt.length,
457 trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length },
458 });
459 });
460});
461β
462describe("collapseExpandedComposerCursor", () => {
463 it("keeps package-like text expanded when another mention already exists earlier", () => {
464 const text = "open @AGENTS.md then @src/index.ts ";
465 const expandedCursor = text.length;
466 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);
467β
468 expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length);
469 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
470 });
471});
472β
473it("preserves the caret before trailing text after mixed-width skill chips", () => {
474 const text = "πΏui a $review tail";
475 const expanded = "πΏui a $review ".length;
476 expect(collapseExpandedComposerCursor(text, expanded)).toBe(6);
477 expect(expandCollapsedComposerCursor(text, 6)).toBe(expanded);
478});
479β
480describe("assistant citation cursor offsets", () => {
481 it("roundtrips every collapsed offset across citations, mentions, skills, and Unicode", () => {
482 const prefix = "π(";
483 const between = "),ιͺ";
484 const after = " @AGENTS.md $review ";
485 const text = `${prefix}${citationSource}${between}${citationSource}${after}${terminalReference}!`;
486 const collapsedLength = `${prefix}β‘${between}β‘ β‘ β‘ β‘!`.length;
487 const boundaries = [
488 [prefix.length, prefix.length],
489 [prefix.length + 1, prefix.length + citationSource.length],
490 [prefix.length + 1 + between.length, prefix.length + citationSource.length + between.length],
491 [
492 prefix.length + 2 + between.length,
493 prefix.length + citationSource.length * 2 + between.length,
494 ],
495 [collapsedLength, text.length],
496 ] as const;
497β
498 for (const [collapsed, expanded] of boundaries) {
499 expect(expandCollapsedComposerCursor(text, collapsed)).toBe(expanded);
500 expect(collapseExpandedComposerCursor(text, expanded)).toBe(collapsed);
501 }
502 for (let cursor = 0; cursor <= collapsedLength; cursor += 1) {
503 expect(
504 collapseExpandedComposerCursor(text, expandCollapsedComposerCursor(text, cursor)),
505 ).toBe(cursor);
506 }
507 expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe(collapsedLength);
508 });
509β
510 it("snaps serialized offsets inside a citation to the end of its chip", () => {
511 const prefix = "επ";
512 const text = `${prefix}${citationSource}.`;
513β
514 for (const offset of [1, citationSource.length - 1, citationSource.length]) {
515 expect(collapseExpandedComposerCursor(text, prefix.length + offset)).toBe(prefix.length + 1);
516 }
517 });
518β
519 it("deletes one collapsed citation without changing its neighbor or surrounding punctuation", () => {
520 const text = `(${citationSource}),${citationSource}!`;
521 const result = replaceTextRange(
522 text,
523 expandCollapsedComposerCursor(text, 1),
524 expandCollapsedComposerCursor(text, 2),
525 "",
526 );
527β
528 expect(result).toEqual({ text: `(),${citationSource}!`, cursor: 1 });
529 expect(collapseExpandedComposerCursor(result.text, result.text.length)).toBe("(),β‘!".length);
530 });
531});
532β
533describe("literal editor answer cursor state", () => {
534 const prefill = "- [X] done $my-skill ";
535β
536 it("keeps character positions after token-like text for inserted lines and replacements", () => {
537 for (const replacement of ["\n", "\t", "edited\n "]) {
538 const next = replaceTextRange(prefill, prefill.length, prefill.length, replacement);
539 const cursor = collapseExpandedComposerCursor(next.text, next.cursor, true);
540 expect(next.text).toBe(prefill + replacement);
541 expect(cursor).toBe(next.text.length);
542 expect(expandCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor);
543 expect(clampCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor);
544 }
545 const replaced = replaceTextRange(prefill, 11, 20, "$another-skill");
546 const cursor = collapseExpandedComposerCursor(replaced.text, replaced.cursor, true);
547 expect(replaced.text).toBe("- [X] done $another-skill ");
548 expect(cursor).toBe(25);
549 expect(expandCollapsedComposerCursor(replaced.text, cursor, true)).toBe(25);
550 });
551β
552 it("does not open a skill or path menu when restoring an editor answer", () => {
553 for (const value of ["- [X] done $my-skill", "Use @README", ""]) {
554 expect(composerStateAtPromptEnd(value, true)).toEqual({
555 cursor: value.length,
556 trigger: null,
557 });
558 }
559 expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill");
560 });
561});
562β
563describe("extendReplacementRangeForTrailingSpace", () => {
564 it("swallows one existing space after the range when the replacement ends with one", () => {
565 expect(extendReplacementRangeForTrailingSpace("@fi rest", 3, "@file ")).toBe(4);
566 expect(extendReplacementRangeForTrailingSpace("@firest", 3, "@file ")).toBe(3);
567 expect(extendReplacementRangeForTrailingSpace("@fi rest", 3, "@file")).toBe(3);
568 expect(extendReplacementRangeForTrailingSpace("@fi", 3, "@file ")).toBe(3);
569 });
570});
571β
572describe("side chat slash commands", () => {
573 it("are not offered after the start of a message", () => {
574 const items = (["side", "btw", "clear", "model"] as const).map((command) => ({
575 id: `slash:${command}`,
576 type: "slash-command" as const,
577 command,
578 label: `/${command}`,
579 description: "",
580 }));
581 expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([
582 "slash:model",
583 ]);
584 });
585});