feat(chat): show project and user skills as slash command suggestions
This commit is contained in:
parent
43ae8ae6bc
commit
15b2b655ea
8 changed files with 169 additions and 18 deletions
|
|
@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
|
|
@ -208,17 +209,21 @@ export const MessageComposer = ({
|
|||
|
||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
||||
// Project skills as slash command suggestions
|
||||
const projectSkills = useStore(
|
||||
useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
|
||||
);
|
||||
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
|
||||
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
|
||||
|
||||
// Fetch skills catalog for the team's project on mount / project change
|
||||
useEffect(() => {
|
||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||
}, [fetchSkillsCatalog, projectPath]);
|
||||
|
||||
const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
KNOWN_SLASH_COMMANDS.map((command) => ({
|
||||
id: `command:${command.name}`,
|
||||
name: command.name,
|
||||
command: command.command,
|
||||
description: command.description,
|
||||
subtitle: command.description,
|
||||
type: 'command',
|
||||
})),
|
||||
[]
|
||||
() => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills),
|
||||
[projectSkills, userSkills]
|
||||
);
|
||||
|
||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
|
||||
|
|
|
|||
|
|
@ -83,11 +83,12 @@ export const MentionSuggestionList = ({
|
|||
}
|
||||
|
||||
// Categorize suggestions (folders are grouped with files)
|
||||
type Section = 'member' | 'team' | 'task' | 'file' | 'command';
|
||||
type Section = 'member' | 'team' | 'task' | 'file' | 'command' | 'skill';
|
||||
const getSuggestionSection = (s: MentionSuggestion): Section => {
|
||||
if (s.type === 'file' || s.type === 'folder') return 'file';
|
||||
if (s.type === 'task') return 'task';
|
||||
if (s.type === 'command') return 'command';
|
||||
if (s.type === 'skill') return 'skill';
|
||||
if (s.type === 'team') return 'team';
|
||||
return 'member';
|
||||
};
|
||||
|
|
@ -98,6 +99,7 @@ export const MentionSuggestionList = ({
|
|||
task: 'Tasks',
|
||||
file: 'Files',
|
||||
command: 'Commands',
|
||||
skill: 'Skills',
|
||||
};
|
||||
|
||||
// Determine which sections are present
|
||||
|
|
@ -117,6 +119,7 @@ export const MentionSuggestionList = ({
|
|||
const isTeam = section === 'team';
|
||||
const isTask = section === 'task';
|
||||
const isCommand = section === 'command';
|
||||
const isSkill = section === 'skill';
|
||||
const taskTeamColorSet =
|
||||
isTask && s.color
|
||||
? getTeamColorSet(s.color)
|
||||
|
|
@ -165,6 +168,8 @@ export const MentionSuggestionList = ({
|
|||
<Hash size={13} className="shrink-0 text-blue-500 dark:text-blue-400" />
|
||||
) : isCommand ? (
|
||||
<Command size={13} className="shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
) : isSkill ? (
|
||||
<Command size={13} className="shrink-0 text-cyan-500 dark:text-cyan-400" />
|
||||
) : isTeam ? (
|
||||
<UsersRound
|
||||
size={13}
|
||||
|
|
@ -188,13 +193,21 @@ export const MentionSuggestionList = ({
|
|||
? { color: 'var(--color-link, #60a5fa)' }
|
||||
: isCommand
|
||||
? { color: 'rgb(245 158 11)' }
|
||||
: isSkill
|
||||
? { color: 'rgb(6 182 212)' }
|
||||
: colorSet
|
||||
? { color: getThemedText(colorSet, isLight) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<HighlightedName
|
||||
name={isTask ? `#${s.name}` : isCommand ? (s.command ?? `/${s.name}`) : s.name}
|
||||
name={
|
||||
isTask
|
||||
? `#${s.name}`
|
||||
: isCommand || isSkill
|
||||
? (s.command ?? `/${s.name}`)
|
||||
: s.name
|
||||
}
|
||||
query={query}
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -218,7 +231,7 @@ export const MentionSuggestionList = ({
|
|||
{isTask && s.subtitle ? (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{s.subtitle}</div>
|
||||
) : null}
|
||||
{isCommand && s.description ? (
|
||||
{(isCommand || isSkill) && s.description ? (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{s.description}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ export interface MentionSuggestion {
|
|||
description?: string;
|
||||
/** Color name from TeamColorSet palette */
|
||||
color?: string;
|
||||
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */
|
||||
type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command';
|
||||
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */
|
||||
type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill';
|
||||
/** Whether the team is currently online (team suggestions only) */
|
||||
isOnline?: boolean;
|
||||
/** Absolute file/folder path (file/folder suggestions only) */
|
||||
|
|
@ -21,7 +21,7 @@ export interface MentionSuggestion {
|
|||
insertText?: string;
|
||||
/** Optional extra searchable text (subject, team name, path, etc.) */
|
||||
searchText?: string;
|
||||
/** Optional slash command string including leading slash (command suggestions only) */
|
||||
/** Optional slash command string including leading slash (command and skill suggestions only) */
|
||||
command?: `/${string}`;
|
||||
/** Canonical task id (task suggestions only) */
|
||||
taskId?: string;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
|||
|
||||
export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' {
|
||||
if (suggestion.type === 'task') return '#';
|
||||
if (suggestion.type === 'command') return '/';
|
||||
if (suggestion.type === 'command' || suggestion.type === 'skill') return '/';
|
||||
return '@';
|
||||
}
|
||||
|
||||
export function getSuggestionInsertionText(suggestion: MentionSuggestion): string {
|
||||
if (suggestion.type === 'command') {
|
||||
if (suggestion.type === 'command' || suggestion.type === 'skill') {
|
||||
return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name;
|
||||
}
|
||||
return suggestion.insertText ?? suggestion.name;
|
||||
|
|
|
|||
48
src/renderer/utils/skillCommandSuggestions.ts
Normal file
48
src/renderer/utils/skillCommandSuggestions.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
|
||||
|
||||
export function buildSlashCommandSuggestions(
|
||||
builtIns: readonly KnownSlashCommandDefinition[],
|
||||
projectSkills: readonly SkillCatalogItem[],
|
||||
userSkills: readonly SkillCatalogItem[]
|
||||
): MentionSuggestion[] {
|
||||
const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
|
||||
id: `command:${command.name}`,
|
||||
name: command.name,
|
||||
command: command.command,
|
||||
description: command.description,
|
||||
subtitle: command.description,
|
||||
type: 'command',
|
||||
}));
|
||||
|
||||
const seenSkillNames = new Set<string>();
|
||||
const skillSuggestions: MentionSuggestion[] = [];
|
||||
for (const skill of [...projectSkills, ...userSkills]) {
|
||||
const normalizedFolderName = skill.folderName.trim().toLowerCase();
|
||||
if (
|
||||
!skill.isValid ||
|
||||
!normalizedFolderName ||
|
||||
!isSupportedSlashCommandName(normalizedFolderName) ||
|
||||
getKnownSlashCommand(normalizedFolderName) !== null ||
|
||||
seenSkillNames.has(normalizedFolderName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenSkillNames.add(normalizedFolderName);
|
||||
skillSuggestions.push({
|
||||
id: `skill:${skill.id}`,
|
||||
name: skill.folderName,
|
||||
command: `/${normalizedFolderName}`,
|
||||
description: skill.description,
|
||||
subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill',
|
||||
searchText: `${skill.name} ${skill.folderName}`,
|
||||
type: 'skill',
|
||||
});
|
||||
}
|
||||
|
||||
return [...builtInSuggestions, ...skillSuggestions];
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand {
|
|||
endIndex: number;
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9:-]{0,63}$/i;
|
||||
const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i;
|
||||
|
||||
export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [
|
||||
|
|
@ -78,6 +79,10 @@ export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition
|
|||
return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
export function isSupportedSlashCommandName(name: string): boolean {
|
||||
return SLASH_COMMAND_NAME_PATTERN.test(name.trim());
|
||||
}
|
||||
|
||||
export function buildSlashCommandMeta(
|
||||
name: string,
|
||||
args?: string,
|
||||
|
|
|
|||
72
test/renderer/utils/skillCommandSuggestions.test.ts
Normal file
72
test/renderer/utils/skillCommandSuggestions.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
|
||||
import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands';
|
||||
|
||||
import type { SkillCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
function createSkill(overrides: Partial<SkillCatalogItem>): SkillCatalogItem {
|
||||
return {
|
||||
id: overrides.id ?? 'skill-id',
|
||||
sourceType: 'filesystem',
|
||||
name: overrides.name ?? 'Skill Name',
|
||||
description: overrides.description ?? 'Skill description',
|
||||
folderName: overrides.folderName ?? 'skill-name',
|
||||
scope: overrides.scope ?? 'project',
|
||||
rootKind: overrides.rootKind ?? 'claude',
|
||||
projectRoot: overrides.projectRoot ?? '/tmp/project',
|
||||
discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills',
|
||||
skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name',
|
||||
skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md',
|
||||
metadata: overrides.metadata ?? {},
|
||||
invocationMode: overrides.invocationMode ?? 'manual-only',
|
||||
flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false },
|
||||
isValid: overrides.isValid ?? true,
|
||||
issues: overrides.issues ?? [],
|
||||
modifiedAt: overrides.modifiedAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildSlashCommandSuggestions', () => {
|
||||
it('keeps built-ins and adds valid skills in a separate suggestion type', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [
|
||||
createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }),
|
||||
], []);
|
||||
|
||||
expect(suggestions[0]?.type).toBe('command');
|
||||
expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true);
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({
|
||||
name: 'review-skill',
|
||||
command: '/review-skill',
|
||||
subtitle: 'Project skill',
|
||||
type: 'skill',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters slash-unsafe names and built-in collisions', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
[
|
||||
createSkill({ id: 'unsafe', folderName: 'bad skill' }),
|
||||
createSkill({ id: 'collision', folderName: 'plan' }),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:unsafe')).toBeUndefined();
|
||||
expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers project skills when user and project skills share the same slash name', () => {
|
||||
const suggestions = buildSlashCommandSuggestions(
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
[createSkill({ id: 'project', folderName: 'shared-skill', scope: 'project' })],
|
||||
[createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })]
|
||||
);
|
||||
|
||||
expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1);
|
||||
expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe(
|
||||
'skill:project'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
getKnownSlashCommand,
|
||||
isSupportedSlashCommandName,
|
||||
KNOWN_SLASH_COMMANDS,
|
||||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
|
|
@ -53,4 +54,11 @@ describe('slashCommands', () => {
|
|||
expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model');
|
||||
expect(getKnownSlashCommand('foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates slash-compatible command names', () => {
|
||||
expect(isSupportedSlashCommandName('review')).toBe(true);
|
||||
expect(isSupportedSlashCommandName('skill:name')).toBe(true);
|
||||
expect(isSupportedSlashCommandName('my_skill')).toBe(false);
|
||||
expect(isSupportedSlashCommandName('my skill')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue