382 lines · 12.5 KB · TypeScript
RawDownload
import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";import {  detectComposerTrigger,  replaceTextRange,  serializeComposerFileLink,} 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; export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection {  return { start: draftMessage.length, end: draftMessage.length };} /** Shared autocomplete for thread composers and unsent new-task drafts. */export function useComposerCommandMenu({  draftMessage,  ownerKey,  environmentId,  projectCwd,  selectedProviderStatus,  hasThread,  enabled = true,  onChangeDraftMessage,  onUpdateInteractionMode,}: {  readonly draftMessage: string;  readonly ownerKey: string | null;  readonly environmentId: EnvironmentId | null;  readonly projectCwd: string | null;  readonly selectedProviderStatus: ServerProvider | null;  readonly hasThread: boolean;  readonly enabled?: boolean;  readonly onChangeDraftMessage: (value: string) => void;  readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => 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 slashCommands = selectedProviderStatus?.slashCommands ?? [];  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 allBuiltIn = [        {          id: "cmd:model",          type: "slash-command" as const,          command: "model",          label: "/model",          description: "Switch model",        },        {          id: "cmd:plan",          type: "slash-command" as const,          command: "plan",          label: "/plan",          description: "Switch to plan mode",        },        {          id: "cmd:default",          type: "slash-command" as const,          command: "default",          label: "/default",          description: "Switch to default mode",        },      ];      const builtIn = allBuiltIn.filter(        (item) =>          item.command.includes(q) &&          (item.command === "model" || onUpdateInteractionMode !== undefined),      );       // A provider expands a slash command only when it opens the whole      // message; elsewhere it arrives as literal text. Built-ins apply      // locally and skills insert a `$` mention the server dispatches from      // any position, so only provider commands are position-gated.      const providerCommands: ComposerCommandItem[] = [];      const expandableCommands = trigger.rangeStart === 0 ? slashCommands : [];      for (const command of expandableCommands) {        if (!command.name.toLowerCase().includes(q)) continue;        // Codex feedback uploads an existing thread's session and logs.        if (          !hasThread &&          selectedProviderStatus?.driver === "codex" &&          command.name === "feedback"        ) {          continue;        }        providerCommands.push({          id: `pcmd:${command.name}`,          type: "provider-slash-command",          command,          label: `/${command.name}`,          description: command.description ?? "",        });      }       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 [...builtIn, ...providerCommands, ...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,    onUpdateInteractionMode,    pathSearch.entries,    selectedProviderStatus,    skills,    slashCommands,    trigger,  ]);   const onSelect = useCallback(    (item: ComposerCommandItem) => {      if (!trigger) return;       if (        item.type === "slash-command" &&        (item.command === "plan" || item.command === "default")      ) {        const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");        setSelection({ start: result.cursor, end: result.cursor });        onChangeDraftMessage(result.text);        onUpdateInteractionMode?.(item.command);        return;      }       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} `;      }       const result = replaceTextRange(        draftMessage,        trigger.rangeStart,        trigger.rangeEnd,        replacement,      );      setSelection({ start: result.cursor, end: result.cursor });      onChangeDraftMessage(result.text);    },    [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger],  );   return {    selection,    onSelectionChange,    trigger,    items,    skills,    isLoading: pathSearch.isPending,    onSelect,  };}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t