mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,4 +12,5 @@ export const queryKeys = {
|
||||
path,
|
||||
staged ? 'staged' : 'unstaged'
|
||||
] as const,
|
||||
slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user