diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 66dcced40..005851b0e 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -181,7 +181,7 @@ export function HappyComposer(props: { // For Codex user prompts with content, expand the content instead of command name let textToInsert = suggestion.text let addSpace = true - if (agentFlavor === 'codex' && suggestion.source === 'user' && suggestion.content) { + if (agentFlavor === 'codex' && suggestion.source !== 'builtin' && suggestion.content) { textToInsert = suggestion.content addSpace = false } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index beb05a3d8..b185684ea 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' -import type { AttachmentMetadata, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session } from '@/types/api' +import type { + AttachmentMetadata, + CodexCollaborationMode, + DecryptedMessage, + PermissionMode, + Session, + SlashCommand +} from '@/types/api' import type { ChatBlock, NormalizedMessage } from '@/chat/types' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { normalizeDecryptedMessage } from '@/chat/normalize' @@ -12,6 +19,9 @@ import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import { HappyThread } from '@/components/AssistantChat/HappyThread' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' +import { findUnsupportedCodexBuiltinSlashCommand } from '@/lib/codexSlashCommands' +import { useToast } from '@/lib/toast-context' +import { useTranslation } from '@/lib/use-translation' import { SessionHeader } from '@/components/SessionHeader' import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' @@ -39,8 +49,11 @@ export function SessionChat(props: { onAtBottomChange: (atBottom: boolean) => void onRetryMessage?: (localId: string) => void autocompleteSuggestions?: (query: string) => Promise + availableSlashCommands?: readonly SlashCommand[] }) { const { haptic } = usePlatform() + const { addToast } = useToast() + const { t } = useTranslation() const navigate = useNavigate() const sessionInactive = !props.session.active const terminalSupported = isRemoteTerminalSupported(props.session.metadata) @@ -260,9 +273,26 @@ export function SessionChat(props: { }, [navigate, props.session.id]) const handleSend = useCallback((text: string, attachments?: AttachmentMetadata[]) => { + if (agentFlavor === 'codex') { + const unsupportedCommand = findUnsupportedCodexBuiltinSlashCommand( + text, + props.availableSlashCommands ?? [] + ) + if (unsupportedCommand) { + haptic.notification('error') + addToast({ + title: t('composer.codexSlashUnsupported.title'), + body: t('composer.codexSlashUnsupported.body', { command: `/${unsupportedCommand}` }), + sessionId: props.session.id, + url: `/sessions/${props.session.id}` + }) + return + } + } + props.onSend(text, attachments) setForceScrollToken((token) => token + 1) - }, [props.onSend]) + }, [agentFlavor, props.availableSlashCommands, props.onSend, props.session.id, addToast, haptic, t]) const attachmentAdapter = useMemo(() => { if (!props.session.active) { diff --git a/web/src/hooks/queries/useSlashCommands.ts b/web/src/hooks/queries/useSlashCommands.ts index 77ae8b573..202486ce9 100644 --- a/web/src/hooks/queries/useSlashCommands.ts +++ b/web/src/hooks/queries/useSlashCommands.ts @@ -4,6 +4,7 @@ import type { ApiClient } from '@/api/client' import type { SlashCommand } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { queryKeys } from '@/lib/query-keys' +import { getBuiltinSlashCommands } from '@/lib/codexSlashCommands' function levenshteinDistance(a: string, b: string): number { if (a.length === 0) return b.length @@ -21,38 +22,6 @@ function levenshteinDistance(a: string, b: string): number { return matrix[b.length][a.length] } -/** - * 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 and free up context', source: 'builtin' }, - { name: 'compact', description: 'Clear conversation history but keep a summary in context', source: 'builtin' }, - { name: 'context', description: 'Visualize current context usage as a colored grid', source: 'builtin' }, - { name: 'cost', description: 'Show the total cost and duration of the current session', source: 'builtin' }, - { name: 'doctor', description: 'Diagnose and verify your Claude Code installation and settings', source: 'builtin' }, - { name: 'plan', description: 'View or open the current session plan', source: 'builtin' }, - { name: 'stats', description: 'Show your Claude Code usage statistics and activity', source: 'builtin' }, - { name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' }, - ], - codex: [ - { name: 'review', description: 'Review current changes and find issues', source: 'builtin' }, - { name: 'new', description: 'Start a new chat during a conversation', source: 'builtin' }, - { name: 'compat', description: 'Summarize conversation to prevent hitting the context limit', source: 'builtin' }, - { name: 'undo', description: 'Ask Codex to undo a turn', source: 'builtin' }, - { name: 'diff', description: 'Show git diff including untracked files', source: 'builtin' }, - { name: 'status', description: 'Show current session configuration and token usage', source: 'builtin' }, - ], - gemini: [ - { name: 'about', description: 'Show version info', source: 'builtin' }, - { name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' }, - { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, - { name: 'stats', description: 'Check session stats', source: 'builtin' }, - ], - opencode: [], -} - export function useSlashCommands( api: ApiClient | null, sessionId: string | null, @@ -82,7 +51,7 @@ export function useSlashCommands( // Merge built-in commands with user-defined and plugin commands from API const commands = useMemo(() => { - const builtin = BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS['claude'] ?? [] + const builtin = getBuiltinSlashCommands(agentType) // If API succeeded, add user-defined and plugin commands if (query.data?.success && query.data.commands) { @@ -106,7 +75,7 @@ export function useSlashCommands( key: `/${cmd.name}`, text: `/${cmd.name}`, label: `/${cmd.name}`, - description: cmd.description ?? (cmd.source === 'user' ? 'Custom command' : undefined), + description: cmd.description ?? (cmd.source === 'builtin' ? undefined : 'Custom command'), content: cmd.content, source: cmd.source })) @@ -132,7 +101,7 @@ export function useSlashCommands( key: `/${cmd.name}`, text: `/${cmd.name}`, label: `/${cmd.name}`, - description: cmd.description ?? (cmd.source === 'user' ? 'Custom command' : undefined), + description: cmd.description ?? (cmd.source === 'builtin' ? undefined : 'Custom command'), content: cmd.content, source: cmd.source })) diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts new file mode 100644 index 000000000..708028ed2 --- /dev/null +++ b/web/src/lib/codexSlashCommands.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { findUnsupportedCodexBuiltinSlashCommand, getBuiltinSlashCommands } from './codexSlashCommands' + +describe('getBuiltinSlashCommands', () => { + it('does not expose codex built-ins in remote web mode', () => { + expect(getBuiltinSlashCommands('codex')).toEqual([]) + }) +}) + +describe('findUnsupportedCodexBuiltinSlashCommand', () => { + it('detects unsupported codex built-ins', () => { + expect(findUnsupportedCodexBuiltinSlashCommand('/status', [])).toBe('status') + expect(findUnsupportedCodexBuiltinSlashCommand(' /diff ', [])).toBe('diff') + }) + + it('ignores regular messages and unknown commands', () => { + expect(findUnsupportedCodexBuiltinSlashCommand('show me status', [])).toBeNull() + expect(findUnsupportedCodexBuiltinSlashCommand('/custom-status', [])).toBeNull() + }) + + it('does not block custom commands that override the same name', () => { + expect(findUnsupportedCodexBuiltinSlashCommand('/status', [ + { name: 'status', source: 'project', content: 'project status prompt' } + ])).toBeNull() + }) +}) diff --git a/web/src/lib/codexSlashCommands.ts b/web/src/lib/codexSlashCommands.ts new file mode 100644 index 000000000..ef62e88a8 --- /dev/null +++ b/web/src/lib/codexSlashCommands.ts @@ -0,0 +1,58 @@ +import type { SlashCommand } from '@/types/api' + +const BUILTIN_COMMANDS: Record = { + claude: [ + { name: 'clear', description: 'Clear conversation history and free up context', source: 'builtin' }, + { name: 'compact', description: 'Clear conversation history but keep a summary in context', source: 'builtin' }, + { name: 'context', description: 'Visualize current context usage as a colored grid', source: 'builtin' }, + { name: 'cost', description: 'Show the total cost and duration of the current session', source: 'builtin' }, + { name: 'doctor', description: 'Diagnose and verify your Claude Code installation and settings', source: 'builtin' }, + { name: 'plan', description: 'View or open the current session plan', source: 'builtin' }, + { name: 'stats', description: 'Show your Claude Code usage statistics and activity', source: 'builtin' }, + { name: 'status', description: 'Show Claude Code status including version, model, account, and API connectivity', source: 'builtin' }, + ], + // Codex remote turns send slash-prefixed input as plain text to app-server. + // Hide built-ins here until remote slash command execution is implemented end-to-end. + codex: [], + gemini: [ + { name: 'about', description: 'Show version info', source: 'builtin' }, + { name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' }, + { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, + { name: 'stats', description: 'Check session stats', source: 'builtin' }, + ], + opencode: [], +} + +const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ + 'review', + 'new', + 'compat', + 'undo', + 'diff', + 'status', +]) + +export function getBuiltinSlashCommands(agentType: string): SlashCommand[] { + return BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS.claude ?? [] +} + +export function findUnsupportedCodexBuiltinSlashCommand( + text: string, + availableCommands: readonly SlashCommand[] +): string | null { + const match = /^\s*\/([a-z0-9:_-]+)(?:\s|$)/i.exec(text) + if (!match) { + return null + } + + const commandName = match[1]?.toLowerCase() + if (!commandName || !UNSUPPORTED_CODEX_BUILTIN_COMMANDS.has(commandName)) { + return null + } + + const hasCustomCommand = availableCommands.some( + command => command.source !== 'builtin' && command.name.toLowerCase() === commandName + ) + + return hasCustomCommand ? null : commandName +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 32672eb27..484b3a1e1 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -208,6 +208,8 @@ export default { 'composer.send': 'Send', 'composer.stop': 'Stop', 'composer.voice': 'Voice assistant', + 'composer.codexSlashUnsupported.title': 'Codex command unavailable', + 'composer.codexSlashUnsupported.body': 'HAPI remote mode does not yet run built-in Codex slash commands like {command}. Use natural language instead, or run it in the local Codex TUI.', // Voice assistant 'voice.connecting': 'Connecting...', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 14503d554..773b68bd8 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -210,6 +210,8 @@ export default { 'composer.send': '发送', 'composer.stop': '停止', 'composer.voice': '语音助手', + 'composer.codexSlashUnsupported.title': '无法执行 Codex 命令', + 'composer.codexSlashUnsupported.body': 'HAPI 远程模式暂不支持 {command} 这类 Codex 内建 slash command,请改用自然语言,或在本地 Codex TUI 中执行。', // Voice assistant 'voice.connecting': '连接中...', diff --git a/web/src/router.tsx b/web/src/router.tsx index 7b7e9721e..2c27ad577 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -267,6 +267,7 @@ function SessionPage() { // Get agent type from session metadata for slash commands const agentType = session?.metadata?.flavor ?? 'claude' const { + commands: slashCommands, getSuggestions: getSlashSuggestions, } = useSlashCommands(api, sessionId, agentType) const { @@ -313,6 +314,7 @@ function SessionPage() { onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} autocompleteSuggestions={getAutocompleteSuggestions} + availableSlashCommands={slashCommands} /> ) }