From 9d2dec137bcd7cf45392ca53bb14de14f13c07b1 Mon Sep 17 00:00:00 2001 From: CoColate Date: Wed, 29 Apr 2026 09:20:11 +0800 Subject: [PATCH] feat(codex): support slash controls and skill discovery (#545) * feat(codex): resolve slash controls before sending to Codex * feat(codex): discover commands and skills * fix(codex): handle slash commands before attachments * fix(codex): block remaining unsupported built-ins --- cli/src/codex/runCodex.ts | 99 +++++++-- cli/src/codex/utils/slashCommands.test.ts | 87 ++++++++ cli/src/codex/utils/slashCommands.ts | 200 ++++++++++++++++++ cli/src/modules/common/skills.test.ts | 19 +- cli/src/modules/common/skills.ts | 29 ++- cli/src/modules/common/slashCommands.test.ts | 74 +++++++ cli/src/modules/common/slashCommands.ts | 67 ++++-- .../AssistantChat/HappyComposer.tsx | 14 +- web/src/components/SessionChat.tsx | 22 +- web/src/hooks/queries/useSlashCommands.ts | 14 +- web/src/lib/codexSlashCommands.test.ts | 11 +- web/src/lib/codexSlashCommands.ts | 16 +- 12 files changed, 571 insertions(+), 81 deletions(-) create mode 100644 cli/src/codex/utils/slashCommands.test.ts create mode 100644 cli/src/codex/utils/slashCommands.ts diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index bb17a247..17985940 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -1,4 +1,5 @@ import { logger } from '@/ui/logger'; +import { randomUUID } from 'node:crypto'; import { loop, type EnhancedMode, type PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; @@ -13,6 +14,8 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import type { ReasoningEffort } from './appServerTypes'; +import { listSlashCommands } from '@/modules/common/slashCommands'; +import { resolveCodexSlashCommand } from './utils/slashCommands'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -89,7 +92,29 @@ export async function runCodex(opts: { ); }; - session.onUserMessage((message, localId) => { + const applySlashUpdates = (updates: { + permissionMode?: PermissionMode; + model?: string | null; + modelReasoningEffort?: ReasoningEffort | null; + collaborationMode?: EnhancedMode['collaborationMode']; + } | undefined): void => { + if (!updates) return; + if (updates.permissionMode !== undefined) { + currentPermissionMode = updates.permissionMode; + } + if (updates.model !== undefined) { + currentModel = updates.model ?? undefined; + } + if (updates.modelReasoningEffort !== undefined) { + currentModelReasoningEffort = updates.modelReasoningEffort ?? undefined; + } + if (updates.collaborationMode !== undefined) { + currentCollaborationMode = updates.collaborationMode; + } + applyCurrentConfigToSession(); + }; + + const syncCurrentConfigFromSession = (): void => { const sessionPermissionMode = sessionWrapperRef.current?.getPermissionMode(); if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'codex')) { currentPermissionMode = sessionPermissionMode as PermissionMode; @@ -106,22 +131,66 @@ export async function runCodex(opts: { if (sessionCollaborationMode) { currentCollaborationMode = sessionCollaborationMode; } + }; - const messagePermissionMode = currentPermissionMode; - logger.debug( - `[Codex] User message received with permission mode: ${currentPermissionMode}, ` + - `model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` + - `collaborationMode: ${currentCollaborationMode}` - ); + let userMessageChain: Promise = Promise.resolve(); + session.onUserMessage((message, localId) => { + userMessageChain = userMessageChain.then(async () => { + try { + syncCurrentConfigFromSession(); + let text = message.content.text; + const commands = await listSlashCommands('codex', workingDirectory).catch(() => []); + const slash = resolveCodexSlashCommand(text, { + commands, + permissionMode: currentPermissionMode, + collaborationMode: currentCollaborationMode, + model: currentModel, + modelReasoningEffort: currentModelReasoningEffort + }); + if (slash.kind !== 'passthrough') { + applySlashUpdates(slash.updates); + if (slash.message) { + session.sendAgentMessage({ + type: 'message', + message: slash.message, + id: randomUUID() + }); + } + if (slash.kind === 'handled') { + if (localId) session.emitMessagesConsumed([localId]); + return; + } + text = slash.text; + } + text = formatMessageWithAttachments(text, message.content.attachments); - const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode ?? 'default', - model: currentModel, - modelReasoningEffort: currentModelReasoningEffort, - collaborationMode: currentCollaborationMode - }; - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - messageQueue.push(formattedText, enhancedMode, localId); + const messagePermissionMode = currentPermissionMode; + logger.debug( + `[Codex] User message received with permission mode: ${currentPermissionMode}, ` + + `model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` + + `collaborationMode: ${currentCollaborationMode}` + ); + + const enhancedMode: EnhancedMode = { + permissionMode: messagePermissionMode ?? 'default', + model: currentModel, + modelReasoningEffort: currentModelReasoningEffort, + collaborationMode: currentCollaborationMode + }; + messageQueue.push(text, enhancedMode, localId); + } catch (error) { + logger.debug('[Codex] Failed to handle user message', error); + const enhancedMode: EnhancedMode = { + permissionMode: currentPermissionMode ?? 'default', + model: currentModel, + modelReasoningEffort: currentModelReasoningEffort, + collaborationMode: currentCollaborationMode + }; + messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId); + } + }).catch((error) => { + logger.debug('[Codex] User message handler chain failed', error); + }); }); const formatFailureReason = (message: string): string => { diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts new file mode 100644 index 00000000..99dac269 --- /dev/null +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCodexSlashCommand } from './slashCommands'; + +const state = { + permissionMode: 'default' as const, + collaborationMode: 'default' as const, + model: 'gpt-5.5', + modelReasoningEffort: 'high' as const +}; + +describe('resolveCodexSlashCommand', () => { + it('enables plan mode without sending a turn', () => { + expect(resolveCodexSlashCommand('/plan', state)).toEqual({ + kind: 'handled', + message: 'Codex plan mode enabled', + updates: { collaborationMode: 'plan' } + }); + }); + + it('enables plan mode and sends prompt when /plan has text', () => { + expect(resolveCodexSlashCommand('/plan design the fix', state)).toEqual({ + kind: 'replace', + text: 'design the fix', + message: 'Codex plan mode enabled', + updates: { collaborationMode: 'plan' } + }); + }); + + it('returns to default collaboration mode', () => { + expect(resolveCodexSlashCommand('/plan off', { ...state, collaborationMode: 'plan' })).toEqual({ + kind: 'handled', + message: 'Codex plan mode disabled', + updates: { collaborationMode: 'default' } + }); + }); + + it('sets model, reasoning effort, and permission mode', () => { + expect(resolveCodexSlashCommand('/model gpt-5.4', state)).toMatchObject({ + updates: { model: 'gpt-5.4' } + }); + expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({ + updates: { modelReasoningEffort: 'low' } + }); + expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({ + updates: { permissionMode: 'yolo' } + }); + }); + + it('expands custom Codex prompt commands', () => { + expect(resolveCodexSlashCommand('/review src/index.ts', { + ...state, + commands: [ + { name: 'review', source: 'project', content: 'Review this code.' } + ] + })).toEqual({ + kind: 'replace', + text: 'Review this code.\n\nUser arguments: src/index.ts', + message: 'Expanded /review' + }); + }); + + it('handles unsupported Codex built-in commands instead of sending them to the model', () => { + for (const command of ['diff', 'undo', 'review', 'compat']) { + expect(resolveCodexSlashCommand(`/${command}`, state)).toEqual({ + kind: 'handled', + message: `/${command} is a Codex CLI command that is not supported in HAPI sessions yet.` + }); + } + }); + + it('expands custom prompts before checking unsupported built-in names', () => { + expect(resolveCodexSlashCommand('/review src/index.ts', { + ...state, + commands: [ + { name: 'review', source: 'project', content: 'Review this code.' } + ] + })).toEqual({ + kind: 'replace', + text: 'Review this code.\n\nUser arguments: src/index.ts', + message: 'Expanded /review' + }); + }); + + it('passes unknown slash commands through', () => { + expect(resolveCodexSlashCommand('/unknown', state)).toEqual({ kind: 'passthrough' }); + }); +}); diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts new file mode 100644 index 00000000..da66ed13 --- /dev/null +++ b/cli/src/codex/utils/slashCommands.ts @@ -0,0 +1,200 @@ +import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'; +import type { CodexPermissionMode } from '@hapi/protocol/types'; +import type { ReasoningEffort } from '../appServerTypes'; +import type { EnhancedMode } from '../loop'; +import type { SlashCommand } from '@/modules/common/slashCommands'; + +const REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']); + +const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ + 'clear', + 'compact', + 'compat', + 'diff', + 'init', + 'login', + 'logout', + 'mcp', + 'new', + 'prompts', + 'quit', + 'redo', + 'review', + 'undo' +]); + +export type CodexSlashResolution = + | { kind: 'passthrough' } + | { + kind: 'handled'; + message: string; + updates?: { + collaborationMode?: EnhancedMode['collaborationMode']; + permissionMode?: CodexPermissionMode; + model?: string | null; + modelReasoningEffort?: ReasoningEffort | null; + }; + } + | { + kind: 'replace'; + text: string; + message?: string; + updates?: { + collaborationMode?: EnhancedMode['collaborationMode']; + permissionMode?: CodexPermissionMode; + model?: string | null; + modelReasoningEffort?: ReasoningEffort | null; + }; + }; + +export function resolveCodexSlashCommand( + text: string, + state: { + commands?: readonly SlashCommand[]; + permissionMode: CodexPermissionMode; + collaborationMode: EnhancedMode['collaborationMode']; + model?: string; + modelReasoningEffort?: ReasoningEffort; + } +): CodexSlashResolution { + const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text); + if (!match) return { kind: 'passthrough' }; + + const command = match[1]?.toLowerCase(); + const rest = match[2]?.trim() ?? ''; + if (!command) return { kind: 'passthrough' }; + + const custom = state.commands?.find((candidate) => + candidate.source !== 'builtin' && candidate.name.toLowerCase() === command + ); + if (custom?.content) { + return { + kind: 'replace', + text: rest ? `${custom.content}\n\nUser arguments: ${rest}` : custom.content, + message: `Expanded /${custom.name}` + }; + } + + if (command === 'plan') { + const lowerRest = rest.toLowerCase(); + if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') { + return { + kind: 'handled', + message: 'Codex plan mode disabled', + updates: { collaborationMode: 'default' } + }; + } + if (rest) { + return { + kind: 'replace', + text: rest, + message: 'Codex plan mode enabled', + updates: { collaborationMode: 'plan' } + }; + } + return { + kind: 'handled', + message: 'Codex plan mode enabled', + updates: { collaborationMode: 'plan' } + }; + } + + if (command === 'default' || command === 'execute') { + return { + kind: 'handled', + message: 'Codex collaboration mode: default', + updates: { collaborationMode: 'default' } + }; + } + + if (command === 'status') { + return { + kind: 'handled', + message: [ + `Codex status`, + `permission: ${state.permissionMode}`, + `collaboration: ${state.collaborationMode}`, + `model: ${state.model ?? 'auto'}`, + `reasoning: ${state.modelReasoningEffort ?? 'default'}` + ].join('\n') + }; + } + + if (command === 'model') { + if (!rest) { + return { kind: 'handled', message: `Codex model: ${state.model ?? 'auto'}` }; + } + const model = rest === 'auto' ? null : rest; + return { + kind: 'handled', + message: `Codex model set to ${model ?? 'auto'}`, + updates: { model } + }; + } + + if (command === 'reasoning' || command === 'effort') { + if (!rest) { + return { kind: 'handled', message: `Codex reasoning effort: ${state.modelReasoningEffort ?? 'default'}` }; + } + if (rest === 'default' || rest === 'auto') { + return { + kind: 'handled', + message: 'Codex reasoning effort set to default', + updates: { modelReasoningEffort: null } + }; + } + if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) { + return { + kind: 'handled', + message: `Unknown Codex reasoning effort: ${rest}` + }; + } + return { + kind: 'handled', + message: `Codex reasoning effort set to ${rest}`, + updates: { modelReasoningEffort: rest as ReasoningEffort } + }; + } + + if (command === 'permissions' || command === 'permission') { + if (!rest) { + return { kind: 'handled', message: `Codex permission mode: ${state.permissionMode}` }; + } + if (!(CODEX_PERMISSION_MODES as readonly string[]).includes(rest)) { + return { + kind: 'handled', + message: `Unknown Codex permission mode: ${rest}` + }; + } + return { + kind: 'handled', + message: `Codex permission mode set to ${rest}`, + updates: { permissionMode: rest as CodexPermissionMode } + }; + } + + if (command === 'help') { + return { + kind: 'handled', + message: [ + 'Supported Codex slash commands:', + '/plan [prompt] — enable plan mode, optionally send prompt', + '/plan off — return to default mode', + '/status — show current Codex session config', + '/model [name|auto] — show or set model', + '/reasoning [low|medium|high|xhigh|default] — show or set reasoning effort', + '/permissions [default|read-only|safe-yolo|yolo] — show or set permission mode', + 'Custom /commands from .codex/prompts are expanded before sending.' + ].join('\n') + }; + } + + if (UNSUPPORTED_CODEX_BUILTIN_COMMANDS.has(command)) { + return { + kind: 'handled', + message: `/${command} is a Codex CLI command that is not supported in HAPI sessions yet.` + }; + } + + return { kind: 'passthrough' }; +} diff --git a/cli/src/modules/common/skills.test.ts b/cli/src/modules/common/skills.test.ts index b6d597e9..c7ee948e 100644 --- a/cli/src/modules/common/skills.test.ts +++ b/cli/src/modules/common/skills.test.ts @@ -69,15 +69,14 @@ describe('listSkills', () => { expect(skills.find((s) => s.name === 'alpha')?.description).toBe('Alpha from agents') }) - it('lists user skills from ~/.codex/skills', async () => { + it('lists user skills from ~/.codex/skills including Codex bundled system skills', async () => { await writeSkill(join(homeDir, '.agents', 'skills', 'amis'), 'amis', 'AMIS guide') await writeSkill(join(homeDir, '.codex', 'skills', 'hello-agents'), 'helloagents', 'Main skill') - // Hidden directories (starting with .) are skipped await writeSkill(join(homeDir, '.codex', 'skills', '.system', 'skill-creator'), 'skill-creator', 'Create skills') const skills = await listSkills() - expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents']) + expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents', 'skill-creator']) }) it('falls back to directory name when frontmatter is missing', async () => { @@ -119,6 +118,20 @@ describe('listSkills', () => { expect(skills.map((skill) => skill.name)).toEqual(['claude-local', 'claude-root']) }) + it('loads project skills from .codex/skills directories', async () => { + const repoRoot = join(sandboxDir, 'repo') + const workingDirectory = join(repoRoot, 'apps', 'web') + + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(repoRoot, '.codex', 'skills', 'codex-root'), 'codex-root', 'Codex root skill') + await writeSkill(join(workingDirectory, '.codex', 'skills', 'codex-local'), 'codex-local', 'Codex local skill') + await writeSkill(join(workingDirectory, '.codex', 'skills', '.system', 'codex-system'), 'codex-system', 'Codex system skill') + + const skills = await listSkills(workingDirectory) + + expect(skills.map((skill) => skill.name)).toEqual(['codex-local', 'codex-root', 'codex-system']) + }) + it('prefers .agents project skills over .claude project skills with same name', async () => { const repoRoot = join(sandboxDir, 'repo') const workingDirectory = join(repoRoot, 'apps', 'web') diff --git a/cli/src/modules/common/skills.ts b/cli/src/modules/common/skills.ts index e63f84c3..46f44b66 100644 --- a/cli/src/modules/common/skills.ts +++ b/cli/src/modules/common/skills.ts @@ -38,6 +38,7 @@ function getProjectSkillsRoots(directory: string): string[] { return [ join(directory, '.agents', 'skills'), join(directory, '.claude', 'skills'), + join(directory, '.codex', 'skills'), ]; } @@ -105,13 +106,25 @@ function extractSkillSummary(skillDir: string, fileContent: string): SkillSummar return { name, description }; } -async function listTopLevelSkillDirs(skillsRoot: string): Promise { +async function listTopLevelSkillDirs(skillsRoot: string, options: { includeCodexSystem?: boolean } = {}): Promise { try { const entries = await readdir(skillsRoot, { withFileTypes: true }); const result: string[] = []; for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) { + if (!entry.isDirectory()) { + continue; + } + + if (entry.name.startsWith('.')) { + if (options.includeCodexSystem && entry.name === '.system') { + const systemEntries = await readdir(join(skillsRoot, entry.name), { withFileTypes: true }).catch(() => []); + for (const systemEntry of systemEntries) { + if (systemEntry.isDirectory() && !systemEntry.name.startsWith('.')) { + result.push(join(skillsRoot, entry.name, systemEntry.name)); + } + } + } continue; } @@ -138,12 +151,18 @@ async function readSkillsFromDirs(skillDirs: string[]): Promise return skills.filter((skill): skill is SkillSummary => skill !== null); } +function isCodexSkillsRoot(root: string): boolean { + return root.endsWith(join('.codex', 'skills')); +} + export async function listSkills(workingDirectory?: string): Promise { const projectRoots = await listProjectSkillsRoots(workingDirectory); + const userRoots = getUserSkillsRoots(); + const adminRoot = getAdminSkillsRoot(); const [projectSkillDirs, userSkillDirs, adminSkillDirs] = await Promise.all([ - Promise.all(projectRoots.map(async (root) => await listTopLevelSkillDirs(root))).then((dirs) => dirs.flat()), - Promise.all(getUserSkillsRoots().map(async (root) => await listTopLevelSkillDirs(root))).then((dirs) => dirs.flat()), - listTopLevelSkillDirs(getAdminSkillsRoot()), + Promise.all(projectRoots.map(async (root) => await listTopLevelSkillDirs(root, { includeCodexSystem: isCodexSkillsRoot(root) }))).then((dirs) => dirs.flat()), + Promise.all(userRoots.map(async (root) => await listTopLevelSkillDirs(root, { includeCodexSystem: isCodexSkillsRoot(root) }))).then((dirs) => dirs.flat()), + listTopLevelSkillDirs(adminRoot, { includeCodexSystem: true }), ]); const [projectSkills, userSkills, adminSkills] = await Promise.all([ diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index 2296d060..195b1356 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -6,19 +6,25 @@ import { listSlashCommands } from './slashCommands' describe('listSlashCommands', () => { const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + const originalCodexHome = process.env.CODEX_HOME let sandboxDir: string let claudeConfigDir: string + let codexHome: string let projectDir: string beforeEach(async () => { sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-slash-commands-')) claudeConfigDir = join(sandboxDir, 'global-claude') + codexHome = join(sandboxDir, 'global-codex') projectDir = join(sandboxDir, 'project') process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + process.env.CODEX_HOME = codexHome await mkdir(join(claudeConfigDir, 'commands'), { recursive: true }) + await mkdir(join(codexHome, 'prompts'), { recursive: true }) await mkdir(join(projectDir, '.claude', 'commands'), { recursive: true }) + await mkdir(join(projectDir, '.codex', 'prompts'), { recursive: true }) }) afterEach(async () => { @@ -27,6 +33,11 @@ describe('listSlashCommands', () => { } else { process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir } + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } await rm(sandboxDir, { recursive: true, force: true }) }) @@ -98,4 +109,67 @@ describe('listSlashCommands', () => { await expect(listSlashCommands('claude', nonExistentProjectDir)).resolves.toBeDefined() }) + + it('exposes HAPI-supported Codex built-ins', async () => { + const commands = await listSlashCommands('codex', projectDir) + + expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([ + 'plan', + 'status', + 'model', + 'reasoning', + 'permissions', + ])) + }) + + it('loads Codex global and project prompts', async () => { + await writeFile( + join(codexHome, 'prompts', 'global-prompt.md'), + ['---', 'description: Global Codex prompt', '---', '', 'Global Codex body'].join('\n') + ) + await writeFile( + join(projectDir, '.codex', 'prompts', 'project-prompt.md'), + ['---', 'description: Project Codex prompt', '---', '', 'Project Codex body'].join('\n') + ) + + const commands = await listSlashCommands('codex', projectDir) + + expect(commands.find(cmd => cmd.name === 'global-prompt')).toMatchObject({ + source: 'user', + description: 'Global Codex prompt', + content: 'Global Codex body', + }) + expect(commands.find(cmd => cmd.name === 'project-prompt')).toMatchObject({ + source: 'project', + description: 'Project Codex prompt', + content: 'Project Codex body', + }) + }) + + it('loads Codex project prompts from cwd up to repo root with nearest override', async () => { + const repoRoot = join(sandboxDir, 'repo') + const workingDirectory = join(repoRoot, 'apps', 'web') + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await mkdir(join(repoRoot, '.codex', 'prompts'), { recursive: true }) + await mkdir(join(workingDirectory, '.codex', 'prompts'), { recursive: true }) + + await writeFile( + join(repoRoot, '.codex', 'prompts', 'shared.md'), + ['---', 'description: Root prompt', '---', '', 'Root body'].join('\n') + ) + await writeFile( + join(workingDirectory, '.codex', 'prompts', 'shared.md'), + ['---', 'description: Local prompt', '---', '', 'Local body'].join('\n') + ) + + const commands = await listSlashCommands('codex', workingDirectory) + const sharedCommands = commands.filter(cmd => cmd.name === 'shared') + + expect(sharedCommands).toHaveLength(1) + expect(sharedCommands[0]).toMatchObject({ + source: 'project', + description: 'Local prompt', + content: 'Local body', + }) + }) }) diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 127cd78b..41c38e90 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -1,5 +1,5 @@ -import { readdir, readFile } from 'fs/promises'; -import { join } from 'path'; +import { access, readdir, readFile } from 'fs/promises'; +import { dirname, join, resolve } from 'path'; import { homedir } from 'os'; import { parse as parseYaml } from 'yaml'; @@ -32,7 +32,18 @@ const BUILTIN_COMMANDS: Record = { { name: 'cost', description: 'Show session cost', source: 'builtin' }, { name: 'plan', description: 'Toggle plan mode', source: 'builtin' }, ], - codex: [], + codex: [ + { name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, + { name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' }, + { name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' }, + { name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' }, + { name: 'status', description: 'Show current Codex session config', source: 'builtin' }, + { name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' }, + { name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' }, + { name: 'effort', description: 'Alias for /reasoning', source: 'builtin' }, + { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, + { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, + ], gemini: [ { name: 'about', description: 'About Gemini', source: 'builtin' }, { name: 'clear', description: 'Clear conversation', source: 'builtin' }, @@ -115,6 +126,43 @@ function getProjectCommandsDir(agent: string, projectDir: string): string | null } } +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function listProjectCommandDirs(agent: string, projectDir?: string): Promise { + if (!projectDir) { + return []; + } + + const resolvedProjectDir = resolve(projectDir); + const directories = [resolvedProjectDir]; + let currentDirectory = resolvedProjectDir; + + while (true) { + if (await pathExists(join(currentDirectory, '.git'))) { + return [...directories] + .reverse() + .map((directory) => getProjectCommandsDir(agent, directory)) + .filter((directory): directory is string => directory !== null); + } + + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + const dir = getProjectCommandsDir(agent, resolvedProjectDir); + return dir ? [dir] : []; + } + + currentDirectory = parentDirectory; + directories.push(currentDirectory); + } +} + /** * Scan a directory for commands (*.md files). * Returns commands with parsed frontmatter. @@ -199,16 +247,9 @@ async function scanUserCommands(agent: string): Promise { * Scan project-defined commands from /.claude/commands/ or equivalent. */ async function scanProjectCommands(agent: string, projectDir?: string): Promise { - if (!projectDir) { - return []; - } - - const dir = getProjectCommandsDir(agent, projectDir); - if (!dir) { - return []; - } - - return scanCommandsDir(dir, 'project'); + const dirs = await listProjectCommandDirs(agent, projectDir); + const commands = await Promise.all(dirs.map(async (dir) => await scanCommandsDir(dir, 'project'))); + return commands.flat(); } /** diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 8fbb7cbf..b596f651 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -199,20 +199,12 @@ export function HappyComposer(props: { markSkillUsed(suggestion.text.slice(1)) } - // 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 !== 'builtin' && suggestion.content) { - textToInsert = suggestion.content - addSpace = false - } - const result = applySuggestion( inputState.text, inputState.selection, - textToInsert, + suggestion.text, autocompletePrefixes, - addSpace + true ) api.composer().setText(result.text) @@ -233,7 +225,7 @@ export function HappyComposer(props: { }, 0) haptic('light') - }, [api, suggestions, inputState, autocompletePrefixes, haptic, agentFlavor]) + }, [api, suggestions, inputState, autocompletePrefixes, haptic]) const abortDisabled = controlsDisabled || isAborting || !threadIsRunning const switchDisabled = controlsDisabled || isSwitching || !controlledByUser diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index c70e4fed..a8f5d5ef 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -20,8 +20,6 @@ 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' @@ -67,7 +65,6 @@ export function SessionChat(props: { availableSlashCommands?: readonly SlashCommand[] }) { const { haptic } = usePlatform() - const { addToast } = useToast() const { t } = useTranslation() const navigate = useNavigate() const sessionInactive = !props.session.active @@ -349,26 +346,9 @@ 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) - }, [agentFlavor, props.availableSlashCommands, props.onSend, props.session.id, addToast, haptic, t]) + }, [props.onSend]) const attachmentAdapter = useMemo(() => { if (!props.session.active) { diff --git a/web/src/hooks/queries/useSlashCommands.ts b/web/src/hooks/queries/useSlashCommands.ts index 202486ce..0fc3d2b0 100644 --- a/web/src/hooks/queries/useSlashCommands.ts +++ b/web/src/hooks/queries/useSlashCommands.ts @@ -49,16 +49,18 @@ export function useSlashCommands( retry: false, // Don't retry RPC failures }) - // Merge built-in commands with user-defined and plugin commands from API + // Merge local built-ins with commands discovered by the active CLI. + // The CLI can expose agent-specific built-ins plus user/plugin/project commands; + // keep local built-ins as an offline fallback, then append/override from RPC. const commands = useMemo(() => { const builtin = getBuiltinSlashCommands(agentType) - // If API succeeded, add user-defined and plugin commands if (query.data?.success && query.data.commands) { - const extraCommands = query.data.commands.filter( - cmd => cmd.source === 'user' || cmd.source === 'plugin' || cmd.source === 'project' - ) - return [...builtin, ...extraCommands] + const commandMap = new Map() + for (const command of [...builtin, ...query.data.commands]) { + commandMap.set(command.name, command) + } + return Array.from(commandMap.values()) } // Fallback to built-in commands only diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts index 708028ed..ba64cfc8 100644 --- a/web/src/lib/codexSlashCommands.test.ts +++ b/web/src/lib/codexSlashCommands.test.ts @@ -2,14 +2,19 @@ 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([]) + it('exposes HAPI-supported codex built-ins in remote web mode', () => { + expect(getBuiltinSlashCommands('codex').map((command) => command.name)).toEqual(expect.arrayContaining([ + 'plan', + 'status', + 'execute', + 'effort', + 'permission', + ])) }) }) describe('findUnsupportedCodexBuiltinSlashCommand', () => { it('detects unsupported codex built-ins', () => { - expect(findUnsupportedCodexBuiltinSlashCommand('/status', [])).toBe('status') expect(findUnsupportedCodexBuiltinSlashCommand(' /diff ', [])).toBe('diff') }) diff --git a/web/src/lib/codexSlashCommands.ts b/web/src/lib/codexSlashCommands.ts index ef62e88a..6cb52195 100644 --- a/web/src/lib/codexSlashCommands.ts +++ b/web/src/lib/codexSlashCommands.ts @@ -11,9 +11,18 @@ const BUILTIN_COMMANDS: Record = { { 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: [], + codex: [ + { name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' }, + { name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' }, + { name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' }, + { name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' }, + { name: 'status', description: 'Show current Codex session config', source: 'builtin' }, + { name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' }, + { name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' }, + { name: 'effort', description: 'Alias for /reasoning', source: 'builtin' }, + { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, + { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, + ], gemini: [ { name: 'about', description: 'Show version info', source: 'builtin' }, { name: 'clear', description: 'Clear the screen and conversation history', source: 'builtin' }, @@ -29,7 +38,6 @@ const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([ 'compat', 'undo', 'diff', - 'status', ]) export function getBuiltinSlashCommands(agentType: string): SlashCommand[] {