返回 DeepSeek-Reasonix
SlashMenu.tsx
根目录 / desktop / frontend / src / components / SlashMenu.tsx
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
125 lines Plain Text