1import type {
2 EnvironmentId,
3 ProjectId,
4 ProviderInteractionMode,
5 ServerProvider,
6 ThreadId,
7 ThreadPullRequestLink,
8} from "@t3tools/contracts";
9import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
11
12const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = [];
13import {
14 COMPOSER_CONTEXT_MAX_RECORDS,
15 PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS,
16} from "@t3tools/contracts";
17import { Alert } from "react-native";
18import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
19import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext";
20import { uuidv4 } from "../../lib/uuid";
21import {
22 getComposerDraftSnapshot,
23 readComposerDraftSelection,
24 setComposerDraftContext,
25} from "../../state/use-composer-drafts";
26import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
27import {
28 detectComposerTrigger,
29 replaceTextRange,
30 serializeComposerFileLink,
31 type ComposerTrigger,
32} from "@t3tools/shared/composerTrigger";
33import {
34 insertRankedSearchResult,
35 normalizeSearchQuery,
36 scoreQueryMatch,
37} from "@t3tools/shared/searchRanking";
38import {
39 dedupeProviderSkillsByName,
40 getProviderSkillsForSlashMenu,
41 getProviderSlashCommandsForSlashMenu,
42 isProviderSkillUserInvocable,
43 hasCompleteProviderWorkspaceSnapshot,
44 hasCurrentProviderWorkspaceSnapshot,
45 resolveProviderSkillsForCwd,
46 resolveProviderSlashCommandsForCwd,
47} from "@t3tools/client-runtime/providerSkills";
48import { useCallback, useEffect, useMemo, useRef, useState } from "react";
49
50import type { ComposerEditorSelection } from "../../components/ComposerEditor";
51import { serverEnvironment } from "../../state/server";
52import { useAtomCommand } from "../../state/use-atom-command";
53import { useComposerPathSearch, useComposerPullRequestSearch } from "../../state/queries";
54import type { ComposerCommandItem } from "./ComposerCommandPopover";
55import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
56
57const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
58
59function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {
60 return { start: draftMessage.length, end: draftMessage.length };
61}
62
63export function buildComposerSlashCommandItems(input: {
64 readonly query: string;
65 readonly atMessageStart: boolean;
66 readonly hasThread: boolean;
67 readonly hasCompactableConversation?: boolean;
68 /** Whether T3 itself offers /usage-limits for the selected provider. */
69 readonly offersUsageLimits?: boolean;
70 readonly allowInteractionMode: boolean;
71 readonly selectedProviderStatus: Pick<
72 ServerProvider,
73 "driver" | "slashCommands" | "showInteractionModeToggle"
74 > | null;
75}): ComposerCommandItem[] {
76 const query = input.query.toLowerCase();
77 const allowInteractionMode =
78 input.allowInteractionMode && input.selectedProviderStatus?.showInteractionModeToggle !== false;
79 const builtIn = [
80 {
81 id: "cmd:model",
82 type: "slash-command",
83 command: "model",
84 label: "/model",
85 description: "Switch model",
86 },
87 {
88 id: "cmd:plan",
89 type: "slash-command",
90 command: "plan",
91 label: "/plan",
92 description: "Switch to plan mode",
93 },
94 {
95 id: "cmd:default",
96 type: "slash-command",
97 command: "default",
98 label: "/default",
99 description: "Switch to default mode",
100 },
101 ] satisfies ComposerCommandItem[];
102 const items: ComposerCommandItem[] = builtIn.filter(
103 (item) => item.command.includes(query) && (item.command === "model" || allowInteractionMode),
104 );
105
106 // Providers expand commands only at the start of a message. T3 commands
107 // change local state and do not have this restriction.
108 if (!input.atMessageStart) return items;
109 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
110 if (!command.name.toLowerCase().includes(query)) continue;
111 if (command.name === "compact" && !input.hasCompactableConversation) continue;
112 // T3's own limits command is answered by the thread composer; New Task has
113 // nowhere to show it. A provider's same-named command is left alone.
114 if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) {
115 continue;
116 }
117 if (
118 !input.hasThread &&
119 input.selectedProviderStatus?.driver === "codex" &&
120 command.name === "feedback"
121 ) {
122 continue;
123 }
124 items.push({
125 id: `pcmd:${command.name}`,
126 type: "provider-slash-command",
127 command,
128 label: `/${command.name}`,
129 description: command.description ?? "",
130 });
131 }
132 return items;
133}
134
135export function resolveComposerCommandSelection(input: {
136 readonly draftMessage: string;
137 readonly trigger: Pick<ComposerTrigger, "rangeStart" | "rangeEnd">;
138 readonly item: ComposerCommandItem;
139 readonly allowInteractionMode: boolean;
140}): {
141 readonly text: string;
142 readonly cursor: number;
143 readonly interactionMode: ProviderInteractionMode | null;
144} {
145 const { draftMessage, trigger, item } = input;
146 if (
147 input.allowInteractionMode &&
148 item.type === "slash-command" &&
149 (item.command === "plan" || item.command === "default")
150 ) {
151 return {
152 ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""),
153 interactionMode: item.command,
154 };
155 }
156
157 let replacement = "";
158 if (item.type === "path") {
159 replacement = `${serializeComposerFileLink(item.path)} `;
160 } else if (item.type === "skill") {
161 replacement = `$${item.skill.name} `;
162 } else if (item.type === "slash-command") {
163 replacement = `/${item.command} `;
164 } else if (item.type === "provider-slash-command") {
165 replacement = `/${item.command.name} `;
166 }
167 return {
168 ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement),
169 interactionMode: null,
170 };
171}
172
173/** Shared autocomplete for thread composers and unsent new-task drafts. */
174export function useComposerCommandMenu({
175 draftMessage,
176 ownerKey,
177 environmentId,
178 threadShells = EMPTY_THREAD_SHELLS,
179 currentThreadId = null,
180 projectCwd,
181 pullRequestProjectId = null,
182 pullRequestRepository = null,
183 threadPullRequests,
184 selectedProviderStatus,
185 hasThread,
186 hasCompactableConversation,
187 offersUsageLimits = false,
188 enabled = true,
189 onChangeDraftMessage,
190 onUpdateInteractionMode,
191 onUsageLimits,
192}: {
193 readonly draftMessage: string;
194 readonly ownerKey: string | null;
195 readonly environmentId: EnvironmentId | null;
196 /** Candidates for `@` thread suggestions; the caller reads them from the entity store. */
197 readonly threadShells?: ReadonlyArray<EnvironmentThreadShell>;
198 /** Left out of `@` thread suggestions: a thread is never context for itself. */
199 readonly currentThreadId?: ThreadId | null;
200 readonly projectCwd: string | null;
201 readonly pullRequestProjectId?: ProjectId | null;
202 readonly pullRequestRepository?: string | null;
203 /** The thread's linked pull requests, offered in `#` suggestions from any repository. */
204 readonly threadPullRequests?: ReadonlyArray<ThreadPullRequestLink>;
205 readonly selectedProviderStatus: ServerProvider | null;
206 readonly hasThread: boolean;
207 readonly hasCompactableConversation: boolean;
208 /** Whether T3 itself offers /usage-limits for the selected provider. */
209 readonly offersUsageLimits?: boolean;
210 readonly enabled?: boolean;
211 readonly onChangeDraftMessage: (value: string) => void;
212 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
213 /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */
214 readonly onUsageLimits?: () => void;
215}) {
216 const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
217 const previousOwnerKeyRef = useRef(ownerKey);
218 const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => {
219 setSelection(nextSelection);
220 }, []);
221 useEffect(() => {
222 // An insert (attachment, terminal capture, review comment) rewrites the draft and records
223 // the caret that belongs after the new chip. Clamping alone would keep the old offset,
224 // which sits before it.
225 const inserted = ownerKey ? readComposerDraftSelection(ownerKey, draftMessage) : null;
226 if (inserted) {
227 setSelection((current) =>
228 current.start === inserted.start && current.end === inserted.end ? current : inserted,
229 );
230 return;
231 }
232 const end = draftMessage.length;
233 setSelection((current) => {
234 const start = Math.min(current.start, end);
235 const selectionEnd = Math.min(current.end, end);
236 if (start === current.start && selectionEnd === current.end) {
237 return current;
238 }
239 return { start, end: selectionEnd };
240 });
241 }, [draftMessage, ownerKey]);
242 useEffect(() => {
243 if (previousOwnerKeyRef.current === ownerKey) return;
244 previousOwnerKeyRef.current = ownerKey;
245 setSelection(composerSelectionAtEnd(draftMessage));
246 }, [draftMessage, ownerKey]);
247
248 const skills = useMemo(
249 () =>
250 selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [],
251 [projectCwd, selectedProviderStatus],
252 );
253 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
254 reportFailure: false,
255 });
256 const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
257 const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
258 selectedProviderStatus,
259 projectCwd,
260 );
261 // The last scan this composer asked for. A request inside the TTL is not
262 // repeated, so a client clock ahead of the server's cannot loop rescans.
263 const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null);
264 const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
265 key: string;
266 notBefore: number;
267 } | null>(null);
268 const workspaceRefreshScopeKey =
269 environmentId && projectCwd && selectedProviderInstanceId
270 ? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`
271 : null;
272 const workspaceSlashCommandsPending =
273 selectedProviderStatus?.workspaceSnapshots?.some(
274 (snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true,
275 ) ?? false;
276 useEffect(() => {
277 if (
278 !workspaceSlashCommandsPending ||
279 !workspaceRefreshRetry ||
280 workspaceRefreshRetry.key !== workspaceRefreshScopeKey
281 )
282 return;
283 const timeout = setTimeout(
284 () => {
285 setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current));
286 },
287 Math.max(0, workspaceRefreshRetry.notBefore - Date.now()),
288 );
289 return () => clearTimeout(timeout);
290 }, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]);
291 const hadWorkspaceSnapshotRef = useRef(false);
292 useEffect(() => {
293 if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
294 workspaceRefreshKeyRef.current = null;
295 setWorkspaceRefreshRetry(null);
296 }
297 hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
298 }, [hasWorkspaceSnapshot]);
299 useEffect(() => {
300 if (!environmentId || !projectCwd || !selectedProviderInstanceId) return;
301 const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`;
302 const now = Date.now();
303 const lastRequest = workspaceRefreshKeyRef.current;
304 if (
305 lastRequest?.key === key &&
306 now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS
307 )
308 return;
309 if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) {
310 setWorkspaceRefreshRetry(null);
311 return;
312 }
313 const retry = workspaceRefreshRetry;
314 if (retry?.key === key && now < retry.notBefore) return;
315 const request = { key, requestedAt: now };
316 workspaceRefreshKeyRef.current = request;
317 const retryLater = () => {
318 if (workspaceRefreshKeyRef.current !== request) return;
319 workspaceRefreshKeyRef.current = null;
320 setWorkspaceRefreshRetry({
321 key,
322 notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
323 });
324 };
325 void refreshProviders({
326 environmentId,
327 input: { instanceId: selectedProviderInstanceId, cwd: projectCwd },
328 }).then((result) => {
329 const refreshed =
330 result._tag === "Success" &&
331 hasCompleteProviderWorkspaceSnapshot(
332 result.value.providers.find(
333 (provider) => provider.instanceId === selectedProviderInstanceId,
334 ),
335 projectCwd,
336 );
337 if (!refreshed) retryLater();
338 }, retryLater);
339 }, [
340 draftMessage,
341 environmentId,
342 projectCwd,
343 refreshProviders,
344 selectedProviderInstanceId,
345 selectedProviderStatus,
346 workspaceRefreshRetry,
347 ]);
348
349 const trigger = useMemo(() => {
350 if (!enabled || selection.start !== selection.end) {
351 return null;
352 }
353 return detectComposerTrigger(draftMessage, selection.end);
354 }, [draftMessage, enabled, selection]);
355 const pathSearch = useComposerPathSearch({
356 environmentId,
357 cwd: trigger?.kind === "path" ? projectCwd : null,
358 query: trigger?.kind === "path" ? trigger.query : null,
359 });
360 const pullRequestSearch = useComposerPullRequestSearch({
361 environmentId,
362 projectId: pullRequestProjectId,
363 repository: pullRequestRepository,
364 threadPullRequests,
365 query: trigger?.kind === "pull-request" ? trigger.query : null,
366 });
367
368 const items = useMemo<ComposerCommandItem[]>(() => {
369 if (!trigger) return [];
370
371 if (trigger.kind === "pull-request") {
372 return pullRequestSearch.entries.map((entry) => ({
373 id: `pr:${entry.projectId}:${entry.repository}:${entry.number}`,
374 type: "pull-request",
375 pullRequest: {
376 number: entry.number,
377 title: entry.title,
378 url: entry.url,
379 headBranch: entry.headBranch,
380 baseBranch: entry.baseBranch,
381 state: entry.state,
382 isDraft: entry.isDraft,
383 },
384 label: `#${entry.number}`,
385 description: `${entry.isDraft ? "Draft" : entry.state} · ${
386 entry.repository.toLowerCase() === pullRequestRepository?.toLowerCase()
387 ? ""
388 : `${entry.repository} · `
389 }${entry.title}`,
390 }));
391 }
392
393 if (trigger.kind === "slash-command") {
394 const q = trigger.query.toLowerCase();
395 const visibleSkills = getProviderSkillsForSlashMenu(skills, true);
396 const commandItems = buildComposerSlashCommandItems({
397 query: q,
398 atMessageStart: trigger.rangeStart === 0,
399 hasThread,
400 hasCompactableConversation,
401 offersUsageLimits,
402 allowInteractionMode: onUpdateInteractionMode !== undefined,
403 selectedProviderStatus: selectedProviderStatus
404 ? {
405 ...selectedProviderStatus,
406 slashCommands: getProviderSlashCommandsForSlashMenu(
407 resolveProviderSlashCommandsForCwd(selectedProviderStatus, projectCwd),
408 visibleSkills,
409 ),
410 }
411 : null,
412 });
413
414 const skillItems = visibleSkills
415 .filter((skill) => matchesSlashSkillQuery(skill, q))
416 .map((skill) => ({
417 id: `skill:${skill.name}`,
418 type: "skill" as const,
419 skill,
420 label: `skill:${skill.name}`,
421 description: skill.shortDescription ?? skill.description ?? "",
422 }));
423
424 return [...commandItems, ...skillItems];
425 }
426
427 if (trigger.kind === "skill") {
428 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
429 const normalizedQuery = normalizeSearchQuery(trigger.query, {
430 trimLeadingPattern: /^\p{Sc}+/u,
431 });
432
433 if (!normalizedQuery) {
434 return enabledSkills.slice(0, 20).map((skill) => ({
435 id: `skill:${skill.name}`,
436 type: "skill" as const,
437 skill,
438 label: skill.displayName ?? skill.name,
439 description: skill.shortDescription ?? skill.description ?? "",
440 }));
441 }
442
443 const ranked: Array<{
444 item: (typeof enabledSkills)[number];
445 score: number;
446 tieBreaker: string;
447 }> = [];
448 for (const skill of enabledSkills) {
449 const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
450 const scores = [
451 scoreQueryMatch({
452 value: skill.name.toLowerCase(),
453 query: normalizedQuery,
454 exactBase: 0,
455 prefixBase: 2,
456 boundaryBase: 4,
457 includesBase: 6,
458 fuzzyBase: 100,
459 boundaryMarkers: ["-", "_", "/"],
460 }),
461 scoreQueryMatch({
462 value: displayLabel,
463 query: normalizedQuery,
464 exactBase: 1,
465 prefixBase: 3,
466 boundaryBase: 5,
467 includesBase: 7,
468 fuzzyBase: 110,
469 }),
470 scoreQueryMatch({
471 value: skill.shortDescription?.toLowerCase() ?? "",
472 query: normalizedQuery,
473 exactBase: 20,
474 prefixBase: 22,
475 boundaryBase: 24,
476 includesBase: 26,
477 }),
478 scoreQueryMatch({
479 value: skill.description?.toLowerCase() ?? "",
480 query: normalizedQuery,
481 exactBase: 30,
482 prefixBase: 32,
483 boundaryBase: 34,
484 includesBase: 36,
485 }),
486 ].filter((score): score is number => score !== null);
487
488 if (scores.length > 0) {
489 insertRankedSearchResult(
490 ranked,
491 {
492 item: skill,
493 score: Math.min(...scores),
494 tieBreaker: `${displayLabel}\u0000${skill.name}`,
495 },
496 20,
497 );
498 }
499 }
500
501 return ranked.map(({ item: skill }) => ({
502 id: `skill:${skill.name}`,
503 type: "skill" as const,
504 skill,
505 label: skill.displayName ?? skill.name,
506 description: skill.shortDescription ?? skill.description ?? "",
507 }));
508 }
509
510 if (trigger.kind === "path") {
511 const threadItems = environmentId
512 ? matchComposerThreadItems({
513 shells: threadShells,
514 environmentId,
515 excludeThreadId: currentThreadId,
516 query: trigger.query,
517 })
518 : [];
519 return [
520 ...threadItems,
521 ...pathSearch.entries.map((entry) => {
522 const parts = entry.path.split("/");
523 return {
524 id: `path:${entry.path}`,
525 type: "path" as const,
526 path: entry.path,
527 kind: entry.kind,
528 label: parts[parts.length - 1] ?? entry.path,
529 description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
530 };
531 }),
532 ];
533 }
534
535 return [];
536 }, [
537 currentThreadId,
538 environmentId,
539 threadShells,
540 hasThread,
541 hasCompactableConversation,
542 onUpdateInteractionMode,
543 pathSearch.entries,
544 pullRequestSearch.entries,
545 projectCwd,
546 selectedProviderStatus,
547 skills,
548 trigger,
549 offersUsageLimits,
550 ]);
551
552 const onSelect = useCallback(
553 (item: ComposerCommandItem) => {
554 if (!trigger) return;
555 if (item.type === "thread") {
556 if (!ownerKey || trigger.kind !== "path") return;
557 const shell = threadShells.find(
558 (candidate) =>
559 candidate.environmentId === item.thread.environmentId &&
560 candidate.id === item.thread.threadId,
561 );
562 if (!shell) return;
563 const record = threadComposerContext(item.thread, shell.title);
564 const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? [];
565 const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId);
566 if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) {
567 Alert.alert(
568 "Too many context items",
569 "Remove some context from the draft and try again.",
570 );
571 return;
572 }
573 const result = replaceTextRange(
574 draftMessage,
575 trigger.rangeStart,
576 trigger.rangeEnd,
577 `${formatComposerContextReference(record)} `,
578 );
579 onChangeDraftMessage(result.text);
580 if (!alreadyAttached) {
581 const draft = getComposerDraftSnapshot(ownerKey);
582 setComposerDraftContext(ownerKey, {
583 version: 1,
584 records: [...(draft.context?.records ?? []), record],
585 });
586 }
587 setSelection({ start: result.cursor, end: result.cursor });
588 return;
589 }
590 if (item.type === "pull-request") {
591 if (
592 !ownerKey ||
593 trigger.kind !== "pull-request" ||
594 !items.some((candidate) => candidate.id === item.id)
595 )
596 return;
597 const record = pullRequestComposerContext(item.pullRequest, uuidv4());
598 if (
599 (getComposerDraftSnapshot(ownerKey).context?.records.length ?? 0) >=
600 COMPOSER_CONTEXT_MAX_RECORDS
601 ) {
602 Alert.alert(
603 "Too many context items",
604 "Remove some context from the draft and try again.",
605 );
606 return;
607 }
608 const result = replaceTextRange(
609 draftMessage,
610 trigger.rangeStart,
611 trigger.rangeEnd,
612 `${formatComposerContextReference(record)} `,
613 );
614 onChangeDraftMessage(result.text);
615 const draft = getComposerDraftSnapshot(ownerKey);
616 setComposerDraftContext(ownerKey, {
617 version: 1,
618 records: [...(draft.context?.records ?? []), record],
619 });
620 setSelection({ start: result.cursor, end: result.cursor });
621 return;
622 }
623
624 if (
625 item.type === "provider-slash-command" &&
626 item.command.name === USAGE_LIMITS_COMMAND.name &&
627 onUsageLimits
628 ) {
629 const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
630 setSelection({ start: cleared.cursor, end: cleared.cursor });
631 onChangeDraftMessage(cleared.text);
632 onUsageLimits();
633 return;
634 }
635
636 const result = resolveComposerCommandSelection({
637 draftMessage,
638 trigger,
639 item,
640 allowInteractionMode:
641 onUpdateInteractionMode !== undefined &&
642 selectedProviderStatus?.showInteractionModeToggle !== false,
643 });
644 setSelection({ start: result.cursor, end: result.cursor });
645 onChangeDraftMessage(result.text);
646 if (result.interactionMode !== null) {
647 onUpdateInteractionMode?.(result.interactionMode);
648 }
649 },
650 [
651 draftMessage,
652 ownerKey,
653 items,
654 onChangeDraftMessage,
655 onUpdateInteractionMode,
656 onUsageLimits,
657 selectedProviderStatus?.showInteractionModeToggle,
658 threadShells,
659 trigger,
660 ],
661 );
662
663 return {
664 selection,
665 onSelectionChange,
666 trigger,
667 items,
668 skills,
669 isLoading:
670 trigger?.kind === "pull-request" ? pullRequestSearch.isPending : pathSearch.isPending,
671 error:
672 trigger?.kind === "pull-request"
673 ? pullRequestProjectId === null || pullRequestRepository === null
674 ? "Pull requests are unavailable for this project."
675 : pullRequestSearch.error
676 : null,
677 onSelect,
678 };
679}