Skip to content
Source
Sign inSign up

use-composer-command-menu.ts

670 lines23.0 KB

1import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests";
2import * as Dialog from "../../components/ui/Dialog";
3import type {
4 EnvironmentId,
5 ProjectId,
6 ServerProvider,
7 ThreadId,
8 ThreadPullRequestLink,
9} from "@t3tools/contracts";
10import {
11 type ComposerEnvironmentCandidate,
12 matchComposerEnvironmentItems,
13 matchComposerThreadItems,
14} from "@t3tools/client-runtime/composerThreadItems";
15import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
16​
17const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = [];
18const EMPTY_ENVIRONMENTS: ReadonlyArray<ComposerEnvironmentCandidate> = [];
19const NO_LINKED_ENVIRONMENTS: ReadonlyMap<EnvironmentId, string> = new Map();
20import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts";
21import {
22 formatComposerContextReference,
23 threadContextRecord,
24} from "@t3tools/shared/composerContextReferences";
25import { environmentComposerContext, pullRequestComposerContext } from "../../lib/composerContext";
26import { uuidv4 } from "../../lib/uuid";
27import {
28 getComposerDraftSnapshot,
29 setComposerDraftContext,
30 useComposerDraftSelection,
31} from "../../state/use-composer-drafts";
32import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
33import {
34 detectComposerTrigger,
35 replaceTextRange,
36 serializeComposerFileLink,
37 type ComposerTrigger,
38} from "@t3tools/shared/composerTrigger";
39import {
40 insertRankedSearchResult,
41 normalizeSearchQuery,
42 scoreQueryMatch,
43} from "@t3tools/shared/searchRanking";
44import {
45 dedupeProviderSkillsByName,
46 getProviderSkillsForSlashMenu,
47 getProviderSlashCommandsForSlashMenu,
48 isProviderSkillUserInvocable,
49 resolveProviderSkillsForCwd,
50 resolveProviderSlashCommandsForCwd,
51} from "@t3tools/client-runtime/providerSkills";
52import {
53 formatComposerModelMention,
54 rankComposerModelMentions,
55} from "@t3tools/client-runtime/composer-model-mentions";
56import { useAtomRefresh } from "@effect/atom-react";
57import { Atom } from "effect/reactivity";
58import { useEffect, useState } from "react";
59​
60import type { ComposerEditorSelection } from "../../components/ComposerEditor";
61import { providerWorkspaceSnapshotRefresh } from "../../state/provider-workspace-snapshot-refresh";
62import {
63 useComposerPathSearch,
64 useComposerPullRequestSearch,
65 useThreadSearch,
66} from "../../state/queries";
67import type { ModelOption } from "../../lib/modelOptions";
68import type { ComposerCommandItem } from "./ComposerCommandPopover";
69import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
70​
71const idleWorkspaceSnapshotRefreshAtom: Atom.Atom<unknown> = Atom.make<void>(undefined).pipe(
72 Atom.withLabel("mobile:provider-workspace-snapshot-refresh:idle"),
73);
74​
75function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {
76 return { start: draftMessage.length, end: draftMessage.length };
78​
79/**
80 * A caret placed by an insertion (chips, pasted context) wins while the draft
81 * still has the text it was placed in. Otherwise the composer's own caret is
82 * clamped to the draft, and a caret from another owner starts at the end.
83 */
84export function resolveComposerMenuSelection(input: {
85 readonly draftMessage: string;
86 readonly ownerKey: string | null;
87 readonly inserted: ComposerEditorSelection | null;
88 readonly local: {
89 readonly ownerKey: string | null;
90 readonly start: number;
91 readonly end: number;
92 };
93}): ComposerEditorSelection {
94 if (input.inserted) return input.inserted;
95 if (input.local.ownerKey !== input.ownerKey) return composerSelectionAtEnd(input.draftMessage);
96 const length = input.draftMessage.length;
97 return { start: Math.min(input.local.start, length), end: Math.min(input.local.end, length) };
99​
100export function buildComposerSlashCommandItems(input: {
101 readonly query: string;
102 readonly atMessageStart: boolean;
103 readonly hasThread: boolean;
104 readonly hasCompactableConversation?: boolean;
105 readonly offersUsageLimits?: boolean;
106 /** T3's `/clear` replaces the provider's, which would clear a session T3 still treats as live. */
107 readonly offersFreshSession?: boolean;
108 readonly offersSideChat?: boolean;
109 readonly selectedProviderStatus: Pick<ServerProvider, "driver" | "slashCommands"> | null;
110}): ComposerCommandItem[] {
111 const query = input.query.toLowerCase();
112 const items: ComposerCommandItem[] = "model".includes(query)
113 ? [
114 {
115 id: "cmd:model",
116 type: "slash-command",
117 command: "model",
118 label: "/model",
119 description: "Switch model",
120 },
121 ]
122 : [];
123​
124 // Providers expand commands only at the start of a message. T3 commands
125 // change local state and do not have this restriction, except `/clear`.
126 if (!input.atMessageStart) return items;
127 if (input.offersSideChat) {
128 for (const [command, description] of [
129 ["side", "Ask a side question in a new side chat"],
130 ["btw", "Same as /side"],
131 ] as const) {
132 if (!command.includes(query)) continue;
133 items.push({
134 id: `cmd:${command}`,
135 type: "slash-command",
136 command,
137 label: `/${command}`,
138 description,
139 });
140 }
141 }
142 if (input.offersFreshSession && "clear".includes(query)) {
143 items.push({
144 id: "cmd:clear",
145 type: "slash-command",
146 command: "clear",
147 label: "/clear",
148 description: "Start a fresh session",
149 });
150 }
151 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
152 if (!command.name.toLowerCase().includes(query)) continue;
153 if (command.name === "clear" && input.offersFreshSession) continue;
154 if (command.name === "compact" && !input.hasCompactableConversation) continue;
155 if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) {
156 continue;
157 }
158 if (
159 !input.hasThread &&
160 input.selectedProviderStatus?.driver === "codex" &&
161 command.name === "feedback"
162 ) {
163 continue;
164 }
165 items.push({
166 id: `pcmd:${command.name}`,
167 type: "provider-slash-command",
168 command,
169 label: `/${command.name}`,
170 description: command.description ?? "",
171 });
172 }
173 return items;
175​
176export function buildComposerModelItems(
177 modelOptions: ReadonlyArray<ModelOption>,
178 query: string,
179 providers: ReadonlyArray<ServerProvider>,
180): ComposerCommandItem[] {
181 // The picker retains saved selections even when their provider is no longer usable.
182 const readyProviderIds = new Set(
183 providers
184 .filter(
185 (provider) =>
186 provider.installed &&
187 provider.status === "ready" &&
188 provider.auth.status !== "unauthenticated" &&
189 provider.availability !== "unavailable",
190 )
191 .map((provider) => provider.instanceId),
192 );
193 return rankComposerModelMentions(
194 modelOptions.filter(
195 (option) =>
196 readyProviderIds.has(option.selection.instanceId) &&
197 !option.isUnavailable &&
198 !option.isHidden &&
199 !option.isLegacy,
200 ),
201 query,
202 (option) => ({
203 name: option.label,
204 ...(option.subtitle ? { subProvider: option.subtitle } : {}),
205 driverKind: option.providerDriver,
206 providerDisplayName: option.providerLabel,
207 }),
208 ).map((option) => ({
209 id: `model:${option.key}`,
210 type: "model",
211 option,
212 label: option.label,
213 description: option.subtitle
214 ? `${option.providerLabel} · ${option.subtitle}`
215 : option.providerLabel,
216 }));
218​
219export function resolveComposerCommandSelection(input: {
220 readonly draftMessage: string;
221 readonly trigger: Pick<ComposerTrigger, "rangeStart" | "rangeEnd">;
222 readonly item: ComposerCommandItem;
223}): { readonly text: string; readonly cursor: number } {
224 const { draftMessage, trigger, item } = input;
225 let replacement = "";
226 if (item.type === "path") {
227 replacement = `${serializeComposerFileLink(item.path)} `;
228 } else if (item.type === "skill") {
229 replacement = `$${item.skill.name} `;
230 } else if (item.type === "slash-command") {
231 replacement = `/${item.command} `;
232 } else if (item.type === "provider-slash-command") {
233 replacement = `/${item.command.name} `;
234 } else if (item.type === "model") {
235 replacement = `${formatComposerModelMention(item.option.selection.instanceId, item.option.selection.model)} `;
236 }
237 return replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement);
239​
240export function useComposerCommandMenu({
241 draftMessage,
242 ownerKey,
243 environmentId,
244 threadShells = EMPTY_THREAD_SHELLS,
245 environments = EMPTY_ENVIRONMENTS,
246 linkedEnvironments = NO_LINKED_ENVIRONMENTS,
247 currentThreadId = null,
248 projectCwd,
249 pullRequestProjectId = null,
250 pullRequestRepository = null,
251 threadPullRequests,
252 selectedProviderStatus,
253 hasThread,
254 hasCompactableConversation,
255 offersUsageLimits = false,
256 enabled = true,
257 onChangeDraftMessage,
258 onUsageLimits,
259 modelOptions,
260 modelProviders,
261 onFreshSession,
262 offersSideChat = false,
263}: {
264 readonly draftMessage: string;
265 readonly ownerKey: string | null;
266 readonly environmentId: EnvironmentId | null;
267 readonly threadShells?: ReadonlyArray<EnvironmentThreadShell>;
268 readonly environments?: ReadonlyArray<ComposerEnvironmentCandidate>;
269 /** The machines `environmentId` links to, by name, whose threads the picker also offers. */
270 readonly linkedEnvironments?: ReadonlyMap<EnvironmentId, string>;
271 readonly currentThreadId?: ThreadId | null;
272 readonly projectCwd: string | null;
273 readonly pullRequestProjectId?: ProjectId | null;
274 readonly pullRequestRepository?: string | null;
275 readonly threadPullRequests?: ReadonlyArray<ThreadPullRequestLink>;
276 readonly selectedProviderStatus: ServerProvider | null;
277 readonly hasThread: boolean;
278 readonly hasCompactableConversation: boolean;
279 readonly offersUsageLimits?: boolean;
280 readonly enabled?: boolean;
281 readonly onChangeDraftMessage: (value: string) => void;
282 readonly onUsageLimits?: () => void;
283 readonly modelOptions?: ReadonlyArray<ModelOption>;
284 readonly modelProviders?: ReadonlyArray<ServerProvider>;
285 /** Arms a fresh session for the next message; absent where `/clear` is unavailable. */
286 readonly onFreshSession?: () => void;
287 readonly offersSideChat?: boolean;
288}) {
289 const [localSelection, setLocalSelection] = useState(() => ({
290 ownerKey,
291 ...composerSelectionAtEnd(draftMessage),
292 }));
293 // Thread mentions rank by recency as of when the composer mounted.
294 const [now] = useState(() => Date.now());
295 const insertedSelection = useComposerDraftSelection(ownerKey, draftMessage);
296 const selection = resolveComposerMenuSelection({
297 draftMessage,
298 ownerKey,
299 inserted: insertedSelection,
300 local: localSelection,
301 });
302 const setSelection = (next: ComposerEditorSelection) =>
303 setLocalSelection({ ownerKey, start: next.start, end: next.end });
304​
305 const skills = selectedProviderStatus
306 ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd)
307 : [];
308 const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
309 const refreshWorkspaceSnapshot = useAtomRefresh(
310 environmentId && projectCwd && selectedProviderInstanceId
311 ? providerWorkspaceSnapshotRefresh({
312 environmentId,
313 instanceId: selectedProviderInstanceId,
314 cwd: projectCwd,
315 })
316 : idleWorkspaceSnapshotRefreshAtom,
317 );
318 // Every draft change rechecks a stale snapshot, including drafts written by
319 // another surface (a prompt link) that never move the editor selection.
320 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes the provider workspace snapshot atom whenever the draft text changes from any surface
321 useEffect(() => {
322 refreshWorkspaceSnapshot();
323 }, [
324 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Draft changes are the recheck signal.
325 draftMessage,
326 refreshWorkspaceSnapshot,
327 ]);
328 const onSelectionChange = (nextSelection: ComposerEditorSelection) => {
329 setSelection(nextSelection);
330 };
331​
332 const trigger = (() => {
333 if (!enabled || selection.start !== selection.end) {
334 return null;
335 }
336 return detectComposerTrigger(draftMessage, selection.end);
337 })();
338 const pathSearch = useComposerPathSearch({
339 environmentId,
340 cwd: trigger?.kind === "path" ? projectCwd : null,
341 query: trigger?.kind === "path" ? trigger.query : null,
342 });
343 // Mounts older settled threads matching the mention, which shells may leave out.
344 useThreadSearch(
345 trigger?.kind === "path" && environmentId ? [environmentId] : [],
346 trigger?.kind === "path" ? trigger.query : "",
347 { titlesOnly: true },
348 );
349 const pullRequestSearch = useComposerPullRequestSearch({
350 environmentId,
351 projectId: pullRequestProjectId,
352 repository: pullRequestRepository,
353 threadPullRequests,
354 query: trigger?.kind === "pull-request" ? trigger.query : null,
355 });
356​
357 const items = ((): ComposerCommandItem[] => {
358 if (!trigger) return [];
359​
360 if (trigger.kind === "pull-request") {
361 return pullRequestSearch.entries.map((entry) => ({
362 id: `pr:${entry.projectId}:${threadPullRequestKeyOf(entry)}`,
363 type: "pull-request",
364 pullRequest: {
365 number: entry.number,
366 title: entry.title,
367 url: entry.url,
368 headBranch: entry.headBranch,
369 baseBranch: entry.baseBranch,
370 state: entry.state,
371 isDraft: entry.isDraft,
372 },
373 label: `#${entry.number}`,
374 description: `${entry.isDraft ? "Draft" : entry.state} · ${
375 entry.repository.toLowerCase() === pullRequestRepository?.toLowerCase()
376 ? ""
377 : `${entry.repository} · `
378 }${entry.title}`,
379 }));
380 }
381​
382 if (trigger.kind === "slash-command") {
383 const q = trigger.query.toLowerCase();
384 const visibleSkills = getProviderSkillsForSlashMenu(skills, true);
385 const commandItems = buildComposerSlashCommandItems({
386 query: q,
387 atMessageStart: trigger.rangeStart === 0,
388 hasThread,
389 hasCompactableConversation,
390 offersUsageLimits,
391 offersFreshSession: onFreshSession !== undefined,
392 offersSideChat,
393 selectedProviderStatus: selectedProviderStatus
394 ? {
395 ...selectedProviderStatus,
396 slashCommands: getProviderSlashCommandsForSlashMenu(
397 resolveProviderSlashCommandsForCwd(selectedProviderStatus, projectCwd),
398 visibleSkills,
399 ),
400 }
401 : null,
402 });
403​
404 const skillItems = visibleSkills
405 .filter((skill) => matchesSlashSkillQuery(skill, q))
406 .map((skill) => ({
407 id: `skill:${skill.name}`,
408 type: "skill" as const,
409 skill,
410 label: `skill:${skill.name}`,
411 description: skill.shortDescription ?? skill.description ?? "",
412 }));
413​
414 return [...commandItems, ...skillItems];
415 }
416​
417 if (trigger.kind === "skill") {
418 const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));
419 const normalizedQuery = normalizeSearchQuery(trigger.query, {
420 trimLeadingPattern: /^\p{Sc}+/u,
421 });
422​
423 if (!normalizedQuery) {
424 return enabledSkills.slice(0, 20).map((skill) => ({
425 id: `skill:${skill.name}`,
426 type: "skill" as const,
427 skill,
428 label: skill.displayName ?? skill.name,
429 description: skill.shortDescription ?? skill.description ?? "",
430 }));
431 }
432​
433 const ranked: Array<{
434 item: (typeof enabledSkills)[number];
435 score: number;
436 tieBreaker: string;
437 }> = [];
438 for (const skill of enabledSkills) {
439 const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
440 const scores = [
441 scoreQueryMatch({
442 value: skill.name.toLowerCase(),
443 query: normalizedQuery,
444 exactBase: 0,
445 prefixBase: 2,
446 boundaryBase: 4,
447 includesBase: 6,
448 fuzzyBase: 100,
449 boundaryMarkers: ["-", "_", "/"],
450 }),
451 scoreQueryMatch({
452 value: displayLabel,
453 query: normalizedQuery,
454 exactBase: 1,
455 prefixBase: 3,
456 boundaryBase: 5,
457 includesBase: 7,
458 fuzzyBase: 110,
459 }),
460 scoreQueryMatch({
461 value: skill.shortDescription?.toLowerCase() ?? "",
462 query: normalizedQuery,
463 exactBase: 20,
464 prefixBase: 22,
465 boundaryBase: 24,
466 includesBase: 26,
467 }),
468 scoreQueryMatch({
469 value: skill.description?.toLowerCase() ?? "",
470 query: normalizedQuery,
471 exactBase: 30,
472 prefixBase: 32,
473 boundaryBase: 34,
474 includesBase: 36,
475 }),
476 ].filter((score): score is number => score !== null);
477​
478 if (scores.length > 0) {
479 insertRankedSearchResult(
480 ranked,
481 {
482 item: skill,
483 score: Math.min(...scores),
484 tieBreaker: `${displayLabel}\u0000${skill.name}`,
485 },
486 20,
487 );
488 }
489 }
490​
491 return ranked.map(({ item: skill }) => ({
492 id: `skill:${skill.name}`,
493 type: "skill" as const,
494 skill,
495 label: skill.displayName ?? skill.name,
496 description: skill.shortDescription ?? skill.description ?? "",
497 }));
498 }
499​
500 if (trigger.kind === "path") {
501 const threadItems = environmentId
502 ? matchComposerThreadItems({
503 shells: threadShells,
504 environmentId,
505 linkedEnvironments,
506 excludeThreadId: currentThreadId,
507 query: trigger.query,
508 now,
509 })
510 : [];
511 const environmentItems = environmentId
512 ? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query })
513 : [];
514 const modelItems =
515 modelOptions && modelProviders
516 ? buildComposerModelItems(modelOptions, trigger.query, modelProviders)
517 : [];
518 return [
519 ...threadItems,
520 ...environmentItems,
521 ...modelItems,
522 ...pathSearch.entries.map((entry) => {
523 const parts = entry.path.split("/");
524 return {
525 id: `path:${entry.path}`,
526 type: "path" as const,
527 path: entry.path,
528 kind: entry.kind,
529 label: parts[parts.length - 1] ?? entry.path,
530 description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
531 };
532 }),
533 ];
534 }
535​
536 return [];
537 })();
538​
539 const onSelect = (item: ComposerCommandItem) => {
540 if (!trigger) return;
541 if (item.type === "environment") {
542 if (!ownerKey || trigger.kind !== "path") return;
543 const record = environmentComposerContext(item.environmentId, item.label);
544 const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? [];
545 const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId);
546 if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) {
547 Dialog.alert("Too many context items", "Remove some context from the draft and try again.");
548 return;
549 }
550 const result = replaceTextRange(
551 draftMessage,
552 trigger.rangeStart,
553 trigger.rangeEnd,
554 `${formatComposerContextReference(record)} `,
555 );
556 onChangeDraftMessage(result.text);
557 if (!alreadyAttached) {
558 const draft = getComposerDraftSnapshot(ownerKey);
559 setComposerDraftContext(ownerKey, {
560 version: 1,
561 records: [...(draft.context?.records ?? []), record],
562 });
563 }
564 setSelection({ start: result.cursor, end: result.cursor });
565 return;
566 }
567 if (item.type === "thread") {
568 if (!ownerKey || trigger.kind !== "path") return;
569 const shell = threadShells.find(
570 (candidate) =>
571 candidate.environmentId === item.thread.environmentId &&
572 candidate.id === item.thread.threadId,
573 );
574 if (!shell) return;
575 const record = threadContextRecord(item.thread, shell.title);
576 const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? [];
577 const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId);
578 if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) {
579 Dialog.alert("Too many context items", "Remove some context from the draft and try again.");
580 return;
581 }
582 const result = replaceTextRange(
583 draftMessage,
584 trigger.rangeStart,
585 trigger.rangeEnd,
586 `${formatComposerContextReference(record)} `,
587 );
588 onChangeDraftMessage(result.text);
589 if (!alreadyAttached) {
590 const draft = getComposerDraftSnapshot(ownerKey);
591 setComposerDraftContext(ownerKey, {
592 version: 1,
593 records: [...(draft.context?.records ?? []), record],
594 });
595 }
596 setSelection({ start: result.cursor, end: result.cursor });
597 return;
598 }
599 if (item.type === "pull-request") {
600 if (
601 !ownerKey ||
602 trigger.kind !== "pull-request" ||
603 !items.some((candidate) => candidate.id === item.id)
604 )
605 return;
606 const record = pullRequestComposerContext(item.pullRequest, uuidv4());
607 if (
608 (getComposerDraftSnapshot(ownerKey).context?.records.length ?? 0) >=
609 COMPOSER_CONTEXT_MAX_RECORDS
610 ) {
611 Dialog.alert("Too many context items", "Remove some context from the draft and try again.");
612 return;
613 }
614 const result = replaceTextRange(
615 draftMessage,
616 trigger.rangeStart,
617 trigger.rangeEnd,
618 `${formatComposerContextReference(record)} `,
619 );
620 onChangeDraftMessage(result.text);
621 const draft = getComposerDraftSnapshot(ownerKey);
622 setComposerDraftContext(ownerKey, {
623 version: 1,
624 records: [...(draft.context?.records ?? []), record],
625 });
626 setSelection({ start: result.cursor, end: result.cursor });
627 return;
628 }
629​
630 if (item.type === "slash-command" && item.command === "clear" && onFreshSession) {
631 const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
632 setSelection({ start: cleared.cursor, end: cleared.cursor });
633 onChangeDraftMessage(cleared.text);
634 onFreshSession();
635 return;
636 }
637 if (
638 item.type === "provider-slash-command" &&
639 item.command.name === USAGE_LIMITS_COMMAND.name &&
640 onUsageLimits
641 ) {
642 const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
643 setSelection({ start: cleared.cursor, end: cleared.cursor });
644 onChangeDraftMessage(cleared.text);
645 onUsageLimits();
646 return;
647 }
648​
649 const result = resolveComposerCommandSelection({ draftMessage, trigger, item });
650 setSelection({ start: result.cursor, end: result.cursor });
651 onChangeDraftMessage(result.text);
652 };
653​
654 return {
655 selection,
656 onSelectionChange,
657 trigger,
658 items,
659 skills,
660 isLoading:
661 trigger?.kind === "pull-request" ? pullRequestSearch.isPending : pathSearch.isPending,
662 error:
663 trigger?.kind === "pull-request"
664 ? pullRequestProjectId === null || pullRequestRepository === null
665 ? "Pull requests are unavailable for this project."
666 : pullRequestSearch.error
667 : null,
668 onSelect,
669 };