feat(chat): show project and user skills as slash command suggestions

This commit is contained in:
Diego Serrano 2026-04-14 07:37:27 -04:00 committed by GitHub
parent 7d98956dad
commit 5375eea19f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 169 additions and 18 deletions

View file

@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
import { import {
extractTaskRefsFromText, extractTaskRefsFromText,
stripEncodedTaskReferenceMetadata, stripEncodedTaskReferenceMetadata,
@ -208,17 +209,21 @@ export const MessageComposer = ({
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(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[]>( const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
() => () => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills),
KNOWN_SLASH_COMMANDS.map((command) => ({ [projectSkills, userSkills]
id: `command:${command.name}`,
name: command.name,
command: command.command,
description: command.description,
subtitle: command.description,
type: 'command',
})),
[]
); );
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();

View file

@ -83,11 +83,12 @@ export const MentionSuggestionList = ({
} }
// Categorize suggestions (folders are grouped with files) // 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 => { const getSuggestionSection = (s: MentionSuggestion): Section => {
if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'file' || s.type === 'folder') return 'file';
if (s.type === 'task') return 'task'; if (s.type === 'task') return 'task';
if (s.type === 'command') return 'command'; if (s.type === 'command') return 'command';
if (s.type === 'skill') return 'skill';
if (s.type === 'team') return 'team'; if (s.type === 'team') return 'team';
return 'member'; return 'member';
}; };
@ -98,6 +99,7 @@ export const MentionSuggestionList = ({
task: 'Tasks', task: 'Tasks',
file: 'Files', file: 'Files',
command: 'Commands', command: 'Commands',
skill: 'Skills',
}; };
// Determine which sections are present // Determine which sections are present
@ -117,6 +119,7 @@ export const MentionSuggestionList = ({
const isTeam = section === 'team'; const isTeam = section === 'team';
const isTask = section === 'task'; const isTask = section === 'task';
const isCommand = section === 'command'; const isCommand = section === 'command';
const isSkill = section === 'skill';
const taskTeamColorSet = const taskTeamColorSet =
isTask && s.color isTask && s.color
? getTeamColorSet(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" /> <Hash size={13} className="shrink-0 text-blue-500 dark:text-blue-400" />
) : isCommand ? ( ) : isCommand ? (
<Command size={13} className="shrink-0 text-amber-500 dark:text-amber-400" /> <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 ? ( ) : isTeam ? (
<UsersRound <UsersRound
size={13} size={13}
@ -188,13 +193,21 @@ export const MentionSuggestionList = ({
? { color: 'var(--color-link, #60a5fa)' } ? { color: 'var(--color-link, #60a5fa)' }
: isCommand : isCommand
? { color: 'rgb(245 158 11)' } ? { color: 'rgb(245 158 11)' }
: isSkill
? { color: 'rgb(6 182 212)' }
: colorSet : colorSet
? { color: getThemedText(colorSet, isLight) } ? { color: getThemedText(colorSet, isLight) }
: undefined : undefined
} }
> >
<HighlightedName <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} query={query}
/> />
</span> </span>
@ -218,7 +231,7 @@ export const MentionSuggestionList = ({
{isTask && s.subtitle ? ( {isTask && s.subtitle ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{s.subtitle}</div> <div className="truncate text-[10px] text-[var(--color-text-muted)]">{s.subtitle}</div>
) : null} ) : null}
{isCommand && s.description ? ( {(isCommand || isSkill) && s.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]"> <div className="truncate text-[10px] text-[var(--color-text-muted)]">
{s.description} {s.description}
</div> </div>

View file

@ -9,8 +9,8 @@ export interface MentionSuggestion {
description?: string; description?: string;
/** Color name from TeamColorSet palette */ /** Color name from TeamColorSet palette */
color?: string; color?: string;
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */ /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */
type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command'; type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill';
/** Whether the team is currently online (team suggestions only) */ /** Whether the team is currently online (team suggestions only) */
isOnline?: boolean; isOnline?: boolean;
/** Absolute file/folder path (file/folder suggestions only) */ /** Absolute file/folder path (file/folder suggestions only) */
@ -21,7 +21,7 @@ export interface MentionSuggestion {
insertText?: string; insertText?: string;
/** Optional extra searchable text (subject, team name, path, etc.) */ /** Optional extra searchable text (subject, team name, path, etc.) */
searchText?: string; 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}`; command?: `/${string}`;
/** Canonical task id (task suggestions only) */ /** Canonical task id (task suggestions only) */
taskId?: string; taskId?: string;

View file

@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention';
export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' { export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' {
if (suggestion.type === 'task') return '#'; if (suggestion.type === 'task') return '#';
if (suggestion.type === 'command') return '/'; if (suggestion.type === 'command' || suggestion.type === 'skill') return '/';
return '@'; return '@';
} }
export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { 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.command?.slice(1) ?? suggestion.insertText ?? suggestion.name;
} }
return suggestion.insertText ?? suggestion.name; return suggestion.insertText ?? suggestion.name;

View 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];
}

View file

@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand {
endIndex: number; 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; const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i;
export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [ 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; 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( export function buildSlashCommandMeta(
name: string, name: string,
args?: string, args?: string,

View 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'
);
});
});

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
getKnownSlashCommand, getKnownSlashCommand,
isSupportedSlashCommandName,
KNOWN_SLASH_COMMANDS, KNOWN_SLASH_COMMANDS,
parseStandaloneSlashCommand, parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands'; } from '@shared/utils/slashCommands';
@ -53,4 +54,11 @@ describe('slashCommands', () => {
expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model'); expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model');
expect(getKnownSlashCommand('foo')).toBeNull(); 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);
});
}); });