diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index e12c8d12..e51ca5e2 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -704,4 +704,45 @@ describe('ChatInput file mentions', () => { expect(fileSearchMenu).not.toHaveClass('min-w-[480px]') expect(fileSearchMenu).not.toHaveTextContent('Navigate') }) + + it('prioritizes active-session slash commands by command name when filtering', async () => { + useChatStore.setState({ + sessions: { + [sessionId]: { + ...useChatStore.getState().sessions[sessionId]!, + slashCommands: [ + { + name: 'agent-team-orchestrator', + description: 'Agent Teams can use Subagent orchestration.', + }, + { + name: 'lark-calendar', + description: 'Includes suggestion helpers.', + }, + { + name: 'superpowers:brainstorming', + description: 'Creative work planning.', + }, + ], + }, + }, + }) + + render() + + await waitFor(() => { + expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId) + }) + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '/su', selectionStart: 3 }, + }) + + await waitFor(() => { + const commandButtons = screen + .getAllByRole('button') + .filter((button) => button.textContent?.startsWith('/')) + expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming') + }) + }) }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 7b0b89dd..579e7b26 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -24,6 +24,7 @@ import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlash import { ContextUsageIndicator } from './ContextUsageIndicator' import { FALLBACK_SLASH_COMMANDS, + filterSlashCommands, findSlashTrigger, mergeSlashCommands, replaceSlashToken, @@ -343,14 +344,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ) const filteredCommands = useMemo(() => { - const source = allSlashCommands - if (!slashFilter) return source - const lower = slashFilter.toLowerCase() - return source.filter((command) => ( - command.name.toLowerCase().includes(lower) || - command.description.toLowerCase().includes(lower) || - command.argumentHint?.toLowerCase().includes(lower) - )) + return filterSlashCommands(allSlashCommands, slashFilter) }, [allSlashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 763b2f4b..2d9b569a 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + filterSlashCommands, findSlashToken, insertSlashTrigger, mergeSlashCommands, @@ -75,6 +76,22 @@ describe('composerUtils', () => { ) }) + it('ranks slash command name matches before broad description matches', () => { + expect( + filterSlashCommands([ + { name: 'lark-calendar', description: 'Includes shortcuts and suggestion helpers' }, + { name: 'agent-team-orchestrator', description: 'Uses Subagent orchestration' }, + { name: 'superpowers:brainstorming', description: 'Creative work planning' }, + { name: 'superpowers:systematic-debugging', description: 'Debug unexpected behavior' }, + ], 'su').map((command) => command.name), + ).toEqual([ + 'superpowers:brainstorming', + 'superpowers:systematic-debugging', + 'lark-calendar', + 'agent-team-orchestrator', + ]) + }) + it('resolves hidden settings aliases without displaying duplicate fallback rows', () => { expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 94ba1f0f..d56f9f9d 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -108,6 +108,39 @@ export function mergeSlashCommands( return [...merged.values()] } +function getSlashCommandMatchRank(command: SlashCommandOption, filter: string): number { + const name = command.name.toLowerCase() + const description = command.description.toLowerCase() + const argumentHint = command.argumentHint?.toLowerCase() ?? '' + const nameParts = name.split(/[:/._-]+/).filter(Boolean) + + if (name === filter) return 0 + if (name.startsWith(filter)) return 1 + if (nameParts.some((part) => part.startsWith(filter))) return 2 + if (name.includes(filter)) return 3 + if (description.includes(filter)) return 4 + if (argumentHint.includes(filter)) return 5 + return Number.POSITIVE_INFINITY +} + +export function filterSlashCommands( + commands: ReadonlyArray, + filter: string, +): SlashCommandOption[] { + const normalized = filter.trim().toLowerCase() + if (!normalized) return [...commands] + + return commands + .map((command, index) => ({ + command, + index, + rank: getSlashCommandMatchRank(command, normalized), + })) + .filter((item) => Number.isFinite(item.rank)) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map((item) => item.command) +} + export type SlashTrigger = { slashPos: number filter: string diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index dfa90160..0d7e0799 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -246,6 +246,45 @@ describe('EmptySession', () => { }) }) + it('prioritizes enabled plugin slash commands by command name when filtering', async () => { + mocks.listSkills.mockResolvedValueOnce({ + skills: [ + { + name: 'agent-team-orchestrator', + description: 'Agent Teams can use Subagent orchestration.', + userInvocable: true, + }, + { + name: 'lark-calendar', + description: 'Includes suggestion helpers.', + userInvocable: true, + }, + { + name: 'superpowers:brainstorming', + description: 'Creative work planning.', + userInvocable: true, + }, + ], + }) + + render() + + await waitFor(() => { + expect(mocks.listSkills).toHaveBeenCalledTimes(1) + }) + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '/su', selectionStart: 3 }, + }) + + await waitFor(() => { + const commandButtons = screen + .getAllByRole('button') + .filter((button) => button.textContent?.startsWith('/')) + expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming') + }) + }) + it('integrates repository launch controls into the desktop composer panel', async () => { render() diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 64e02fd1..a2ba8154 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -25,6 +25,7 @@ import { } from '../lib/composerAttachments' import { FALLBACK_SLASH_COMMANDS, + filterSlashCommands, findSlashToken, insertSlashTrigger, mergeSlashCommands, @@ -215,13 +216,7 @@ export function EmptySession() { } const filteredCommands = useMemo(() => { - const source = allSlashCommands - if (!slashFilter) return source - const lower = slashFilter.toLowerCase() - return source.filter((command) => ( - command.name.toLowerCase().includes(lower) || - command.description.toLowerCase().includes(lower) - )) + return filterSlashCommands(allSlashCommands, slashFilter) }, [allSlashCommands, slashFilter]) const exactSlashCommand = useMemo(() => { diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index f8bfb1d0..8300c88b 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -15,6 +15,9 @@ import { import { conversationService } from '../services/conversationService.js' import { clearCommandsCache } from '../../commands.js' import { sanitizePath } from '../../utils/sessionStoragePortable.js' +import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js' +import { clearPluginCache } from '../../utils/plugins/pluginLoader.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' // ============================================================================ // Test helpers @@ -397,10 +400,16 @@ describe('SessionService', () => { beforeEach(async () => { await setupTmpConfigDir() service = new SessionService() + clearInstalledPluginsCache() + clearPluginCache('sessions-api-test-setup') + resetSettingsCache() }) afterEach(async () => { clearCommandsCache() + clearInstalledPluginsCache() + clearPluginCache('session-service-test-teardown') + resetSettingsCache() await cleanupTmpDir() }) @@ -1481,6 +1490,9 @@ describe('Sessions API', () => { server.stop(true) server = null } + clearInstalledPluginsCache() + clearPluginCache('sessions-api-test-teardown') + resetSettingsCache() await cleanupTmpDir() }) @@ -2026,6 +2038,95 @@ describe('Sessions API', () => { ) }) + it('GET /api/sessions/:id/slash-commands should include enabled plugin skills before CLI init', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff' + const workDir = path.join(tmpDir, 'workspace', 'app') + const marketplaceRoot = path.join(tmpDir, 'marketplace-root') + const pluginRoot = path.join(marketplaceRoot, 'plugins', 'superpowers') + const pluginsDir = path.join(tmpDir, 'plugins') + const marketplaceFile = path.join( + marketplaceRoot, + '.claude-plugin', + 'marketplace.json', + ) + + await fs.mkdir(path.join(pluginRoot, '.claude-plugin'), { recursive: true }) + await fs.mkdir(path.dirname(marketplaceFile), { recursive: true }) + await fs.mkdir(pluginsDir, { recursive: true }) + await fs.mkdir(workDir, { recursive: true }) + await writeSkill( + path.join(pluginRoot, 'skills'), + 'brainstorming', + 'Superpowers brainstorming skill', + ) + await fs.writeFile( + path.join(pluginRoot, '.claude-plugin', 'plugin.json'), + JSON.stringify({ + name: 'superpowers', + version: '5.0.7', + description: 'Core skills library', + }), + 'utf-8', + ) + await fs.writeFile( + marketplaceFile, + JSON.stringify({ + name: 'claude-plugins-official', + owner: { name: 'Test' }, + plugins: [ + { + name: 'superpowers', + source: './plugins/superpowers', + version: '5.0.7', + }, + ], + }), + 'utf-8', + ) + await fs.writeFile( + path.join(pluginsDir, 'known_marketplaces.json'), + JSON.stringify({ + 'claude-plugins-official': { + source: { source: 'directory', path: marketplaceRoot }, + installLocation: marketplaceRoot, + lastUpdated: new Date(0).toISOString(), + }, + }), + 'utf-8', + ) + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + enabledPlugins: { + 'superpowers@claude-plugins-official': true, + }, + }), + 'utf-8', + ) + + resetSettingsCache() + clearPluginCache('sessions-api-plugin-skills') + clearCommandsCache() + await writeSessionFile('-tmp-api-test', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry(workDir), + ]) + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + commands: Array<{ name: string; description: string }> + } + + expect(body.commands).toContainEqual( + expect.objectContaining({ + name: 'superpowers:brainstorming', + description: 'Superpowers brainstorming skill', + }), + ) + }) + it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index c8b8e48b..4fe19747 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -19,8 +19,7 @@ import { sessionService } from '../services/sessionService.js' import { conversationService } from '../services/conversationService.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { closeSessionConnection, getSlashCommands } from '../ws/handler.js' -import { getCommandName } from '../../commands.js' -import { getSkillDirCommands } from '../../skills/loadSkillsDir.js' +import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js' import { WorkspaceService } from '../services/workspaceService.js' import { getRepositoryContext, @@ -426,24 +425,43 @@ function cleanupAdapterSessionMappings(sessionId: string): void { } } -async function getSessionSlashCommands(sessionId: string): Promise { - const cachedCommands = getSlashCommands(sessionId) - if (cachedCommands.length > 0) { - return Response.json({ commands: cachedCommands }) +function mergeSessionSlashCommands( + preferred: Array<{ name: string; description?: string; argumentHint?: string }>, + fallback: SkillSlashCommand[], +): Array<{ name: string; description: string; argumentHint?: string }> { + const merged = new Map() + + for (const command of preferred) { + if (!command.name) continue + merged.set(command.name, { + name: command.name, + description: command.description || '', + ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), + }) } + for (const command of fallback) { + if (!command.name || merged.has(command.name)) continue + merged.set(command.name, { + name: command.name, + description: command.description || '', + }) + } + + return [...merged.values()] +} + +async function getSessionSlashCommands(sessionId: string): Promise { + const cachedCommands = getSlashCommands(sessionId) const workDir = await sessionService.getSessionWorkDir(sessionId) if (!workDir) { throw ApiError.notFound(`Session not found: ${sessionId}`) } - const commands = await getSkillDirCommands(workDir) - const slashCommands = commands - .filter((command) => command.userInvocable !== false) - .map((command) => ({ - name: getCommandName(command), - description: command.description || '', - })) + const skillCommands = await listSkillSlashCommands(workDir) + const slashCommands = cachedCommands.length > 0 + ? mergeSessionSlashCommands(cachedCommands, skillCommands) + : skillCommands return Response.json({ commands: slashCommands }) } @@ -466,14 +484,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise message?.type === 'system' && message.subtype === 'init') const transcriptMetadata = await sessionService.getTranscriptMetadata(sessionId) const cachedSlashCommands = getSlashCommands(sessionId) + const skillSlashCommands = await listSkillSlashCommands(workDir) const fallbackSlashCommands = cachedSlashCommands.length > 0 - ? cachedSlashCommands - : (await getSkillDirCommands(workDir)) - .filter((command) => command.userInvocable !== false) - .map((command) => ({ - name: getCommandName(command), - description: command.description || '', - })) + ? mergeSessionSlashCommands(cachedSlashCommands, skillSlashCommands) + : skillSlashCommands const slashCommandCount = Array.isArray(initMessage?.slash_commands) ? initMessage.slash_commands.length : fallbackSlashCommands.length diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 59233791..1d268588 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -12,7 +12,7 @@ import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js' import { getCwd } from '../../utils/cwd.js' -import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' +import { loadAllPlugins, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' import type { LoadedPlugin } from '../../types/plugin.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' @@ -275,6 +275,11 @@ type PluginSkillLocation = { pluginName: string } +export type SkillSlashCommand = { + name: string + description: string +} + function buildPluginSkillName(pluginName: string, skillDir: string): string { return `${pluginName}:${path.basename(skillDir)}` } @@ -285,7 +290,11 @@ async function collectPluginSkillDirectories(): Promise error.type === 'plugin-cache-miss')) { + enabledPlugins = (await loadAllPlugins()).enabled + } else { + enabledPlugins = result.enabled + } } catch { return locations } @@ -359,6 +368,27 @@ async function collectPluginSkills(): Promise { return skills } +async function collectAllSkills(cwd?: string): Promise { + const [userSkills, projectSkills, pluginSkills] = await Promise.all([ + collectSkillsFromRoots([getUserSkillsDir()], 'user'), + collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'), + collectPluginSkills(), + ]) + + const skills = [...userSkills, ...projectSkills, ...pluginSkills] + skills.sort((a, b) => a.name.localeCompare(b.name)) + return skills +} + +export async function listSkillSlashCommands(cwd?: string): Promise { + return (await collectAllSkills(cwd)) + .filter((skill) => skill.userInvocable) + .map((skill) => ({ + name: skill.name, + description: skill.description || '', + })) +} + // ─── Router ────────────────────────────────────────────────────────────────── export async function handleSkillsApi( @@ -390,14 +420,7 @@ export async function handleSkillsApi( async function listSkills(url: URL): Promise { const cwd = getRequestedCwd(url) - const [userSkills, projectSkills, pluginSkills] = await Promise.all([ - collectSkillsFromRoots([getUserSkillsDir()], 'user'), - collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'), - collectPluginSkills(), - ]) - - const skills = [...userSkills, ...projectSkills, ...pluginSkills] - skills.sort((a, b) => a.name.localeCompare(b.name)) + const skills = await collectAllSkills(cwd) return Response.json({ skills }) }