1import { resolveComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch";
2import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
3import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts";
4import {
5 collectAssistantCitations,
6 expandAssistantCitationsForProvider,
7 serializeAssistantCitation,
8} from "@t3tools/shared/assistantCitations";
9import {
10 DEFAULT_RESOLVED_KEYBINDINGS,
11 compileResolvedKeybindingsConfig,
12 mergeWithDefaultKeybindings,
13} from "@t3tools/shared/keybindings";
14import { describe, expect, it } from "vite-plus/test";
15
16import {
17 clampCollapsedComposerCursor,
18 collapseExpandedComposerCursor,
19 composerSubmissionIntentForKey,
20 composerStateAtPromptEnd,
21 detectComposerTrigger,
22 expandCollapsedComposerCursor,
23 formatAssistantCitationForComposer,
24 isCollapsedCursorAdjacentToInlineToken,
25 parseStandaloneComposerSlashCommand,
26 replaceTextRange,
27} from "./composer-logic";
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("formatAssistantCitationForComposer", () => {
52 it.each([undefined, "", " \n\t "])(
53 "keeps citation-only insertion for a blank comment %j",
54 (comment) => {
55 expect(formatAssistantCitationForComposer(citation, comment)).toBe(
56 `${serializeAssistantCitation(citation)} `,
57 );
58 },
59 );
60
61 it("binds a multiline comment to its citation without adding standalone prompt text", () => {
62 const comment = 'What does "keep" mean? πŸ‘‹\n Please show an example.';
63 const text = formatAssistantCitationForComposer(citation, ` ${comment}\n`);
64 const boundCitation = { ...citation, comment };
65 expect(text).toBe(`${serializeAssistantCitation(boundCitation)} `);
66 expect(collectAssistantCitations(text).map((entry) => entry.citation)).toEqual([boundCitation]);
67 expect(expandAssistantCitationsForProvider(text)).toMatch(/^\[assistant-quote-1\] \n\n/);
68 });
69});
70
71describe("composerSubmissionIntentForKey", () => {
72 const input = {
73 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
74 platform: "Linux",
75 isMobileViewport: false,
76 isDraftThread: false,
77 };
78 const enter = { key: "Enter", metaKey: false, ctrlKey: false, altKey: false, shiftKey: false };
79
80 it.each([
81 ["enter", "one line", false, "foreground"],
82 ["enter", "two\nlines", false, "foreground"],
83 ["mod-enter-multiline", "one line", false, "foreground"],
84 ["mod-enter-multiline", "two\nlines", false, null],
85 ["mod-enter-multiline", "two\nlines", true, "foreground"],
86 ["mod-enter", "one line", false, null],
87 ["mod-enter", "one line", true, "foreground"],
88 ] as const)("honors %s for %j", (sendShortcut, prompt, ctrlKey, expected) => {
89 expect(
90 composerSubmissionIntentForKey({
91 ...input,
92 event: { ...enter, ctrlKey },
93 sendShortcut,
94 prompt,
95 }),
96 ).toBe(expected);
97 });
98
99 it.each(["MacIntel", "Win32", "Linux"])("uses the configured actions on %s", (platform) => {
100 const modEnter = {
101 ...enter,
102 metaKey: platform === "MacIntel",
103 ctrlKey: platform !== "MacIntel",
104 };
105 for (const sendShortcut of ["enter", "mod-enter", "mod-enter-multiline"] as const) {
106 const running = { ...input, platform, sendShortcut, prompt: "two\nlines", isRunning: true };
107 const intent = composerSubmissionIntentForKey({ ...running, event: modEnter });
108 expect(intent).toBe("alternate");
109 for (const activeTurnDefault of ["queue", "steer"] as const) {
110 expect(
111 resolveComposerDispatchMode({
112 running: true,
113 alternateModifier: intent === "alternate",
114 activeTurnDefault,
115 }),
116 ).toBe(activeTurnDefault === "queue" ? "steer" : "queue");
117 }
118 expect(
119 composerSubmissionIntentForKey({
120 ...input,
121 platform,
122 sendShortcut,
123 isDraftThread: true,
124 event: { ...modEnter, altKey: true },
125 }),
126 ).toBe("background");
127 expect(
128 composerSubmissionIntentForKey({
129 ...input,
130 platform,
131 sendShortcut,
132 event: { ...modEnter, altKey: true },
133 }),
134 ).toBe("background");
135 expect(
136 composerSubmissionIntentForKey({
137 ...input,
138 platform,
139 sendShortcut,
140 isDraftThread: true,
141 event: modEnter,
142 }),
143 ).toBe("background");
144 expect(
145 composerSubmissionIntentForKey({ ...running, event: { ...enter, shiftKey: true } }),
146 ).toBeNull();
147 }
148 });
149
150 it("leaves queued-message steering on Mod+Shift+Enter outside a draft", () => {
151 expect(
152 composerSubmissionIntentForKey({
153 ...input,
154 isRunning: true,
155 event: { ...enter, ctrlKey: true, shiftKey: true },
156 }),
157 ).toBeNull();
158 });
159
160 it("does not start a background thread with the queued-message shortcut", () => {
161 expect(
162 composerSubmissionIntentForKey({
163 ...input,
164 isDraftThread: true,
165 event: { ...enter, ctrlKey: true, shiftKey: true },
166 }),
167 ).toBeNull();
168 });
169
170 it.each([
171 ["mod+arrowup", { key: "ArrowUp", ctrlKey: true }],
172 ["shift+tab", { key: "Tab", shiftKey: true }],
173 ] as const)("accepts a remapped %s action", (key, event) => {
174 const keybindings = mergeWithDefaultKeybindings(
175 compileResolvedKeybindingsConfig([
176 { key, command: "composer.sendBackground", when: "composerFocus && draftThreadRoute" },
177 ]),
178 );
179 expect(
180 composerSubmissionIntentForKey({
181 ...input,
182 keybindings,
183 isDraftThread: true,
184 event: { ...enter, ...event },
185 }),
186 ).toBe("background");
187 });
188
189 it("uses remapped keys and removes the old action bindings", () => {
190 const keybindings = mergeWithDefaultKeybindings(
191 compileResolvedKeybindingsConfig([
192 {
193 key: "alt+q",
194 command: "composer.sendAlternate",
195 when: "composerFocus && turnRunning",
196 },
197 {
198 key: "alt+b",
199 command: "composer.sendBackground",
200 when: "composerFocus && draftThreadRoute",
201 },
202 ]),
203 );
204 const custom = { ...input, keybindings };
205 expect(
206 composerSubmissionIntentForKey({
207 ...custom,
208 isRunning: true,
209 event: { ...enter, key: "q", altKey: true },
210 }),
211 ).toBe("alternate");
212 expect(
213 composerSubmissionIntentForKey({
214 ...custom,
215 isDraftThread: true,
216 event: { ...enter, key: "b", altKey: true },
217 }),
218 ).toBe("background");
219 expect(
220 composerSubmissionIntentForKey({
221 ...custom,
222 isRunning: true,
223 event: { ...enter, ctrlKey: true },
224 }),
225 ).toBe("foreground");
226 expect(
227 composerSubmissionIntentForKey({
228 ...custom,
229 isDraftThread: true,
230 event: { ...enter, ctrlKey: true, shiftKey: true },
231 }),
232 ).toBeNull();
233 expect(
234 composerSubmissionIntentForKey({ ...custom, event: { ...enter, key: "b", altKey: true } }),
235 ).toBeNull();
236 });
237
238 it.each([
239 { isMobileViewport: true },
240 { event: { ...enter, isComposing: true } },
241 { event: { ...enter, keyCode: 229 } },
242 { event: { ...enter, repeat: true } },
243 ])("does not submit with %j", (override) => {
244 expect(composerSubmissionIntentForKey({ ...input, event: enter, ...override })).toBeNull();
245 });
246});
247
248describe("detectComposerTrigger", () => {
249 it.each(["Use ", "Use\t", "Use\n "])("detects inline slash skills after %j", (prefix) => {
250 const text = `${prefix}/skill:review later`;
251 const cursor = `${prefix}/skill:review`.length;
252 expect(detectComposerTrigger(text, cursor)).toEqual({
253 kind: "slash-command",
254 query: "skill:review",
255 rangeStart: prefix.length,
256 rangeEnd: cursor,
257 });
258 });
259
260 it.each(["Use /", "Use /review"])("detects the inline slash token in %j", (text) => {
261 expect(detectComposerTrigger(text, text.length)).toEqual({
262 kind: "slash-command",
263 query: text.slice(5),
264 rangeStart: 4,
265 rangeEnd: text.length,
266 });
267 });
268
269 it.each(["Use/review", "https://example.com/review", "Use /review "])(
270 "does not trigger for %j",
271 (text) => expect(detectComposerTrigger(text, text.length)).toBeNull(),
272 );
273
274 it("detects @path trigger at cursor", () => {
275 const text = "Please check @src/com";
276 const trigger = detectComposerTrigger(text, text.length);
277
278 expect(trigger).toEqual({
279 kind: "path",
280 query: "src/com",
281 rangeStart: "Please check ".length,
282 rangeEnd: text.length,
283 });
284 });
285
286 it("detects slash command token while typing command name", () => {
287 const text = "/mo";
288 const trigger = detectComposerTrigger(text, text.length);
289
290 expect(trigger).toEqual({
291 kind: "slash-command",
292 query: "mo",
293 rangeStart: 0,
294 rangeEnd: text.length,
295 });
296 });
297
298 it("keeps /model as a slash command item", () => {
299 const text = "/model";
300 const trigger = detectComposerTrigger(text, text.length);
301
302 expect(trigger).toEqual({
303 kind: "slash-command",
304 query: "model",
305 rangeStart: 0,
306 rangeEnd: text.length,
307 });
308 });
309
310 it("does not keep a subcommand trigger active after /model arguments", () => {
311 const text = "/model spark";
312 const trigger = detectComposerTrigger(text, text.length);
313
314 expect(trigger).toBeNull();
315 });
316
317 it("detects non-model slash commands while typing", () => {
318 const text = "/pl";
319 const trigger = detectComposerTrigger(text, text.length);
320
321 expect(trigger).toEqual({
322 kind: "slash-command",
323 query: "pl",
324 rangeStart: 0,
325 rangeEnd: text.length,
326 });
327 });
328
329 it("keeps slash command detection active for provider commands", () => {
330 const text = "/rev";
331 const trigger = detectComposerTrigger(text, text.length);
332
333 expect(trigger).toEqual({
334 kind: "slash-command",
335 query: "rev",
336 rangeStart: 0,
337 rangeEnd: text.length,
338 });
339 });
340
341 it.each(["$", "€", "£", "¥", "β‚Ή", "β‚©", "β‚Ώ", "𑿝"])(
342 "detects %sskill trigger at cursor",
343 (prefix) => {
344 const text = `Use ${prefix}gh-fi`;
345 const trigger = detectComposerTrigger(text, text.length);
346
347 expect(trigger).toEqual({
348 kind: "skill",
349 query: "gh-fi",
350 rangeStart: "Use ".length,
351 rangeEnd: text.length,
352 });
353 },
354 );
355
356 it("detects a pull request number at a token boundary", () => {
357 const text = "Compare this with #8737";
358
359 expect(detectComposerTrigger(text, text.length)).toEqual({
360 kind: "pull-request",
361 query: "8737",
362 rangeStart: "Compare this with ".length,
363 rangeEnd: text.length,
364 });
365 });
366
367 it("opens pull request completion from a bare hash", () => {
368 const text = "Compare with #";
369
370 expect(detectComposerTrigger(text, text.length)).toEqual({
371 kind: "pull-request",
372 query: "",
373 rangeStart: "Compare with ".length,
374 rangeEnd: text.length,
375 });
376 });
377
378 it("detects a one-word pull request search", () => {
379 const text = "Compare with #composer";
380
381 expect(detectComposerTrigger(text, text.length)).toEqual({
382 kind: "pull-request",
383 query: "composer",
384 rangeStart: "Compare with ".length,
385 rangeEnd: text.length,
386 });
387 });
388
389 it("supports hyphenated pull request search terms", () => {
390 const text = "Find #inline-context";
391
392 expect(detectComposerTrigger(text, text.length)).toEqual({
393 kind: "pull-request",
394 query: "inline-context",
395 rangeStart: "Find ".length,
396 rangeEnd: text.length,
397 });
398 });
399
400 it("does not keep pull request completion active for headings or embedded hashes", () => {
401 expect(detectComposerTrigger("# Heading", "# Heading".length)).toBeNull();
402 expect(detectComposerTrigger("issue#123", "issue#123".length)).toBeNull();
403 });
404
405 it("detects @path trigger in the middle of existing text", () => {
406 // User typed @ between "inspect " and "in this sentence"
407 const text = "Please inspect @in this sentence";
408 const cursorAfterAt = "Please inspect @".length;
409
410 const trigger = detectComposerTrigger(text, cursorAfterAt);
411 expect(trigger).toEqual({
412 kind: "path",
413 query: "",
414 rangeStart: "Please inspect ".length,
415 rangeEnd: cursorAfterAt,
416 });
417 });
418
419 it("detects @path trigger with query typed mid-text", () => {
420 // User typed @sr between "inspect " and "in this sentence"
421 const text = "Please inspect @srin this sentence";
422 const cursorAfterQuery = "Please inspect @sr".length;
423
424 const trigger = detectComposerTrigger(text, cursorAfterQuery);
425 expect(trigger).toEqual({
426 kind: "path",
427 query: "sr",
428 rangeStart: "Please inspect ".length,
429 rangeEnd: cursorAfterQuery,
430 });
431 });
432
433 it("detects trigger with true cursor even when regex-based mention detection would false-match", () => {
434 // MENTION_TOKEN_REGEX can false-match plain text like "@in" as a mention.
435 // The fix bypasses it by computing the expanded cursor from the Lexical node tree.
436 const text = "Please inspect @in this sentence";
437 const cursorAfterAt = "Please inspect @".length;
438
439 const trigger = detectComposerTrigger(text, cursorAfterAt);
440 expect(trigger).not.toBeNull();
441 expect(trigger?.kind).toBe("path");
442 expect(trigger?.query).toBe("");
443 });
444});
445
446describe("filterComposerPullRequestMatches", () => {
447 const entries = [
448 {
449 number: 7,
450 projectId: "project-1",
451 repository: "t3tools/t3code",
452 updatedAt: "2026-09-01T12:00:00.000Z",
453 },
454 {
455 number: 8987,
456 projectId: "project-1",
457 repository: "T3Tools/T3Code",
458 updatedAt: "2026-09-03T12:00:00.000Z",
459 },
460 {
461 number: 27,
462 projectId: "project-1",
463 repository: "t3tools/t3code",
464 updatedAt: "2026-09-02T12:00:00.000Z",
465 },
466 {
467 number: 27,
468 projectId: "project-1",
469 repository: "t3tools/t3code",
470 updatedAt: "2026-09-01T13:00:00.000Z",
471 },
472 {
473 number: 70,
474 projectId: "project-2",
475 repository: "t3tools/other",
476 updatedAt: "2026-09-04T12:00:00.000Z",
477 },
478 ];
479
480 it("matches and de-duplicates number fragments within one repository, exact match first", () => {
481 expect(
482 filterComposerPullRequestMatches({
483 entries,
484 projectId: "project-1",
485 repository: "t3tools/t3code",
486 query: "7",
487 limit: 10,
488 }).map((entry) => entry.number),
489 ).toEqual([7, 8987, 27]);
490 });
491
492 it("keeps an older exact match when newer substring matches would fill the limit", () => {
493 const exact = {
494 number: 7,
495 projectId: "project-1",
496 repository: "t3tools/t3code",
497 updatedAt: "2020-01-01T00:00:00.000Z",
498 };
499 const newerSubstringMatches = Array.from({ length: 12 }, (_unused, index) => ({
500 number: 700 + index,
501 projectId: "project-1",
502 repository: "t3tools/t3code",
503 updatedAt: `2026-09-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`,
504 }));
505 const matches = filterComposerPullRequestMatches({
506 entries: [...newerSubstringMatches, exact],
507 projectId: "project-1",
508 repository: "t3tools/t3code",
509 query: "7",
510 limit: 10,
511 });
512 expect(matches[0]?.number).toBe(7);
513 expect(matches).toHaveLength(10);
514 });
515
516 it("uses an empty query for a capped recent list", () => {
517 expect(
518 filterComposerPullRequestMatches({
519 entries,
520 projectId: "project-1",
521 repository: "t3tools/t3code",
522 query: "",
523 limit: 2,
524 }).map((entry) => entry.number),
525 ).toEqual([8987, 27]);
526 });
527});
528
529describe("replaceTextRange", () => {
530 it("replaces a text range and returns new cursor", () => {
531 const replaced = replaceTextRange("hello @src", 6, 10, "");
532 expect(replaced).toEqual({
533 text: "hello ",
534 cursor: 6,
535 });
536 });
537});
538
539describe("expandCollapsedComposerCursor", () => {
540 it("keeps cursor unchanged when no mention segment is present", () => {
541 expect(expandCollapsedComposerCursor("plain text", 5)).toBe(5);
542 });
543
544 it("maps collapsed mention cursor to expanded text cursor", () => {
545 const text = "what's in my @AGENTS.md fsfdas";
546 const collapsedCursorAfterMention = "what's in my ".length + 2;
547 const expandedCursorAfterMention = "what's in my @AGENTS.md ".length;
548
549 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
550 expandedCursorAfterMention,
551 );
552 });
553
554 it("maps collapsed quoted mention cursor to expanded text cursor", () => {
555 const text = 'what is in @"My File.md" please';
556 const collapsedCursorAfterMention = "what is in ".length + 2;
557 const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
558
559 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
560 expandedCursorAfterMention,
561 );
562 });
563
564 it("maps collapsed markdown file links to their expanded source offsets", () => {
565 const text = "what's in [AGENTS.md](AGENTS.md) please";
566 const collapsedCursorAfterMention = "what's in ".length + 2;
567 const expandedCursorAfterMention = "what's in [AGENTS.md](AGENTS.md) ".length;
568
569 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
570 expandedCursorAfterMention,
571 );
572 });
573
574 it("allows path trigger detection to close after selecting a mention", () => {
575 const text = "what's in my @AGENTS.md ";
576 const collapsedCursorAfterMention = "what's in my ".length + 2;
577 const expandedCursor = expandCollapsedComposerCursor(text, collapsedCursorAfterMention);
578
579 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
580 });
581
582 it.each(["$", "€", "𑿝"])("maps collapsed %s skill cursor to expanded text cursor", (prefix) => {
583 const text = `run ${prefix}review-follow-up then`;
584 const collapsedCursorAfterSkill = "run ".length + 2;
585 const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length;
586
587 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterSkill)).toBe(
588 expandedCursorAfterSkill,
589 );
590 });
591});
592
593describe("composerStateAtPromptEnd", () => {
594 it("puts the caret at the end of a restored parked draft", () => {
595 const prompt = carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half");
596
597 expect(composerStateAtPromptEnd(prompt)).toEqual({
598 cursor: prompt.length,
599 trigger: null,
600 });
601 });
602
603 it("collapses mention chips so the next keystroke lands after the draft", () => {
604 const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please");
605
606 expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length);
607 expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0);
608 expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length);
609 });
610
611 it("keeps a trailing mention trigger when the restored draft ends with @", () => {
612 const prompt = "look at @";
613
614 expect(composerStateAtPromptEnd(prompt)).toEqual({
615 cursor: prompt.length,
616 trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length },
617 });
618 });
619});
620
621describe("collapseExpandedComposerCursor", () => {
622 it("keeps cursor unchanged when no mention segment is present", () => {
623 expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5);
624 });
625
626 it("maps expanded mention cursor back to collapsed cursor", () => {
627 const text = "what's in my @AGENTS.md fsfdas";
628 const collapsedCursorAfterMention = "what's in my ".length + 2;
629 const expandedCursorAfterMention = "what's in my @AGENTS.md ".length;
630
631 expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
632 collapsedCursorAfterMention,
633 );
634 });
635
636 it("maps expanded quoted mention cursor back to collapsed cursor", () => {
637 const text = 'what is in @"My File.md" please';
638 const collapsedCursorAfterMention = "what is in ".length + 2;
639 const expandedCursorAfterMention = 'what is in @"My File.md" '.length;
640
641 expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
642 collapsedCursorAfterMention,
643 );
644 });
645
646 it("maps expanded markdown file link cursors back to collapsed offsets", () => {
647 const text = "what's in [AGENTS.md](AGENTS.md) please";
648 const collapsedCursorAfterMention = "what's in ".length + 2;
649 const expandedCursorAfterMention = "what's in [AGENTS.md](AGENTS.md) ".length;
650
651 expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
652 collapsedCursorAfterMention,
653 );
654 });
655
656 it("keeps package-like text expanded when another mention already exists earlier", () => {
657 const text = "open @AGENTS.md then @src/index.ts ";
658 const expandedCursor = text.length;
659 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);
660
661 expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length);
662 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
663 });
664
665 it("collapses only genuine mentions when package-like text exists earlier", () => {
666 const text = "install @scope/pkg then @README.md ";
667 const expandedCursor = text.length;
668 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);
669
670 expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length);
671 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
672 });
673
674 it.each(["$", "€", "𑿝"])("maps expanded %s skill cursor back to collapsed cursor", (prefix) => {
675 const text = `run ${prefix}review-follow-up then`;
676 const collapsedCursorAfterSkill = "run ".length + 2;
677 const expandedCursorAfterSkill = `run ${prefix}review-follow-up `.length;
678
679 expect(collapseExpandedComposerCursor(text, expandedCursorAfterSkill)).toBe(
680 collapsedCursorAfterSkill,
681 );
682 });
683});
684
685it("preserves the caret before trailing text after mixed-width skill chips", () => {
686 const text = "𑿝ui a $review tail";
687 const expanded = "𑿝ui a $review ".length;
688 expect(collapseExpandedComposerCursor(text, expanded)).toBe(6);
689 expect(expandCollapsedComposerCursor(text, 6)).toBe(expanded);
690});
691
692describe("clampCollapsedComposerCursor", () => {
693 it("clamps to collapsed prompt length when mentions are present", () => {
694 const text = "open @AGENTS.md then ";
695
696 expect(clampCollapsedComposerCursor(text, text.length)).toBe(
697 "open ".length + 1 + " then ".length,
698 );
699 expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe(
700 "open ".length + 1 + " then ".length,
701 );
702 });
703});
704
705describe("assistant citation cursor offsets", () => {
706 it("roundtrips every collapsed offset across citations, mentions, skills, and Unicode", () => {
707 const prefix = "πŸ‘‹(";
708 const between = "),ι›ͺ";
709 const after = " @AGENTS.md $review ";
710 const text = `${prefix}${citationSource}${between}${citationSource}${after}${terminalReference}!`;
711 const collapsedLength = `${prefix}□${between}□ □ □ □!`.length;
712 const boundaries = [
713 [prefix.length, prefix.length],
714 [prefix.length + 1, prefix.length + citationSource.length],
715 [prefix.length + 1 + between.length, prefix.length + citationSource.length + between.length],
716 [
717 prefix.length + 2 + between.length,
718 prefix.length + citationSource.length * 2 + between.length,
719 ],
720 [collapsedLength, text.length],
721 ] as const;
722
723 for (const [collapsed, expanded] of boundaries) {
724 expect(expandCollapsedComposerCursor(text, collapsed)).toBe(expanded);
725 expect(collapseExpandedComposerCursor(text, expanded)).toBe(collapsed);
726 }
727 for (let cursor = 0; cursor <= collapsedLength; cursor += 1) {
728 expect(
729 collapseExpandedComposerCursor(text, expandCollapsedComposerCursor(text, cursor)),
730 ).toBe(cursor);
731 }
732 expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe(collapsedLength);
733 });
734
735 it("snaps serialized offsets inside a citation to the end of its chip", () => {
736 const prefix = "ε‰πŸ‘‹";
737 const text = `${prefix}${citationSource}.`;
738
739 for (const offset of [1, citationSource.length - 1, citationSource.length]) {
740 expect(collapseExpandedComposerCursor(text, prefix.length + offset)).toBe(prefix.length + 1);
741 }
742 expect(isCollapsedCursorAdjacentToInlineToken(text, prefix.length, "right")).toBe(true);
743 expect(isCollapsedCursorAdjacentToInlineToken(text, prefix.length + 1, "left")).toBe(true);
744 expect(isCollapsedCursorAdjacentToInlineToken(text, prefix.length + 2, "left")).toBe(false);
745 });
746
747 it("deletes one collapsed citation without changing its neighbor or surrounding punctuation", () => {
748 const text = `(${citationSource}),${citationSource}!`;
749 const result = replaceTextRange(
750 text,
751 expandCollapsedComposerCursor(text, 1),
752 expandCollapsedComposerCursor(text, 2),
753 "",
754 );
755
756 expect(result).toEqual({ text: `(),${citationSource}!`, cursor: 1 });
757 expect(collapseExpandedComposerCursor(result.text, result.text.length)).toBe("(),□!".length);
758 });
759});
760
761describe("replaceTextRange trailing space consumption", () => {
762 it("double space after insertion when replacement ends with space", () => {
763 // Simulates: "and then |@AG| summarize" where | marks replacement range
764 // The replacement is "@AGENTS.md " (with trailing space)
765 // But if we don't extend rangeEnd, the existing space stays
766 const text = "and then @AG summarize";
767 const rangeStart = "and then ".length;
768 const rangeEnd = "and then @AG".length;
769
770 // Without consuming trailing space: double space
771 const withoutConsume = replaceTextRange(text, rangeStart, rangeEnd, "@AGENTS.md ");
772 expect(withoutConsume.text).toBe("and then @AGENTS.md summarize");
773
774 // With consuming trailing space: single space
775 const extendedEnd = text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
776 const withConsume = replaceTextRange(text, rangeStart, extendedEnd, "@AGENTS.md ");
777 expect(withConsume.text).toBe("and then @AGENTS.md summarize");
778 });
779});
780
781describe("isCollapsedCursorAdjacentToInlineToken", () => {
782 it("returns false when no mention exists", () => {
783 expect(isCollapsedCursorAdjacentToInlineToken("plain text", 6, "left")).toBe(false);
784 expect(isCollapsedCursorAdjacentToInlineToken("plain text", 6, "right")).toBe(false);
785 });
786
787 it("keeps @query typing non-adjacent while no mention pill exists", () => {
788 const text = "hello @pac";
789 expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "left")).toBe(false);
790 expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "right")).toBe(false);
791 });
792
793 it("detects left adjacency only when cursor is directly after a mention", () => {
794 const text = "open @AGENTS.md next";
795 const mentionStart = "open ".length;
796 const mentionEnd = mentionStart + 1;
797
798 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd, "left")).toBe(true);
799 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart, "left")).toBe(false);
800 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd + 1, "left")).toBe(false);
801 });
802
803 it("detects right adjacency only when cursor is directly before a mention", () => {
804 const text = "open @AGENTS.md next";
805 const mentionStart = "open ".length;
806 const mentionEnd = mentionStart + 1;
807
808 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart, "right")).toBe(true);
809 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd, "right")).toBe(false);
810 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart - 1, "right")).toBe(false);
811 });
812
813 it("treats context reference pills as inline tokens for adjacency checks", () => {
814 const text = `open ${terminalReference} next`;
815 const tokenStart = "open ".length;
816 const tokenEnd = tokenStart + 1;
817
818 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);
819 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);
820 });
821
822 it("treats skill pills as inline tokens for adjacency checks", () => {
823 const text = "run $review-follow-up next";
824 const tokenStart = "run ".length;
825 const tokenEnd = tokenStart + 1;
826
827 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);
828 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);
829 });
830});
831
832describe("parseStandaloneComposerSlashCommand", () => {
833 it("parses standalone /plan command", () => {
834 expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan");
835 });
836
837 it("parses standalone /default command", () => {
838 expect(parseStandaloneComposerSlashCommand("/default")).toBe("default");
839 });
840
841 it("ignores slash commands with extra message text", () => {
842 expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull();
843 });
844});
845
846describe("literal editor answer cursor state", () => {
847 const prefill = "- [X] done $my-skill ";
848
849 it("keeps character positions after token-like text for inserted lines and replacements", () => {
850 for (const replacement of ["\n", "\t", "edited\n "]) {
851 const next = replaceTextRange(prefill, prefill.length, prefill.length, replacement);
852 const cursor = collapseExpandedComposerCursor(next.text, next.cursor, true);
853 expect(next.text).toBe(prefill + replacement);
854 expect(cursor).toBe(next.text.length);
855 expect(expandCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor);
856 expect(clampCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor);
857 }
858 const replaced = replaceTextRange(prefill, 11, 20, "$another-skill");
859 const cursor = collapseExpandedComposerCursor(replaced.text, replaced.cursor, true);
860 expect(replaced.text).toBe("- [X] done $another-skill ");
861 expect(cursor).toBe(25);
862 expect(expandCollapsedComposerCursor(replaced.text, cursor, true)).toBe(25);
863 });
864
865 it("does not open a skill or path menu when restoring an editor answer", () => {
866 for (const value of ["- [X] done $my-skill", "Use @README", ""]) {
867 expect(composerStateAtPromptEnd(value, true)).toEqual({
868 cursor: value.length,
869 trigger: null,
870 });
871 }
872 expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill");
873 });
874});
875
876describe("emoji composer triggers", () => {
877 it.each([":dizzy", "feeling :dizzy", "(:dizzy", "first\n:dizzy", ":+1", ":-1"])(
878 "opens for %j",
879 (text) => {
880 const trigger = detectComposerTrigger(text, text.length);
881 expect(trigger?.kind).toBe("emoji");
882 expect(text.slice(trigger!.rangeStart, trigger!.rangeEnd)).toMatch(/^:[a-z_+-]/i);
883 },
884 );
885
886 it.each([
887 ":",
888 "12:30",
889 "https://example.com:dizzy",
890 "http://:dizzy",
891 "hello:dizzy",
892 ":dizzy:",
893 "\\:dizzy",
894 "` :dizzy",
895 "`` :dizzy",
896 "```ts\n:dizzy",
897 "~~~\n:dizzy",
898 "````\n```\n:dizzy",
899 "`code\n:dizzy",
900 ])("does not open for times, URLs, escapes, or code: %j", (text) => {
901 expect(detectComposerTrigger(text, text.length)).toBeNull();
902 });
903
904 it.each([
905 "`code` :dizzy",
906 "\\`literal :dizzy",
907 "```\ncode\n```\n:dizzy",
908 "~~~\ncode\n~~~\n:dizzy",
909 ])("opens after leaving code: %j", (text) =>
910 expect(detectComposerTrigger(text, text.length)?.kind).toBe("emoji"),
911 );
912
913 it("replaces a shortcode in the middle of a sentence and preserves the suffix", () => {
914 const text = "before :dizzy after";
915 const trigger = detectComposerTrigger(text, text.indexOf(" after"))!;
916 expect(replaceTextRange(text, trigger.rangeStart, trigger.rangeEnd, "πŸ’«")).toEqual({
917 text: "before πŸ’« after",
918 cursor: "before πŸ’«".length,
919 });
920 });
921
922 it("replaces the entire shortcode when the caret is inside it", () => {
923 const text = "before :dizzy: after";
924 const trigger = detectComposerTrigger(text, text.indexOf("zzy"))!;
925 expect(trigger.query).toBe("di");
926 expect(replaceTextRange(text, trigger.rangeStart, trigger.rangeEnd, "πŸ’«").text).toBe(
927 "before πŸ’« after",
928 );
929 });
930});