Files
hapi/web/src/hooks/queries/useSlashCommands.ts
T
JlovecandGitHub ef3328f48f fix(cli): support project slash command completion (#245)
Add project-level slash command discovery with recursive nested command scanning, pass workingDirectory through slash-command handlers, and align hub/web source unions to include project commands.
2026-03-05 12:50:59 +08:00

148 lines
6.4 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
import type { ApiClient } from '@/api/client'
import type { SlashCommand } from '@/types/api'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { queryKeys } from '@/lib/query-keys'
function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length
if (b.length === 0) return a.length
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) matrix[i] = [i]
for (let j = 0; j <= a.length; j++) matrix[0][j] = j
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
matrix[i][j] = b[i - 1] === a[j - 1]
? matrix[i - 1][j - 1]
: Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
}
}
return matrix[b.length][a.length]
}
/**
* Built-in slash commands per agent type.
* These are shown immediately without waiting for RPC.
*/
const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
claude: [
{ name: 'clear', description: 'Clear conversation history and free up context', source: 'builtin' },
{ name: 'compact', description: 'Clear conversation history but keep a summary in context', source: 'builtin' },
{ name: 'context', description: 'Visualize current context usage as a colored grid', source: 'builtin' },
{ name: 'cost', description: 'Show the total cost and duration of the current session', source: 'builtin' },
{ name: 'doctor', description: 'Diagnose and verify your Claude Code installation and settings', source: 'builtin' },
{ name: 'plan', description: 'View or open the current session plan', source: 'builtin' },
{ name: 'stats', description: 'Show your Claude Code usage statistics and activity', source: 'builtin' },
{ name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' },
],
codex: [
{ name: 'review', description: 'Review current changes and find issues', source: 'builtin' },
{ name: 'new', description: 'Start a new chat during a conversation', source: 'builtin' },
{ name: 'compat', description: 'Summarize conversation to prevent hitting the context limit', source: 'builtin' },
{ name: 'undo', description: 'Ask Codex to undo a turn', source: 'builtin' },
{ name: 'diff', description: 'Show git diff including untracked files', source: 'builtin' },
{ name: 'status', description: 'Show current session configuration and token usage', source: 'builtin' },
],
gemini: [
{ name: 'about', description: 'Show version info', source: 'builtin' },
{ name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' },
{ name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' },
{ name: 'stats', description: 'Check session stats', source: 'builtin' },
],
opencode: [],
}
export function useSlashCommands(
api: ApiClient | null,
sessionId: string | null,
agentType: string = 'claude'
): {
commands: SlashCommand[]
isLoading: boolean
error: string | null
getSuggestions: (query: string) => Promise<Suggestion[]>
} {
const resolvedSessionId = sessionId ?? 'unknown'
// Fetch user-defined commands from the CLI (requires active session)
const query = useQuery({
queryKey: queryKeys.slashCommands(resolvedSessionId),
queryFn: async () => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
return await api.getSlashCommands(sessionId)
},
enabled: Boolean(api && sessionId),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: false, // Don't retry RPC failures
})
// Merge built-in commands with user-defined and plugin commands from API
const commands = useMemo(() => {
const builtin = BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS['claude'] ?? []
// If API succeeded, add user-defined and plugin commands
if (query.data?.success && query.data.commands) {
const extraCommands = query.data.commands.filter(
cmd => cmd.source === 'user' || cmd.source === 'plugin' || cmd.source === 'project'
)
return [...builtin, ...extraCommands]
}
// Fallback to built-in commands only
return builtin
}, [agentType, query.data])
const getSuggestions = useCallback(async (queryText: string): Promise<Suggestion[]> => {
const searchTerm = queryText.startsWith('/')
? queryText.slice(1).toLowerCase()
: queryText.toLowerCase()
if (!searchTerm) {
return commands.map(cmd => ({
key: `/${cmd.name}`,
text: `/${cmd.name}`,
label: `/${cmd.name}`,
description: cmd.description ?? (cmd.source === 'user' ? 'Custom command' : undefined),
content: cmd.content,
source: cmd.source
}))
}
const maxDistance = Math.max(2, Math.floor(searchTerm.length / 2))
return commands
.map(cmd => {
const name = cmd.name.toLowerCase()
let score: number
if (name === searchTerm) score = 0
else if (name.startsWith(searchTerm)) score = 1
else if (name.includes(searchTerm)) score = 2
else {
const dist = levenshteinDistance(searchTerm, name)
score = dist <= maxDistance ? 3 + dist : Infinity
}
return { cmd, score }
})
.filter(item => item.score < Infinity)
.sort((a, b) => a.score - b.score)
.map(({ cmd }) => ({
key: `/${cmd.name}`,
text: `/${cmd.name}`,
label: `/${cmd.name}`,
description: cmd.description ?? (cmd.source === 'user' ? 'Custom command' : undefined),
content: cmd.content,
source: cmd.source
}))
}, [commands])
return {
commands,
isLoading: query.isLoading,
error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load commands' : null,
getSuggestions,
}
}