feat: add slash command autocomplete to HappyComposer

Implements full-stack slash command autocomplete with agent-specific built-in commands and user-defined command discovery. Includes React Strict Mode fix for suggestion handling.
This commit is contained in:
weishu
2025-12-28 15:19:08 +08:00
parent 20c05a06ec
commit fbc0d601f8
12 changed files with 322 additions and 2 deletions
+7
View File
@@ -5,6 +5,7 @@ import type {
GitCommandResponse,
MachinesResponse,
MessagesResponse,
SlashCommandsResponse,
SpawnResponse,
SessionResponse,
SessionsResponse
@@ -240,4 +241,10 @@ export class ApiClient {
body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName })
})
}
async getSlashCommands(sessionId: string): Promise<SlashCommandsResponse> {
return await this.request<SlashCommandsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/slash-commands`
)
}
}
@@ -105,7 +105,10 @@ export function HappyComposer(props: {
useEffect(() => {
setInputState((prev) => {
if (prev.text === composerText) return prev
return { ...prev, text: composerText }
// When syncing from composerText, update selection to end of text
// This ensures activeWord detection works correctly
const newPos = composerText.length
return { text: composerText, selection: { start: newPos, end: newPos } }
})
}, [composerText])
+3
View File
@@ -4,6 +4,7 @@ import { AssistantRuntimeProvider } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
import type { ChatBlock, NormalizedMessage } from '@/chat/types'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { normalizeDecryptedMessage } from '@/chat/normalize'
import { reduceChatBlocks } from '@/chat/reducer'
import { reconcileChatBlocks } from '@/chat/reconcile'
@@ -28,6 +29,7 @@ export function SessionChat(props: {
onLoadMore: () => Promise<unknown>
onSend: (text: string) => void
onRetryMessage?: (localId: string) => void
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
}) {
const { haptic } = usePlatform()
const navigate = useNavigate()
@@ -184,6 +186,7 @@ export function SessionChat(props: {
onModelModeChange={handleModelModeChange}
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active ? handleViewTerminal : undefined}
autocompleteSuggestions={props.autocompleteSuggestions}
/>
</div>
</AssistantRuntimeProvider>
+103
View File
@@ -0,0 +1,103 @@
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'
/**
* 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', source: 'builtin' },
{ name: 'compact', description: 'Compact conversation context', source: 'builtin' },
{ name: 'context', description: 'Show context information', source: 'builtin' },
{ name: 'cost', description: 'Show session cost', source: 'builtin' },
{ name: 'doctor', description: 'Run diagnostics', source: 'builtin' },
{ name: 'plan', description: 'Toggle plan mode', source: 'builtin' },
{ name: 'stats', description: 'Show session statistics', source: 'builtin' },
{ name: 'status', description: 'Show status', source: 'builtin' },
],
codex: [
{ name: 'review', description: 'Review code', source: 'builtin' },
{ name: 'new', description: 'Start new conversation', source: 'builtin' },
{ name: 'compat', description: 'Check compatibility', source: 'builtin' },
{ name: 'undo', description: 'Undo last action', source: 'builtin' },
{ name: 'diff', description: 'Show changes', source: 'builtin' },
{ name: 'status', description: 'Show status', source: 'builtin' },
{ name: 'ps', description: 'Show processes', source: 'builtin' },
],
gemini: [
{ name: 'about', description: 'About Gemini', source: 'builtin' },
{ name: 'clear', description: 'Clear conversation', source: 'builtin' },
{ name: 'compress', description: 'Compress context', source: 'builtin' },
{ name: 'stats', description: 'Show statistics', source: 'builtin' },
],
}
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 commands from API
const commands = useMemo(() => {
const builtin = BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS['claude'] ?? []
// If API succeeded, add user-defined commands
if (query.data?.success && query.data.commands) {
const userCommands = query.data.commands.filter(cmd => cmd.source === 'user')
return [...builtin, ...userCommands]
}
// Fallback to built-in commands only
return builtin
}, [agentType, query.data])
const getSuggestions = useCallback(async (queryText: string): Promise<Suggestion[]> => {
// queryText will be like "/clea" - strip the leading slash
const searchTerm = queryText.startsWith('/')
? queryText.slice(1).toLowerCase()
: queryText.toLowerCase()
return commands
.filter(cmd => cmd.name.toLowerCase().startsWith(searchTerm))
.map(cmd => ({
key: `/${cmd.name}`,
text: `/${cmd.name}`,
label: `/${cmd.name}`,
description: cmd.description ?? (cmd.source === 'user' ? 'Custom command' : undefined)
}))
}, [commands])
return {
commands,
isLoading: query.isLoading,
error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load commands' : null,
getSuggestions,
}
}
+4 -1
View File
@@ -29,7 +29,10 @@ class ValueSync<T> {
}
setValue(value: T) {
if (this.stopped) return
if (this.stopped) {
// Reset stopped state - this handles React Strict Mode re-mounting
this.stopped = false
}
this.latestValue = value
this.hasValue = true
if (!this.processing) {
+1
View File
@@ -12,4 +12,5 @@ export const queryKeys = {
path,
staged ? 'staged' : 'unstaged'
] as const,
slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const,
}
+8
View File
@@ -20,6 +20,7 @@ import { useMessages } from '@/hooks/queries/useMessages'
import { useMachines } from '@/hooks/queries/useMachines'
import { useSession } from '@/hooks/queries/useSession'
import { useSessions } from '@/hooks/queries/useSessions'
import { useSlashCommands } from '@/hooks/queries/useSlashCommands'
import { useSendMessage } from '@/hooks/mutations/useSendMessage'
import { queryKeys } from '@/lib/query-keys'
import FilesPage from '@/routes/sessions/files'
@@ -136,6 +137,12 @@ function SessionPage() {
isSending,
} = useSendMessage(api, sessionId)
// Get agent type from session metadata for slash commands
const agentType = session?.metadata?.flavor ?? 'claude'
const {
getSuggestions: getSlashSuggestions,
} = useSlashCommands(api, sessionId, agentType)
const refreshSelectedSession = useCallback(() => {
void refetchSession()
void refetchMessages()
@@ -164,6 +171,7 @@ function SessionPage() {
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onRetryMessage={retryMessage}
autocompleteSuggestions={getSlashSuggestions}
/>
)
}
+12
View File
@@ -183,6 +183,18 @@ export type GitStatusFiles = {
totalUnstaged: number
}
export type SlashCommand = {
name: string
description?: string
source: 'builtin' | 'user'
}
export type SlashCommandsResponse = {
success: boolean
commands?: SlashCommand[]
error?: string
}
export type SyncEvent =
| { type: 'session-added'; sessionId: string; data?: unknown }
| { type: 'session-updated'; sessionId: string; data?: unknown }