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