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
@@ -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);
}
+121
View File
@@ -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];
}
+16
View File
@@ -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)
}
+26
View File
@@ -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
}
+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 }