| 1 | import { useMemo } from "react"; |
| 2 | import { useT, type Translator } from "../lib/i18n"; |
| 3 | import type { CommandInfo } from "../lib/types"; |
| 4 | import { VirtualMenu } from "./VirtualMenu"; |
| 5 | |
| 6 | export function slashCommandKindTag(command: CommandInfo, t: Translator): string { |
| 7 | if (command.plugin) { |
| 8 | return t("slash.plugin", { name: command.plugin }); |
| 9 | } |
| 10 | if (command.kind === "custom") return t("slash.project"); |
| 11 | if (command.kind === "mcp") return t("slash.mcp"); |
| 12 | if (command.kind === "subagent") return t("slash.subagent"); |
| 13 | if (command.kind === "skill") return t("slash.skill"); |
| 14 | return ""; |
| 15 | } |
| 16 | |
| 17 | type SlashCommandGroup = "actions" | "management" | "subagents" | "skills" | "integrations"; |
| 18 | |
| 19 | type SlashMenuRow = |
| 20 | | { type: "group"; group: SlashCommandGroup; label: string } |
| 21 | | { type: "command"; command: CommandInfo; commandIndex: number }; |
| 22 | |
| 23 | function slashMenuRowKey(row: SlashMenuRow): string { |
| 24 | return row.type === "group" ? `group:${row.group}` : `${row.command.kind}:${row.command.name}`; |
| 25 | } |
| 26 | |
| 27 | const slashCommandGroups: SlashCommandGroup[] = ["actions", "subagents", "skills", "integrations", "management"]; |
| 28 | const slashCommandGroupOrder = new Map(slashCommandGroups.map((group, index) => [group, index])); |
| 29 | const fallbackQuickActions = new Set(["new", "clear", "compact", "model", "effort", "goal"]); |
| 30 | |
| 31 | export function slashCommandGroup(command: CommandInfo): SlashCommandGroup { |
| 32 | if (command.group && slashCommandGroupOrder.has(command.group)) return command.group; |
| 33 | if (command.kind === "subagent") return "subagents"; |
| 34 | if (command.kind === "mcp") return "integrations"; |
| 35 | if (command.kind === "skill" || command.kind === "custom") return "skills"; |
| 36 | return fallbackQuickActions.has(command.name) ? "actions" : "management"; |
| 37 | } |
| 38 | |
| 39 | export function sortSlashCommandsForMenu(commands: CommandInfo[]): CommandInfo[] { |
| 40 | return commands |
| 41 | .map((command, index) => ({ command, index })) |
| 42 | .sort((a, b) => { |
| 43 | const groupDelta = (slashCommandGroupOrder.get(slashCommandGroup(a.command)) ?? 0) |
| 44 | - (slashCommandGroupOrder.get(slashCommandGroup(b.command)) ?? 0); |
| 45 | return groupDelta || a.index - b.index; |
| 46 | }) |
| 47 | .map(({ command }) => command); |
| 48 | } |
| 49 | |
| 50 | // SlashMenu is the "/" autocomplete dropdown above the composer. Presentational: |
| 51 | // the Composer owns filtering, the active index, and key handling; this renders |
| 52 | // the (virtualized) list and reports hover/pick. Uses mousedown (not click) so |
| 53 | // picking an item doesn't blur the textarea first. |
| 54 | export function SlashMenu({ |
| 55 | items, |
| 56 | activeIndex, |
| 57 | onPick, |
| 58 | onHover, |
| 59 | isDisabled, |
| 60 | disabledReason, |
| 61 | }: { |
| 62 | items: CommandInfo[]; |
| 63 | activeIndex: number; |
| 64 | onPick: (c: CommandInfo) => void; |
| 65 | onHover: (i: number) => void; |
| 66 | isDisabled?: (c: CommandInfo) => boolean; |
| 67 | disabledReason?: string; |
| 68 | }) { |
| 69 | const t = useT(); |
| 70 | const rows = useMemo<SlashMenuRow[]>(() => { |
| 71 | const grouped = new Map<SlashCommandGroup, Array<{ command: CommandInfo; commandIndex: number }>>(); |
| 72 | items.forEach((command, commandIndex) => { |
| 73 | const group = slashCommandGroup(command); |
| 74 | const groupItems = grouped.get(group) ?? []; |
| 75 | groupItems.push({ command, commandIndex }); |
| 76 | grouped.set(group, groupItems); |
| 77 | }); |
| 78 | return slashCommandGroups.flatMap((group) => { |
| 79 | const groupItems = grouped.get(group); |
| 80 | if (!groupItems?.length) return []; |
| 81 | return [ |
| 82 | { type: "group" as const, group, label: t(`slash.group.${group}`) }, |
| 83 | ...groupItems.map(({ command, commandIndex }) => ({ type: "command" as const, command, commandIndex })), |
| 84 | ]; |
| 85 | }); |
| 86 | }, [items, t]); |
| 87 | const activeRowIndex = rows.findIndex((row) => row.type === "command" && row.commandIndex === activeIndex); |
| 88 | return ( |
| 89 | <VirtualMenu |
| 90 | items={rows} |
| 91 | activeIndex={activeRowIndex} |
| 92 | itemKey={slashMenuRowKey} |
| 93 | estimateSize={(row) => row.type === "group" ? 26 : 34} |
| 94 | renderItem={(row) => row.type === "group" ? ( |
| 95 | <div className="slashmenu__group" role="separator" aria-label={row.label}> |
| 96 | {row.label} |
| 97 | </div> |
| 98 | ) : ( |
| 99 | <button |
| 100 | role="option" |
| 101 | aria-selected={row.commandIndex === activeIndex} |
| 102 | aria-disabled={isDisabled?.(row.command) || undefined} |
| 103 | className={`slashmenu__item ${row.commandIndex === activeIndex ? "slashmenu__item--active" : ""}${isDisabled?.(row.command) ? " slashmenu__item--disabled" : ""}`} |
| 104 | disabled={isDisabled?.(row.command)} |
| 105 | onMouseDown={(e) => { |
| 106 | e.preventDefault(); |
| 107 | if (isDisabled?.(row.command)) return; |
| 108 | onPick(row.command); |
| 109 | }} |
| 110 | onMouseMove={() => { |
| 111 | if (!isDisabled?.(row.command)) onHover(row.commandIndex); |
| 112 | }} |
| 113 | > |
| 114 | <span className="slashmenu__name">/{row.command.name}</span> |
| 115 | {row.command.hint && <span className="slashmenu__hint">{row.command.hint}</span>} |
| 116 | <span className="slashmenu__desc"> |
| 117 | {isDisabled?.(row.command) ? disabledReason : row.command.description} |
| 118 | </span> |
| 119 | {slashCommandKindTag(row.command, t) && <span className="slashmenu__kind">{slashCommandKindTag(row.command, t)}</span>} |
| 120 | </button> |
| 121 | )} |
| 122 | /> |
| 123 | ); |
| 124 | } |
| 125 |