feat(chat): show project and user skills as slash command suggestions
This commit is contained in:
parent
7d98956dad
commit
5375eea19f
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 { 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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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;
|
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,
|
||||||
|
|
|
||||||
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 {
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue