diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index ccca2d7e..85c563de 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -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('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); } diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts new file mode 100644 index 00000000..85835b5c --- /dev/null +++ b/cli/src/modules/common/slashCommands.ts @@ -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 = { + 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 { + 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 { + const builtin = BUILTIN_COMMANDS[agent] ?? []; + const user = await scanUserCommands(agent); + + // Combine: built-in first, then user commands + return [...builtin, ...user]; +} diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 6186159d..6e4ec49d 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -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 { + return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as RpcSlashCommandsResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index fb8dea71..025ab66e 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -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 } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 07db3989..595f5d24 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` + ) + } } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 236f72c0..36d94352 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -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]) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3199e21a..9a92b187 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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 onSend: (text: string) => void onRetryMessage?: (localId: string) => void + autocompleteSuggestions?: (query: string) => Promise }) { 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} /> diff --git a/web/src/hooks/queries/useSlashCommands.ts b/web/src/hooks/queries/useSlashCommands.ts new file mode 100644 index 00000000..5d47acd4 --- /dev/null +++ b/web/src/hooks/queries/useSlashCommands.ts @@ -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 = { + 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 +} { + 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 => { + // 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, + } +} diff --git a/web/src/hooks/useActiveSuggestions.ts b/web/src/hooks/useActiveSuggestions.ts index 11d07c46..60eb33d3 100644 --- a/web/src/hooks/useActiveSuggestions.ts +++ b/web/src/hooks/useActiveSuggestions.ts @@ -29,7 +29,10 @@ class ValueSync { } 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) { diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 2d1a0e1b..ef6ce1be 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -12,4 +12,5 @@ export const queryKeys = { path, staged ? 'staged' : 'unstaged' ] as const, + slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, } diff --git a/web/src/router.tsx b/web/src/router.tsx index 43b335ab..9ad8de39 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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} /> ) } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 7142b1bd..8e43eec4 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -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 }