460 lines · 14.9 KB · TypeScript
RawDownload
import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";import {  detectComposerTrigger,  replaceTextRange,  serializeComposerFileLink,  type ComposerTrigger,} from "@t3tools/shared/composerTrigger";import {  insertRankedSearchResult,  normalizeSearchQuery,  scoreQueryMatch,} from "@t3tools/shared/searchRanking";import {  dedupeProviderSkillsByName,  getProviderSkillsForSlashMenu,  isProviderSkillUserInvocable,  resolveProviderSkillsForCwd,} from "@t3tools/client-runtime/providerSkills";import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor";import { serverEnvironment } from "../../state/server";import { useAtomCommand } from "../../state/use-atom-command";import { useComposerPathSearch } from "../../state/queries";import type { ComposerCommandItem } from "./ComposerCommandPopover";import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {  return { start: draftMessage.length, end: draftMessage.length };} export function buildComposerSlashCommandItems(input: {  readonly query: string;  readonly atMessageStart: boolean;  readonly hasThread: boolean;  readonly hasCompactableConversation?: boolean;  /** Whether T3 itself offers /usage-limits for the selected provider. */  readonly offersUsageLimits?: boolean;  readonly allowInteractionMode: boolean;  readonly selectedProviderStatus: Pick<    ServerProvider,    "driver" | "slashCommands" | "showInteractionModeToggle"  > | null;}): ComposerCommandItem[] {  const query = input.query.toLowerCase();  const allowInteractionMode =    input.allowInteractionMode && input.selectedProviderStatus?.showInteractionModeToggle !== false;  const builtIn = [    {      id: "cmd:model",      type: "slash-command",      command: "model",      label: "/model",      description: "Switch model",    },    {      id: "cmd:plan",      type: "slash-command",      command: "plan",      label: "/plan",      description: "Switch to plan mode",    },    {      id: "cmd:default",      type: "slash-command",      command: "default",      label: "/default",      description: "Switch to default mode",    },  ] satisfies ComposerCommandItem[];  const items: ComposerCommandItem[] = builtIn.filter(    (item) => item.command.includes(query) && (item.command === "model" || allowInteractionMode),  );   // Providers expand commands only at the start of a message. T3 commands  // change local state and do not have this restriction.  if (!input.atMessageStart) return items;  for (const command of input.selectedProviderStatus?.slashCommands ?? []) {    if (!command.name.toLowerCase().includes(query)) continue;    if (command.name === "compact" && !input.hasCompactableConversation) continue;    // T3's own limits command is answered by the thread composer; New Task has    // nowhere to show it. A provider's same-named command is left alone.    if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) {      continue;    }    if (      !input.hasThread &&      input.selectedProviderStatus?.driver === "codex" &&      command.name === "feedback"    ) {      continue;    }    items.push({      id: `pcmd:${command.name}`,      type: "provider-slash-command",      command,      label: `/${command.name}`,      description: command.description ?? "",    });  }  return items;} export function resolveComposerCommandSelection(input: {  readonly draftMessage: string;  readonly trigger: Pick<ComposerTrigger, "rangeStart" | "rangeEnd">;  readonly item: ComposerCommandItem;  readonly allowInteractionMode: boolean;}): {  readonly text: string;  readonly cursor: number;  readonly interactionMode: ProviderInteractionMode | null;} {  const { draftMessage, trigger, item } = input;  if (    input.allowInteractionMode &&    item.type === "slash-command" &&    (item.command === "plan" || item.command === "default")  ) {    return {      ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""),      interactionMode: item.command,    };  }   let replacement = "";  if (item.type === "path") {    replacement = `${serializeComposerFileLink(item.path)} `;  } else if (item.type === "skill") {    replacement = `$${item.skill.name} `;  } else if (item.type === "slash-command") {    replacement = `/${item.command} `;  } else if (item.type === "provider-slash-command") {    replacement = `/${item.command.name} `;  }  return {    ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement),    interactionMode: null,  };} /** Shared autocomplete for thread composers and unsent new-task drafts. */export function useComposerCommandMenu({  draftMessage,  ownerKey,  environmentId,  projectCwd,  selectedProviderStatus,  hasThread,  hasCompactableConversation,  offersUsageLimits = false,  enabled = true,  onChangeDraftMessage,  onUpdateInteractionMode,  onUsageLimits,}: {  readonly draftMessage: string;  readonly ownerKey: string | null;  readonly environmentId: EnvironmentId | null;  readonly projectCwd: string | null;  readonly selectedProviderStatus: ServerProvider | null;  readonly hasThread: boolean;  readonly hasCompactableConversation: boolean;  /** Whether T3 itself offers /usage-limits for the selected provider. */  readonly offersUsageLimits?: boolean;  readonly enabled?: boolean;  readonly onChangeDraftMessage: (value: string) => void;  readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;  /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */  readonly onUsageLimits?: () => void;}) {  const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));  const previousOwnerKeyRef = useRef(ownerKey);  const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => {    setSelection(nextSelection);  }, []);  useEffect(() => {    const end = draftMessage.length;    setSelection((current) => {      const start = Math.min(current.start, end);      const selectionEnd = Math.min(current.end, end);      if (start === current.start && selectionEnd === current.end) {        return current;      }      return { start, end: selectionEnd };    });  }, [draftMessage.length]);  useEffect(() => {    if (previousOwnerKeyRef.current === ownerKey) return;    previousOwnerKeyRef.current = ownerKey;    setSelection(composerSelectionAtEnd(draftMessage));  }, [draftMessage, ownerKey]);   const skills = useMemo(    () =>      selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [],    [projectCwd, selectedProviderStatus],  );  const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {    reportFailure: false,  });  const selectedProviderInstanceId = selectedProviderStatus?.instanceId;  const hasWorkspaceSnapshot = Boolean(    projectCwd &&    selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd),  );  const workspaceRefreshKeyRef = useRef<string | null>(null);  const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);  const hadWorkspaceSnapshotRef = useRef(false);  useEffect(() => {    if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {      workspaceRefreshKeyRef.current = null;      workspaceRefreshRetryRef.current = null;    }    hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;  }, [hasWorkspaceSnapshot]);  useEffect(() => {    if (!environmentId || !projectCwd || !selectedProviderInstanceId) return;    const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`;    if (workspaceRefreshKeyRef.current === key) return;    if (hasWorkspaceSnapshot) {      workspaceRefreshKeyRef.current = key;      workspaceRefreshRetryRef.current = null;      return;    }    const retry = workspaceRefreshRetryRef.current;    if (retry?.key === key && Date.now() < retry.notBefore) return;    workspaceRefreshKeyRef.current = key;    const retryLater = () => {      if (workspaceRefreshKeyRef.current !== key) return;      workspaceRefreshKeyRef.current = null;      workspaceRefreshRetryRef.current = {        key,        notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,      };    };    void refreshProviders({      environmentId,      input: { instanceId: selectedProviderInstanceId, cwd: projectCwd },    }).then((result) => {      const refreshed =        result._tag === "Success" &&        result.value.providers          .find((provider) => provider.instanceId === selectedProviderInstanceId)          ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd);      if (!refreshed && workspaceRefreshKeyRef.current === key) {        retryLater();      }    }, retryLater);  }, [    draftMessage,    environmentId,    hasWorkspaceSnapshot,    projectCwd,    refreshProviders,    selectedProviderInstanceId,  ]);   const trigger = useMemo(() => {    if (!enabled || selection.start !== selection.end) {      return null;    }    return detectComposerTrigger(draftMessage, selection.end);  }, [draftMessage, enabled, selection]);  const pathSearch = useComposerPathSearch({    environmentId,    cwd: trigger?.kind === "path" ? projectCwd : null,    query: trigger?.kind === "path" ? trigger.query : null,  });   const items = useMemo<ComposerCommandItem[]>(() => {    if (!trigger) return [];     if (trigger.kind === "slash-command") {      const q = trigger.query.toLowerCase();      const commandItems = buildComposerSlashCommandItems({        query: q,        atMessageStart: trigger.rangeStart === 0,        hasThread,        hasCompactableConversation,        offersUsageLimits,        allowInteractionMode: onUpdateInteractionMode !== undefined,        selectedProviderStatus,      });       const skillItems = getProviderSkillsForSlashMenu(skills, true)        .filter((skill) => matchesSlashSkillQuery(skill, q))        .map((skill) => ({          id: `skill:${skill.name}`,          type: "skill" as const,          skill,          label: `skill:${skill.name}`,          description: skill.shortDescription ?? skill.description ?? "",        }));       return [...commandItems, ...skillItems];    }     if (trigger.kind === "skill") {      const enabledSkills = dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable));      const normalizedQuery = normalizeSearchQuery(trigger.query, {        trimLeadingPattern: /^\$+/,      });       if (!normalizedQuery) {        return enabledSkills.slice(0, 20).map((skill) => ({          id: `skill:${skill.name}`,          type: "skill" as const,          skill,          label: skill.displayName ?? skill.name,          description: skill.shortDescription ?? skill.description ?? "",        }));      }       const ranked: Array<{        item: (typeof enabledSkills)[number];        score: number;        tieBreaker: string;      }> = [];      for (const skill of enabledSkills) {        const displayLabel = (skill.displayName ?? skill.name).toLowerCase();        const scores = [          scoreQueryMatch({            value: skill.name.toLowerCase(),            query: normalizedQuery,            exactBase: 0,            prefixBase: 2,            boundaryBase: 4,            includesBase: 6,            fuzzyBase: 100,            boundaryMarkers: ["-", "_", "/"],          }),          scoreQueryMatch({            value: displayLabel,            query: normalizedQuery,            exactBase: 1,            prefixBase: 3,            boundaryBase: 5,            includesBase: 7,            fuzzyBase: 110,          }),          scoreQueryMatch({            value: skill.shortDescription?.toLowerCase() ?? "",            query: normalizedQuery,            exactBase: 20,            prefixBase: 22,            boundaryBase: 24,            includesBase: 26,          }),          scoreQueryMatch({            value: skill.description?.toLowerCase() ?? "",            query: normalizedQuery,            exactBase: 30,            prefixBase: 32,            boundaryBase: 34,            includesBase: 36,          }),        ].filter((score): score is number => score !== null);         if (scores.length > 0) {          insertRankedSearchResult(            ranked,            {              item: skill,              score: Math.min(...scores),              tieBreaker: `${displayLabel}\u0000${skill.name}`,            },            20,          );        }      }       return ranked.map(({ item: skill }) => ({        id: `skill:${skill.name}`,        type: "skill" as const,        skill,        label: skill.displayName ?? skill.name,        description: skill.shortDescription ?? skill.description ?? "",      }));    }     if (trigger.kind === "path") {      return pathSearch.entries.map((entry) => {        const parts = entry.path.split("/");        return {          id: `path:${entry.path}`,          type: "path" as const,          path: entry.path,          kind: entry.kind,          label: parts[parts.length - 1] ?? entry.path,          description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",        };      });    }     return [];  }, [    hasThread,    hasCompactableConversation,    onUpdateInteractionMode,    pathSearch.entries,    selectedProviderStatus,    skills,    trigger,    offersUsageLimits,  ]);   const onSelect = useCallback(    (item: ComposerCommandItem) => {      if (!trigger) return;       if (        item.type === "provider-slash-command" &&        item.command.name === USAGE_LIMITS_COMMAND.name &&        onUsageLimits      ) {        const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");        setSelection({ start: cleared.cursor, end: cleared.cursor });        onChangeDraftMessage(cleared.text);        onUsageLimits();        return;      }       const result = resolveComposerCommandSelection({        draftMessage,        trigger,        item,        allowInteractionMode:          onUpdateInteractionMode !== undefined &&          selectedProviderStatus?.showInteractionModeToggle !== false,      });      setSelection({ start: result.cursor, end: result.cursor });      onChangeDraftMessage(result.text);      if (result.interactionMode !== null) {        onUpdateInteractionMode?.(result.interactionMode);      }    },    [      draftMessage,      onChangeDraftMessage,      onUpdateInteractionMode,      onUsageLimits,      selectedProviderStatus?.showInteractionModeToggle,      trigger,    ],  );   return {    selection,    onSelectionChange,    trigger,    items,    skills,    isLoading: pathSearch.isPending,    onSelect,  };}