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 777genius
parent 43ae8ae6bc
commit 15b2b655ea
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 { 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();

View file

@ -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>

View file

@ -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;

View file

@ -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;

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;
}
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,

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 {
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);
});
});