| 1 | import { resolveComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; |
| 2 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; |
| 3 | import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; |
| 4 | import { |
| 5 | collectAssistantCitations, |
| 6 | expandAssistantCitationsForProvider, |
| 7 | serializeAssistantCitation, |
| 8 | } from "@t3tools/shared/assistantCitations"; |
| 9 | import { |
| 10 | DEFAULT_RESOLVED_KEYBINDINGS, |
| 11 | compileResolvedKeybindingsConfig, |
| 12 | mergeWithDefaultKeybindings, |
| 13 | } from "@t3tools/shared/keybindings"; |
| 14 | import { describe, expect, it } from "vite-plus/test"; |
| 15 | |
| 16 | import { |
| 17 | clampCollapsedComposerCursor, |
| 18 | collapseExpandedComposerCursor, |
| 19 | composerSubmissionIntentForKey, |
| 20 | composerStateAtPromptEnd, |
| 21 | detectComposerTrigger, |
| 22 | expandCollapsedComposerCursor, |
| 23 | formatAssistantCitationForComposer, |
| 24 | isCollapsedCursorAdjacentToInlineToken, |
| 25 | parseStandaloneComposerSlashCommand, |
| 26 | replaceTextRange, |
| 27 | } from "./composer-logic"; |
| 28 | import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; |
| 29 | import { formatTerminalContextReference } from "./lib/terminalContext"; |
| 30 | |
| 31 | const terminalReference = formatTerminalContextReference({ |
| 32 | id: "ctx-1", |
| 33 | terminalLabel: "Terminal 1", |
| 34 | lineStart: 3, |
| 35 | lineEnd: 4, |
| 36 | }); |
| 37 | |
| 38 | const 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 | }; |
| 49 | const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20"); |
| 50 | |
| 51 | describe("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 | |
| 71 | describe("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 | |
| 248 | describe("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 | |
| 446 | describe("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 | |
| 529 | describe("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 | |
| 539 | describe("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 | |
| 593 | describe("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 | |
| 621 | describe("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 | |
| 685 | it("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 | |
| 692 | describe("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 | |
| 705 | describe("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 | |
| 761 | describe("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 | |
| 781 | describe("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 | |
| 832 | describe("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 | |
| 846 | describe("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 | |
| 876 | describe("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 | }); |