Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/components/AssistantChat/HappyComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
34 changes: 32 additions & 2 deletions web/src/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -39,8 +49,11 @@ export function SessionChat(props: {
onAtBottomChange: (atBottom: boolean) => void
onRetryMessage?: (localId: string) => void
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
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)
Expand Down Expand Up @@ -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) {
Expand Down
39 changes: 4 additions & 35 deletions web/src/hooks/queries/useSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, SlashCommand[]> = {
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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}))
Expand All @@ -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
}))
Expand Down
26 changes: 26 additions & 0 deletions web/src/lib/codexSlashCommands.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
58 changes: 58 additions & 0 deletions web/src/lib/codexSlashCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { SlashCommand } from '@/types/api'

const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
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
}
2 changes: 2 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...',
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '连接中...',
Expand Down
2 changes: 2 additions & 0 deletions web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -313,6 +314,7 @@ function SessionPage() {
onAtBottomChange={setAtBottom}
onRetryMessage={retryMessage}
autocompleteSuggestions={getAutocompleteSuggestions}
availableSlashCommands={slashCommands}
/>
)
}
Expand Down
Loading