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:
@@ -9,6 +9,7 @@ import { run as runDifftastic } from '@/modules/difftastic/index';
|
||||
import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager';
|
||||
import { registerGitHandlers } from './gitHandlers';
|
||||
import { validatePath } from './pathSecurity';
|
||||
import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from './slashCommands';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -486,5 +487,21 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor
|
||||
}
|
||||
});
|
||||
|
||||
// Slash commands handler - lists available slash commands for an agent
|
||||
rpcHandlerManager.registerHandler<ListSlashCommandsRequest, ListSlashCommandsResponse>('listSlashCommands', async (data) => {
|
||||
logger.debug('List slash commands request for agent:', data.agent);
|
||||
|
||||
try {
|
||||
const commands = await listSlashCommands(data.agent);
|
||||
return { success: true, commands };
|
||||
} catch (error) {
|
||||
logger.debug('Failed to list slash commands:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list slash commands'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
registerGitHandlers(rpcHandlerManager, workingDirectory);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: 'builtin' | 'user';
|
||||
}
|
||||
|
||||
export interface ListSlashCommandsRequest {
|
||||
agent: string;
|
||||
}
|
||||
|
||||
export interface ListSlashCommandsResponse {
|
||||
success: boolean;
|
||||
commands?: SlashCommand[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in slash commands for each agent type.
|
||||
*/
|
||||
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' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the user commands directory for an agent type.
|
||||
* Returns null if the agent doesn't support user commands.
|
||||
*/
|
||||
function getUserCommandsDir(agent: string): string | null {
|
||||
switch (agent) {
|
||||
case 'claude': {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
|
||||
return join(configDir, 'commands');
|
||||
}
|
||||
case 'codex': {
|
||||
const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex');
|
||||
return join(codexHome, 'prompts');
|
||||
}
|
||||
default:
|
||||
// Gemini and other agents don't have user commands
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for user-defined commands (*.md files).
|
||||
* Returns the command names (filename without extension).
|
||||
*/
|
||||
async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
|
||||
const dir = getUserCommandsDir(agent);
|
||||
if (!dir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const commands: SlashCommand[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
|
||||
// Remove .md extension to get command name
|
||||
const name = entry.name.slice(0, -3);
|
||||
if (!name) continue;
|
||||
|
||||
commands.push({
|
||||
name,
|
||||
description: 'Custom command',
|
||||
source: 'user',
|
||||
});
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return commands;
|
||||
} catch {
|
||||
// Directory doesn't exist or not accessible - return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available slash commands for an agent type.
|
||||
* Returns built-in commands plus user-defined commands.
|
||||
*/
|
||||
export async function listSlashCommands(agent: string): Promise<SlashCommand[]> {
|
||||
const builtin = BUILTIN_COMMANDS[agent] ?? [];
|
||||
const user = await scanUserCommands(agent);
|
||||
|
||||
// Combine: built-in first, then user commands
|
||||
return [...builtin, ...user];
|
||||
}
|
||||
@@ -133,6 +133,18 @@ export type RpcReadFileResponse = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SlashCommand = {
|
||||
name: string
|
||||
description?: string
|
||||
source: 'builtin' | 'user'
|
||||
}
|
||||
|
||||
export type RpcSlashCommandsResponse = {
|
||||
success: boolean
|
||||
commands?: SlashCommand[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SyncEventType =
|
||||
| 'session-added'
|
||||
| 'session-updated'
|
||||
@@ -705,6 +717,10 @@ export class SyncEngine {
|
||||
return await this.sessionRpc(sessionId, 'ripgrep', { args, cwd }) as RpcCommandResponse
|
||||
}
|
||||
|
||||
async listSlashCommands(sessionId: string, agent: string): Promise<RpcSlashCommandsResponse> {
|
||||
return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as RpcSlashCommandsResponse
|
||||
}
|
||||
|
||||
private async sessionRpc(sessionId: string, method: string, params: unknown): Promise<unknown> {
|
||||
return await this.rpcCall(`${sessionId}:${method}`, params)
|
||||
}
|
||||
|
||||
@@ -182,5 +182,31 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/sessions/:id/slash-commands', async (c) => {
|
||||
const engine = requireSyncEngine(c, getSyncEngine)
|
||||
if (engine instanceof Response) {
|
||||
return engine
|
||||
}
|
||||
|
||||
// Session must exist but doesn't need to be active
|
||||
const sessionResult = requireSessionFromParam(c, engine)
|
||||
if (sessionResult instanceof Response) {
|
||||
return sessionResult
|
||||
}
|
||||
|
||||
// Get agent type from session metadata, default to 'claude'
|
||||
const agent = sessionResult.session.metadata?.flavor ?? 'claude'
|
||||
|
||||
try {
|
||||
const result = await engine.listSlashCommands(sessionResult.sessionId, agent)
|
||||
return c.json(result)
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list slash commands'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -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