From 140311dc77d1900e57ef1a5f0896c25d1b42cbf1 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:47:11 -0400 Subject: [PATCH 1/4] Add repo context scanner API --- apps/codex-claw/src/routeTree.gen.ts | 21 + .../codex-claw/src/routes/api/repo-context.ts | 33 ++ .../src/screens/chat/chat-queries.ts | 15 + apps/codex-claw/src/screens/chat/types.ts | 36 ++ apps/codex-claw/src/server/repo-context.ts | 361 ++++++++++++++++++ 5 files changed, 466 insertions(+) create mode 100644 apps/codex-claw/src/routes/api/repo-context.ts create mode 100644 apps/codex-claw/src/server/repo-context.ts diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index facec96..8cc136e 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ApiWorkspacesRouteImport } from './routes/api/workspaces' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiSessionsRouteImport } from './routes/api/sessions' import { Route as ApiSendRouteImport } from './routes/api/send' +import { Route as ApiRepoContextRouteImport } from './routes/api/repo-context' import { Route as ApiPingRouteImport } from './routes/api/ping' import { Route as ApiPathsRouteImport } from './routes/api/paths' import { Route as ApiHistoryRouteImport } from './routes/api/history' @@ -61,6 +62,11 @@ const ApiSendRoute = ApiSendRouteImport.update({ path: '/api/send', getParentRoute: () => rootRouteImport, } as any) +const ApiRepoContextRoute = ApiRepoContextRouteImport.update({ + id: '/api/repo-context', + path: '/api/repo-context', + getParentRoute: () => rootRouteImport, +} as any) const ApiPingRoute = ApiPingRouteImport.update({ id: '/api/ping', path: '/api/ping', @@ -84,6 +90,7 @@ export interface FileRoutesByFullPath { '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute + '/api/repo-context': typeof ApiRepoContextRoute '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute @@ -97,6 +104,7 @@ export interface FileRoutesByTo { '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute + '/api/repo-context': typeof ApiRepoContextRoute '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute @@ -111,6 +119,7 @@ export interface FileRoutesById { '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute + '/api/repo-context': typeof ApiRepoContextRoute '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute @@ -126,6 +135,7 @@ export interface FileRouteTypes { | '/api/history' | '/api/paths' | '/api/ping' + | '/api/repo-context' | '/api/send' | '/api/sessions' | '/api/stream' @@ -139,6 +149,7 @@ export interface FileRouteTypes { | '/api/history' | '/api/paths' | '/api/ping' + | '/api/repo-context' | '/api/send' | '/api/sessions' | '/api/stream' @@ -152,6 +163,7 @@ export interface FileRouteTypes { | '/api/history' | '/api/paths' | '/api/ping' + | '/api/repo-context' | '/api/send' | '/api/sessions' | '/api/stream' @@ -166,6 +178,7 @@ export interface RootRouteChildren { ApiHistoryRoute: typeof ApiHistoryRoute ApiPathsRoute: typeof ApiPathsRoute ApiPingRoute: typeof ApiPingRoute + ApiRepoContextRoute: typeof ApiRepoContextRoute ApiSendRoute: typeof ApiSendRoute ApiSessionsRoute: typeof ApiSessionsRoute ApiStreamRoute: typeof ApiStreamRoute @@ -231,6 +244,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSendRouteImport parentRoute: typeof rootRouteImport } + '/api/repo-context': { + id: '/api/repo-context' + path: '/api/repo-context' + fullPath: '/api/repo-context' + preLoaderRoute: typeof ApiRepoContextRouteImport + parentRoute: typeof rootRouteImport + } '/api/ping': { id: '/api/ping' path: '/api/ping' @@ -262,6 +282,7 @@ const rootRouteChildren: RootRouteChildren = { ApiHistoryRoute: ApiHistoryRoute, ApiPathsRoute: ApiPathsRoute, ApiPingRoute: ApiPingRoute, + ApiRepoContextRoute: ApiRepoContextRoute, ApiSendRoute: ApiSendRoute, ApiSessionsRoute: ApiSessionsRoute, ApiStreamRoute: ApiStreamRoute, diff --git a/apps/codex-claw/src/routes/api/repo-context.ts b/apps/codex-claw/src/routes/api/repo-context.ts new file mode 100644 index 0000000..e2e2747 --- /dev/null +++ b/apps/codex-claw/src/routes/api/repo-context.ts @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { getRepoContextPayload } from '../../server/repo-context' +import type { RepoContextSelection } from '../../server/repo-context' + +function parseSelections(url: URL): Array { + return url.searchParams + .getAll('selected') + .map((path) => path.trim()) + .filter(Boolean) + .slice(0, 50) + .map((path) => ({ path })) +} + +export const Route = createFileRoute('/api/repo-context')({ + server: { + handlers: { + GET: ({ request }) => { + try { + const url = new URL(request.url) + return json(getRepoContextPayload(parseSelections(url))) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index 2a3b357..95bd7c8 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -3,6 +3,8 @@ import type { QueryClient } from '@tanstack/react-query' import type { GatewayMessage, HistoryResponse, + RepoContextPayload, + RepoContextSelection, SessionListResponse, SessionMeta, WorkspaceListResponse, @@ -112,6 +114,19 @@ export async function deleteWorkspace( return (await res.json()) as WorkspaceListResponse } +export async function fetchRepoContext( + selections: Array, +): Promise { + const query = new URLSearchParams() + for (const selection of selections) { + if (selection.path.trim()) query.append('selected', selection.path.trim()) + } + const suffix = query.toString() ? '?' + query.toString() : '' + const res = await fetch('/api/repo-context' + suffix) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as RepoContextPayload +} + export function updateHistoryMessages( queryClient: QueryClient, friendlyId: string, diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index 1d986b9..1e170b1 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -131,3 +131,39 @@ export type WorkspaceListResponse = { workspaces: Array health: WorkspaceHealthPayload } + +export type RepoContextEntry = { + path: string + name: string + type: 'file' | 'directory' + depth: number + size?: number +} + +export type RepoAgentFile = { + path: string + directory: string + appliesToSelected: boolean +} + +export type RepoContextSelection = { + path: string + type?: 'file' | 'directory' +} + +export type RepoContextEstimate = { + selectedPaths: Array + fileCount: number + byteCount: number + estimatedTokens: number + oversized: boolean + truncated: boolean +} + +export type RepoContextPayload = { + workdir: string + entries: Array + agents: Array + applicableAgents: Array + estimate: RepoContextEstimate +} diff --git a/apps/codex-claw/src/server/repo-context.ts b/apps/codex-claw/src/server/repo-context.ts new file mode 100644 index 0000000..849fed2 --- /dev/null +++ b/apps/codex-claw/src/server/repo-context.ts @@ -0,0 +1,361 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import { getCodexPaths } from './codex-cli' + +export type RepoContextEntry = { + path: string + name: string + type: 'file' | 'directory' + depth: number + size?: number +} + +export type RepoAgentFile = { + path: string + directory: string + appliesToSelected: boolean +} + +export type RepoContextSelection = { + path: string + type?: 'file' | 'directory' +} + +export type RepoContextEstimate = { + selectedPaths: Array + fileCount: number + byteCount: number + estimatedTokens: number + oversized: boolean + truncated: boolean +} + +export type RepoContextPayload = { + workdir: string + entries: Array + agents: Array + applicableAgents: Array + estimate: RepoContextEstimate +} + +const maxEntries = 600 +const maxDepth = 6 +const maxContextChars = 32000 +const oversizedTokenThreshold = 24000 +const defaultIgnoredNames = new Set([ + '.git', + '.next', + '.turbo', + '.vercel', + 'coverage', + 'dist', + 'node_modules', + 'out', +]) + +const textFileExtensions = new Set([ + '.c', + '.cc', + '.conf', + '.cpp', + '.cs', + '.css', + '.go', + '.h', + '.hpp', + '.html', + '.java', + '.js', + '.json', + '.jsx', + '.md', + '.mdx', + '.mjs', + '.py', + '.rb', + '.rs', + '.sh', + '.sql', + '.svelte', + '.toml', + '.ts', + '.tsx', + '.txt', + '.vue', + '.xml', + '.yaml', + '.yml', +]) + +function activeWorkdir() { + return getCodexPaths().workspace.codexWorkdir +} + +function toRepoPath(value: string) { + return value.split(path.sep).join('/') +} + +function fromRepoPath(workdir: string, repoPath: string) { + return path.resolve(workdir, repoPath) +} + +function isInsideWorkdir(workdir: string, targetPath: string) { + const relative = path.relative(workdir, targetPath) + return ( + Boolean(relative) && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ) +} + +function normalizeSelectionPath(value: string) { + return value.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/g, '') +} + +function depthOf(repoPath: string) { + if (!repoPath) return 0 + return repoPath.split('/').filter(Boolean).length - 1 +} + +function runGitLsFiles(workdir: string) { + const result = spawnSync('git', ['ls-files', '-co', '--exclude-standard'], { + cwd: workdir, + env: process.env, + stdio: 'pipe', + encoding: 'utf8', + }) + + if (result.status !== 0) return null + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) +} + +function scanWithoutGit(workdir: string) { + const files: Array = [] + + function visit(directory: string, depth: number) { + if (files.length >= maxEntries || depth > maxDepth) return + const names = readdirSync(directory, { withFileTypes: true }) + for (const item of names) { + if (files.length >= maxEntries) break + if (defaultIgnoredNames.has(item.name)) continue + const absolutePath = path.join(directory, item.name) + const repoPath = toRepoPath(path.relative(workdir, absolutePath)) + if (item.isDirectory()) { + visit(absolutePath, depth + 1) + continue + } + if (item.isFile()) files.push(repoPath) + } + } + + visit(workdir, 0) + return files +} + +function selectedFilesFromEntries( + entries: Array, + selections: Array, +) { + const files = new Set() + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])) + + for (const selection of selections) { + const selectedPath = normalizeSelectionPath(selection.path) + if (!selectedPath) continue + const entry = entryByPath.get(selectedPath) + if (!entry) continue + if (entry.type === 'file') { + files.add(entry.path) + continue + } + for (const candidate of entries) { + if ( + candidate.type === 'file' && + candidate.path.startsWith(entry.path + '/') + ) { + files.add(candidate.path) + } + } + } + + return [...files].sort() +} + +function buildEntries(workdir: string, files: Array) { + const entries = new Map() + + for (const filePath of files.slice(0, maxEntries)) { + const normalized = normalizeSelectionPath(filePath) + if (!normalized) continue + const parts = normalized.split('/') + let current = '' + for (let index = 0; index < parts.length - 1; index += 1) { + current = current ? current + '/' + parts[index] : parts[index] + if (!entries.has(current)) { + entries.set(current, { + path: current, + name: parts[index], + type: 'directory', + depth: depthOf(current), + }) + } + } + + const absolutePath = fromRepoPath(workdir, normalized) + const stat = existsSync(absolutePath) ? statSync(absolutePath) : null + entries.set(normalized, { + path: normalized, + name: parts[parts.length - 1], + type: 'file', + depth: depthOf(normalized), + size: stat?.isFile() ? stat.size : undefined, + }) + } + + return [...entries.values()].sort((a, b) => { + if (a.path === b.path) return 0 + const parentCompare = a.path.localeCompare(b.path) + if (a.type === b.type) return parentCompare + if (a.path.startsWith(b.path + '/')) return 1 + if (b.path.startsWith(a.path + '/')) return -1 + return parentCompare + }) +} + +function isTextFile(repoPath: string) { + if (repoPath === 'AGENTS.md') return true + return textFileExtensions.has(path.extname(repoPath).toLowerCase()) +} + +function agentsFromEntries( + entries: Array, + selectedPaths: Array, +) { + const normalizedSelections = selectedPaths.map(normalizeSelectionPath) + return entries + .filter((entry) => entry.type === 'file' && entry.name === 'AGENTS.md') + .map((entry) => { + const directory = entry.path.includes('/') + ? entry.path.slice(0, entry.path.lastIndexOf('/')) + : '' + const appliesToSelected = normalizedSelections.some((selection) => { + if (!selection) return false + if (!directory) return true + return selection === directory || selection.startsWith(directory + '/') + }) + return { + path: entry.path, + directory, + appliesToSelected, + } + }) +} + +function estimateContext( + workdir: string, + entries: Array, + selections: Array, +): RepoContextEstimate { + const selectedFiles = selectedFilesFromEntries(entries, selections) + let byteCount = 0 + let truncated = false + + for (const filePath of selectedFiles) { + const absolutePath = fromRepoPath(workdir, filePath) + if (!isInsideWorkdir(workdir, absolutePath)) continue + if (!existsSync(absolutePath)) continue + const stat = statSync(absolutePath) + if (!stat.isFile()) continue + byteCount += stat.size + if (byteCount > maxContextChars * 4) truncated = true + } + + const estimatedTokens = Math.ceil(byteCount / 4) + return { + selectedPaths: selections + .map((selection) => normalizeSelectionPath(selection.path)) + .filter(Boolean), + fileCount: selectedFiles.length, + byteCount, + estimatedTokens, + oversized: estimatedTokens > oversizedTokenThreshold, + truncated, + } +} + +export function getRepoContextPayload( + selections: Array = [], +): RepoContextPayload { + const workdir = activeWorkdir() + if (!existsSync(workdir)) { + throw new Error('Workspace directory does not exist: ' + workdir) + } + + const files = runGitLsFiles(workdir) ?? scanWithoutGit(workdir) + const entries = buildEntries(workdir, files) + const selectedPaths = selections.map((selection) => selection.path) + const agents = agentsFromEntries(entries, selectedPaths) + + return { + workdir, + entries, + agents, + applicableAgents: agents.filter((agent) => agent.appliesToSelected), + estimate: estimateContext(workdir, entries, selections), + } +} + +function readContextFile(absolutePath: string) { + const buffer = readFileSync(absolutePath) + if (buffer.includes(0)) return null + return buffer.toString('utf8') +} + +export function buildRepositoryContextPrompt( + selections: Array, +) { + if (selections.length === 0) return '' + + const payload = getRepoContextPayload(selections) + const selectedFiles = selectedFilesFromEntries(payload.entries, selections) + const selectedSet = new Set(selectedFiles) + for (const agent of payload.applicableAgents) { + selectedSet.add(agent.path) + } + + let usedChars = 0 + const blocks: Array = [] + for (const repoPath of [...selectedSet].sort()) { + if (!isTextFile(repoPath)) continue + const absolutePath = fromRepoPath(payload.workdir, repoPath) + if (!isInsideWorkdir(payload.workdir, absolutePath)) continue + if (!existsSync(absolutePath)) continue + const content = readContextFile(absolutePath) + if (content === null) continue + const remaining = maxContextChars - usedChars + if (remaining <= 0) break + const body = + content.length > remaining + ? content.slice(0, remaining) + '\n[truncated]' + : content + usedChars += body.length + blocks.push('--- ' + repoPath + ' ---\n' + body) + } + + if (blocks.length === 0) return '' + + return [ + 'Repository context selected in CodexClaw:', + 'Selected paths: ' + payload.estimate.selectedPaths.join(', '), + 'Estimated impact: ' + + payload.estimate.estimatedTokens + + ' tokens across ' + + payload.estimate.fileCount + + ' files.', + ...blocks, + ].join('\n\n') +} From 0f62f4bbfa38ff63eb0d0744c7935561338b86e8 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:47:21 -0400 Subject: [PATCH 2/4] Send selected repository context --- apps/codex-claw/src/routes/api/send.ts | 40 ++++++++++++++++++- .../src/screens/chat/chat-screen-utils.ts | 10 ++++- .../src/screens/chat/chat-screen.tsx | 26 ++++++++++-- .../chat/hooks/use-chat-pending-send.ts | 4 +- .../src/screens/chat/pending-send.ts | 3 +- apps/codex-claw/src/server/codex-cli.ts | 4 ++ 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/apps/codex-claw/src/routes/api/send.ts b/apps/codex-claw/src/routes/api/send.ts index 1b7bf1c..12546af 100644 --- a/apps/codex-claw/src/routes/api/send.ts +++ b/apps/codex-claw/src/routes/api/send.ts @@ -7,6 +7,8 @@ import { resolveCodexSession, sendCodexPrompt, } from '../../server/codex-cli' +import { buildRepositoryContextPrompt } from '../../server/repo-context' +import type { RepoContextSelection } from '../../server/repo-context' type ParsedAttachment = { mimeType: string @@ -24,6 +26,28 @@ type AttachmentParseResult = error: string } +function parseContextSelections(rawSelections: unknown) { + if (typeof rawSelections === 'undefined') return [] + if (!Array.isArray(rawSelections)) return [] + + const selections: Array = [] + for (const rawSelection of rawSelections.slice(0, 50)) { + if (!rawSelection || typeof rawSelection !== 'object') continue + const selection = rawSelection as Record + const selectedPath = + typeof selection.path === 'string' ? selection.path.trim() : '' + if (!selectedPath) continue + selections.push({ + path: selectedPath, + type: + selection.type === 'directory' || selection.type === 'file' + ? selection.type + : undefined, + }) + } + return selections +} + function parseAttachments(rawAttachments: unknown): AttachmentParseResult { if (typeof rawAttachments === 'undefined') { return { ok: true, attachments: undefined } @@ -111,8 +135,19 @@ export const Route = createFileRoute('/api/send')({ ) } const attachments = parsedAttachments.attachments - - if (!message.trim() && (!attachments || attachments.length === 0)) { + const contextSelections = parseContextSelections( + body.contextSelections, + ) + const contextBlock = + contextSelections.length > 0 + ? buildRepositoryContextPrompt(contextSelections) + : undefined + + if ( + !message.trim() && + (!attachments || attachments.length === 0) && + !contextBlock + ) { return json( { ok: false, error: 'message required' }, { status: 400 }, @@ -143,6 +178,7 @@ export const Route = createFileRoute('/api/send')({ message, thinking, attachments, + contextBlock, idempotencyKey: typeof body.idempotencyKey === 'string' ? body.idempotencyKey diff --git a/apps/codex-claw/src/screens/chat/chat-screen-utils.ts b/apps/codex-claw/src/screens/chat/chat-screen-utils.ts index f884713..a81ae9d 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen-utils.ts +++ b/apps/codex-claw/src/screens/chat/chat-screen-utils.ts @@ -1,4 +1,4 @@ -import type { GatewayMessage } from './types' +import type { GatewayMessage, RepoContextSelection } from './types' import type { AttachmentFile } from '@/components/attachment-button' import { randomUUID } from '@/lib/utils' @@ -11,6 +11,7 @@ type OptimisticMessagePayload = { export function createOptimisticMessage( body: string, attachments?: Array, + contextSelections?: Array, ): OptimisticMessagePayload { const clientId = randomUUID() const optimisticId = `opt-${clientId}` @@ -39,6 +40,13 @@ export function createOptimisticMessage( if (body.trim()) { content.push({ type: 'text', text: body }) + } else if (contextSelections && contextSelections.length > 0) { + content.push({ + type: 'text', + text: + 'Repository context: ' + + contextSelections.map((selection) => selection.path).join(', '), + }) } else if (attachments && attachments.length > 0) { content.push({ type: 'text', text: '' }) } diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index dd61692..8188aa3 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -43,6 +43,7 @@ import { shouldRedirectToConnect } from './hooks/use-chat-error-state' import { useChatRedirect } from './hooks/use-chat-redirect' import type { AttachmentFile } from '@/components/attachment-button' import type { ChatComposerHelpers } from './components/chat-composer' +import type { RepoContextSelection } from './types' import { useExport } from '@/hooks/use-export' import { useChatSettings } from '@/hooks/use-chat-settings' import { cn, randomUUID } from '@/lib/utils' @@ -158,7 +159,8 @@ export function ChatScreen({ !isRecentSession(activeFriendlyId) && sessionsQuery.isSuccess && !sessions.some((session) => session.friendlyId === activeFriendlyId) && - (missingSessionError || (!historyQuery.isFetching && !historyQuery.isSuccess)) + (missingSessionError || + (!historyQuery.isFetching && !historyQuery.isSuccess)) const refreshHistory = useCallback(() => { void historyQuery.refetch() @@ -229,12 +231,14 @@ export function ChatScreen({ body: string, skipOptimistic = false, attachments?: Array, + contextSelections?: Array, ) { let optimisticClientId = '' if (!skipOptimistic) { const { clientId, optimisticMessage } = createOptimisticMessage( body, attachments, + contextSelections, ) optimisticClientId = clientId appendHistoryMessage( @@ -272,6 +276,7 @@ export function ChatScreen({ thinking: settings.thinkingLevel, idempotencyKey: randomUUID(), attachments: attachmentsPayload, + contextSelections, }), }) .then(async (res) => { @@ -349,13 +354,18 @@ export function ChatScreen({ const send = useCallback( (body: string, helpers: ChatComposerHelpers) => { const attachments = helpers.attachments - if (body.length === 0 && (!attachments || attachments.length === 0)) + const contextSelections = helpers.contextSelections + if ( + body.length === 0 && + (!attachments || attachments.length === 0) && + (!contextSelections || contextSelections.length === 0) + ) return helpers.reset() if (isNewChat) { const { clientId, optimisticId, optimisticMessage } = - createOptimisticMessage(body, attachments) + createOptimisticMessage(body, attachments, contextSelections) appendHistoryMessage(queryClient, 'new', 'new', optimisticMessage) setPendingGeneration(true) setSending(true) @@ -371,6 +381,7 @@ export function ChatScreen({ message: body, optimisticMessage, attachments, + contextSelections, }) if (onSessionResolved) { onSessionResolved({ sessionKey, friendlyId }) @@ -404,7 +415,14 @@ export function ChatScreen({ resolvedSessionKey || activeSessionKey || activeFriendlyId - sendMessage(sessionKeyForSend, activeFriendlyId, body, false, attachments) + sendMessage( + sessionKeyForSend, + activeFriendlyId, + body, + false, + attachments, + contextSelections, + ) }, [ activeFriendlyId, diff --git a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts index 6876ae2..c6bb936 100644 --- a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts +++ b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts @@ -8,7 +8,7 @@ import { import type { QueryClient } from '@tanstack/react-query' import type { AttachmentFile } from '@/components/attachment-button' -import type { HistoryResponse } from '../types' +import type { HistoryResponse, RepoContextSelection } from '../types' type UseChatPendingSendInput = { activeFriendlyId: string @@ -25,6 +25,7 @@ type UseChatPendingSendInput = { body: string, skipOptimistic: boolean, attachments?: Array, + contextSelections?: Array, ) => void } @@ -98,6 +99,7 @@ export function useChatPendingSend({ pending.message, true, pending.attachments, + pending.contextSelections, ) }, [ activeFriendlyId, diff --git a/apps/codex-claw/src/screens/chat/pending-send.ts b/apps/codex-claw/src/screens/chat/pending-send.ts index c930fa0..d335f48 100644 --- a/apps/codex-claw/src/screens/chat/pending-send.ts +++ b/apps/codex-claw/src/screens/chat/pending-send.ts @@ -1,4 +1,4 @@ -import type { GatewayMessage } from './types' +import type { GatewayMessage, RepoContextSelection } from './types' import type { AttachmentFile } from '@/components/attachment-button' export type PendingSendPayload = { @@ -7,6 +7,7 @@ export type PendingSendPayload = { message: string optimisticMessage: GatewayMessage attachments?: Array + contextSelections?: Array } let pendingSend: PendingSendPayload | null = null diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index cad34bb..e1b6f3e 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -77,6 +77,7 @@ type SendCodexPromptInput = { message: string thinking?: string attachments?: Array + contextBlock?: string idempotencyKey?: string } @@ -591,6 +592,9 @@ function buildUserPrompt(input: SendCodexPromptInput) { `Attached image count: ${input.attachments.length}. Review the attached image files when they are relevant to the request.`, ) } + if (input.contextBlock?.trim()) { + parts.push(input.contextBlock.trim()) + } return parts.join('\n\n') } From 41fec0ebcb9ed3e73ef2db8183b726a135849d4c Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:47:31 -0400 Subject: [PATCH 3/4] Add repository context picker --- .../screens/chat/components/chat-composer.tsx | 61 +++- .../chat/components/repo-context-picker.tsx | 270 ++++++++++++++++++ 2 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 apps/codex-claw/src/screens/chat/components/repo-context-picker.tsx diff --git a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx index a8a083e..94ddb28 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx @@ -1,7 +1,13 @@ import { memo, useCallback, useRef, useState } from 'react' import { HugeiconsIcon } from '@hugeicons/react' import { ArrowUp02Icon } from '@hugeicons/core-free-icons' +import { + RepoContextButton, + RepoContextPanel, + RepoContextSummary, +} from './repo-context-picker' import type { Ref } from 'react' +import type { RepoContextSelection } from '../types' import type { AttachmentFile } from '@/components/attachment-button' import { @@ -27,6 +33,7 @@ type ChatComposerHelpers = { reset: () => void setValue: (value: string) => void attachments?: Array + contextSelections?: Array } function ChatComposerComponent({ @@ -36,6 +43,10 @@ function ChatComposerComponent({ wrapperRef, }: ChatComposerProps) { const [attachments, setAttachments] = useState>([]) + const [contextOpen, setContextOpen] = useState(false) + const [contextSelections, setContextSelections] = useState< + Array + >([]) const promptRef = useRef(null) const valueRef = useRef('') const setValueRef = useRef<((value: string) => void) | null>(null) @@ -57,6 +68,8 @@ function ChatComposerComponent({ }) return [] }) + setContextSelections([]) + setContextOpen(false) focusPrompt() }, [focusPrompt]) const handleFileSelect = useCallback((file: AttachmentFile) => { @@ -71,6 +84,11 @@ function ChatComposerComponent({ return prev.filter((attachment) => attachment.id !== id) }) }, []) + const handleRemoveContext = useCallback((path: string) => { + setContextSelections((prev) => + prev.filter((selection) => selection.path !== path), + ) + }, []) const setComposerValue = useCallback( (nextValue: string) => { if (setValueRef.current) { @@ -83,16 +101,30 @@ function ChatComposerComponent({ const handleSubmit = useCallback(() => { if (disabled) return const body = valueRef.current.trim() - // Allow submit if there's text OR valid attachments + // Allow submit if there is text, an image, or selected repo context. const validAttachments = attachments.filter((a) => !a.error && a.base64) - if (body.length === 0 && validAttachments.length === 0) return + if ( + body.length === 0 && + validAttachments.length === 0 && + contextSelections.length === 0 + ) + return onSubmit(body, { reset, setValue: setComposerValue, attachments: validAttachments, + contextSelections, }) focusPrompt() - }, [disabled, focusPrompt, onSubmit, reset, setComposerValue, attachments]) + }, [ + disabled, + focusPrompt, + onSubmit, + reset, + setComposerValue, + attachments, + contextSelections, + ]) const submitDisabled = disabled return ( @@ -112,6 +144,15 @@ function ChatComposerComponent({ attachments={attachments} onRemove={handleRemoveAttachment} /> + + )} /> + ( + setContextOpen((current) => !current)} + disabled={disabled} + buttonProps={{ + ...triggerProps, + className: cn('rounded-full', triggerProps.className), + }} + /> + )} + /> ( diff --git a/apps/codex-claw/src/screens/chat/components/repo-context-picker.tsx b/apps/codex-claw/src/screens/chat/components/repo-context-picker.tsx new file mode 100644 index 0000000..58ba59a --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/repo-context-picker.tsx @@ -0,0 +1,270 @@ +import { useEffect, useMemo, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Cancel01Icon, + File01Icon, + Folder01Icon, + FolderSearchIcon, +} from '@hugeicons/core-free-icons' + +import { fetchRepoContext } from '../chat-queries' +import type { + RepoContextEntry, + RepoContextPayload, + RepoContextSelection, +} from '../types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +type RepoContextPanelProps = { + open: boolean + selections: Array + onSelectionsChange: (selections: Array) => void +} + +type RepoContextSummaryProps = { + selections: Array + onRemove: (path: string) => void +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return String(bytes) + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} + +function entryIcon(entry: RepoContextEntry) { + return entry.type === 'directory' ? Folder01Icon : File01Icon +} + +function selectionKey(selection: RepoContextSelection) { + return selection.path +} + +export function RepoContextSummary({ + selections, + onRemove, +}: RepoContextSummaryProps) { + if (selections.length === 0) return null + + return ( +
+ {selections.map((selection) => ( + + + {selection.path} + + + ))} +
+ ) +} + +export function RepoContextPanel({ + open, + selections, + onSelectionsChange, +}: RepoContextPanelProps) { + const [query, setQuery] = useState('') + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) return + let cancelled = false + setLoading(true) + setError(null) + fetchRepoContext(selections) + .then((nextPayload) => { + if (!cancelled) setPayload(nextPayload) + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)) + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, selections]) + + const selectedPaths = useMemo( + () => new Set(selections.map((selection) => selection.path)), + [selections], + ) + const filteredEntries = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase() + const entries = payload?.entries ?? [] + if (!normalizedQuery) return entries.slice(0, 120) + return entries + .filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) + .slice(0, 120) + }, [payload, query]) + + function toggleEntry(entry: RepoContextEntry) { + if (selectedPaths.has(entry.path)) { + onSelectionsChange( + selections.filter((selection) => selection.path !== entry.path), + ) + return + } + onSelectionsChange([ + ...selections, + { + path: entry.path, + type: entry.type, + }, + ]) + } + + if (!open) return null + + const estimate = payload?.estimate + + return ( +
+
+
+ + setQuery(event.target.value)} + placeholder="Search files" + size="sm" + /> +
+
+ {payload?.workdir ?? 'Workspace'} + {estimate ? ( + + {estimate.estimatedTokens.toLocaleString()} tokens ·{' '} + {estimate.fileCount} files · {formatBytes(estimate.byteCount)} + + ) : null} +
+
+ + {error ?
{error}
: null} + {loading ? ( +
Loading context...
+ ) : null} + + {estimate?.oversized ? ( +
+ Selection is large enough to crowd the prompt context. +
+ ) : null} + + {payload?.applicableAgents.length ? ( +
+ Applies:{' '} + {payload.applicableAgents.map((agent) => agent.path).join(', ')} +
+ ) : null} + +
+ {filteredEntries.map((entry) => { + const selected = selectedPaths.has(entry.path) + return ( + + ) + })} + {!loading && filteredEntries.length === 0 ? ( +
No files found.
+ ) : null} +
+
+ ) +} + +export function RepoContextButton({ + open, + onToggle, + disabled, + buttonProps, +}: { + open: boolean + onToggle: () => void + disabled?: boolean + buttonProps?: React.ComponentProps +}) { + return ( + + ) +} From 4297725087d98625e93d6870eb51d3a0c962c064 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:47:41 -0400 Subject: [PATCH 4/4] Cover repo context AGENTS visibility --- .../src/server/repo-context.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/codex-claw/src/server/repo-context.test.ts diff --git a/apps/codex-claw/src/server/repo-context.test.ts b/apps/codex-claw/src/server/repo-context.test.ts new file mode 100644 index 0000000..caaddfd --- /dev/null +++ b/apps/codex-claw/src/server/repo-context.test.ts @@ -0,0 +1,74 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { resetCodexServerStateForTests } from './codex-cli' +import { + buildRepositoryContextPrompt, + getRepoContextPayload, +} from './repo-context' + +describe('repo context payload', function () { + const originalStateDir = process.env.CODEX_CLAW_STATE_DIR + const originalWorkdir = process.env.CODEX_CLI_WORKDIR + let tempDir = '' + + beforeEach(function () { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'codex-claw-context-')) + mkdirSync(path.join(tempDir, 'src'), { recursive: true }) + mkdirSync(path.join(tempDir, 'node_modules', 'ignored'), { + recursive: true, + }) + writeFileSync(path.join(tempDir, 'AGENTS.md'), 'root rules') + writeFileSync(path.join(tempDir, 'src', 'AGENTS.md'), 'src rules') + writeFileSync(path.join(tempDir, 'src', 'app.ts'), 'export const ok = true') + writeFileSync( + path.join(tempDir, 'node_modules', 'ignored', 'file.ts'), + 'ignored', + ) + process.env.CODEX_CLAW_STATE_DIR = path.join(tempDir, '.state') + process.env.CODEX_CLI_WORKDIR = tempDir + resetCodexServerStateForTests() + }) + + afterEach(function () { + if (originalStateDir === undefined) { + delete process.env.CODEX_CLAW_STATE_DIR + } else { + process.env.CODEX_CLAW_STATE_DIR = originalStateDir + } + if (originalWorkdir === undefined) { + delete process.env.CODEX_CLI_WORKDIR + } else { + process.env.CODEX_CLI_WORKDIR = originalWorkdir + } + resetCodexServerStateForTests() + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('finds applicable AGENTS.md files for selected paths', function () { + const payload = getRepoContextPayload([{ path: 'src/app.ts' }]) + + expect(payload.entries.map((entry) => entry.path)).toEqual( + expect.arrayContaining(['AGENTS.md', 'src/AGENTS.md', 'src/app.ts']), + ) + expect(payload.entries.map((entry) => entry.path)).not.toContain( + 'node_modules/ignored/file.ts', + ) + expect(payload.applicableAgents.map((agent) => agent.path)).toEqual([ + 'AGENTS.md', + 'src/AGENTS.md', + ]) + }) + + it('builds a bounded prompt context block', function () { + const prompt = buildRepositoryContextPrompt([{ path: 'src/app.ts' }]) + + expect(prompt).toContain('Repository context selected in CodexClaw') + expect(prompt).toContain('--- AGENTS.md ---') + expect(prompt).toContain('--- src/AGENTS.md ---') + expect(prompt).toContain('--- src/app.ts ---') + expect(prompt).toContain('export const ok = true') + }) +})