diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index 7ba52130..100d9264 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -166,6 +166,11 @@ export type UserInput = type: 'skill'; name: string; path: string; + } + | { + type: 'mention'; + name: string; + path: string; }; export type SandboxPolicy = diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index f49e9e70..795810e8 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -3,6 +3,7 @@ import type { EnhancedMode } from '../loop'; import { buildThreadStartParams, buildTurnStartParams, + buildUserInputFromMessage, codexCollaborationSpawnAgentInstructions, supportsReasoningSummary } from './appServerConfig'; @@ -505,4 +506,34 @@ describe('appServerConfig', () => { expect(params.collaborationMode).toBeUndefined(); expect(params.model).toBe('o3'); }); + + it('builds mention inputs from quoted @file tokens', () => { + expect(buildUserInputFromMessage('please inspect @"src/index.ts" now')).toEqual([ + { type: 'text', text: 'please inspect ' }, + { type: 'mention', name: 'index.ts', path: 'src/index.ts' }, + { type: 'text', text: ' now' } + ]); + }); + + it('builds mention inputs from quoted @file tokens with spaces', () => { + expect(buildUserInputFromMessage('please inspect @"docs/My File.md" now')).toEqual([ + { type: 'text', text: 'please inspect ' }, + { type: 'mention', name: 'My File.md', path: 'docs/My File.md' }, + { type: 'text', text: ' now' } + ]); + }); + + it('builds mention inputs from quoted root-level @file tokens', () => { + expect(buildUserInputFromMessage('please inspect @"package.json" now')).toEqual([ + { type: 'text', text: 'please inspect ' }, + { type: 'mention', name: 'package.json', path: 'package.json' }, + { type: 'text', text: ' now' } + ]); + }); + + it('keeps literal at-mentions as text', () => { + expect(buildUserInputFromMessage('please ask @alice to upgrade @types/node.')).toEqual([ + { type: 'text', text: 'please ask @alice to upgrade @types/node.' } + ]); + }); }); diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 13bbc8c0..f8e10c7c 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -7,7 +7,8 @@ import type { SandboxMode, SandboxPolicy, ThreadStartParams, - TurnStartParams + TurnStartParams, + UserInput } from '../appServerTypes'; import { resolveCodexPermissionModeConfig } from './permissionModeConfig'; @@ -127,6 +128,46 @@ function appendCollaborationInstructions(developerInstructions: string): string return `${developerInstructions}\n\n${codexCollaborationSpawnAgentInstructions}`; } +function mentionNameFromPath(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +export function buildUserInputFromMessage(message: string): UserInput[] { + const inputs: UserInput[] = []; + const mentionPattern = /(^|\s)@"((?:\\.|[^"\\])*)"/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = mentionPattern.exec(message)) !== null) { + const prefix = match[1] ?? ''; + const rawPath = match[2] ?? ''; + const pathText = rawPath; + const path = pathText.replace(/\\(["\\])/g, '$1'); + if (!path) continue; + + const atIndex = match.index + prefix.length; + const textBeforeMention = message.slice(lastIndex, atIndex); + if (textBeforeMention) { + inputs.push({ type: 'text', text: textBeforeMention }); + } + + inputs.push({ + type: 'mention', + name: mentionNameFromPath(path), + path + }); + lastIndex = mentionPattern.lastIndex - (rawPath.length - pathText.length); + } + + const remainder = message.slice(lastIndex); + if (remainder || inputs.length === 0) { + inputs.push({ type: 'text', text: remainder }); + } + + return inputs; +} + export function buildThreadStartParams(args: { cwd: string; mode: EnhancedMode; @@ -192,7 +233,7 @@ export function buildTurnStartParams(args: { const params: TurnStartParams = { threadId: args.threadId, cwd: args.cwd, - input: [{ type: 'text', text: args.message }] + input: buildUserInputFromMessage(args.message) }; const allowCliOverrides = args.mode?.permissionMode === 'default'; diff --git a/web/src/router.tsx b/web/src/router.tsx index db52db98..9c1a0156 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -971,11 +971,26 @@ function SessionPage() { } = useSkills(api, sessionId) const getAutocompleteSuggestions = useCallback(async (query: string) => { + if (query.startsWith('@')) { + if (agentType !== 'codex' || !api || !sessionId) return [] + const search = query.slice(1) + const response = await api.searchSessionFiles(sessionId, search, 50) + if (!response.success || !response.files) return [] + return response.files.map((file) => { + const mentionText = `@"${file.fullPath.replace(/(["\\])/g, '\\$1')}"` + return { + key: mentionText, + text: mentionText, + label: `@${file.fileName}`, + description: file.filePath || file.fullPath + } + }) + } if (query.startsWith('$')) { return await getSkillSuggestions(query) } return await getSlashSuggestions(query) - }, [getSkillSuggestions, getSlashSuggestions]) + }, [agentType, api, sessionId, getSkillSuggestions, getSlashSuggestions]) const refreshSelectedSession = useCallback(() => { void refetchSession()