From 42497dceeb57cbca54c191a6c7c1247653753366 Mon Sep 17 00:00:00 2001 From: Ananovo <78636812+techotaku39@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:50:55 +0800 Subject: [PATCH 1/7] feat(agent): add provider-specific agent detail inventories --- cli/src/agent/contextDetails.test.ts | 200 +++++++++++ cli/src/agent/contextDetails.ts | 320 ++++++++++++++++++ cli/src/claude/claudeLocalLauncher.ts | 15 + cli/src/claude/claudeRemoteLauncher.ts | 33 +- cli/src/claude/runClaude.ts | 13 + cli/src/claude/sdk/types.ts | 3 + cli/src/codex/codexLocalLauncher.ts | 20 ++ cli/src/codex/codexRemoteLauncher.test.ts | 16 +- cli/src/codex/codexRemoteLauncher.ts | 67 +++- shared/src/contextDetails.ts | 59 ++++ shared/src/index.ts | 1 + shared/src/schemas.metadata.test.ts | 29 ++ shared/src/schemas.ts | 4 +- shared/src/types.ts | 6 + .../AssistantChat/ContextDetailsDialog.tsx | 197 +++++++++++ .../AssistantChat/HappyComposer.tsx | 11 + .../AssistantChat/StatusBar.popover.test.tsx | 166 ++++++++- .../components/AssistantChat/StatusBar.tsx | 126 +++++-- web/src/components/SessionChat.tsx | 5 +- web/src/index.css | 5 + web/src/lib/locales/en.ts | 30 ++ web/src/lib/locales/zh-CN.ts | 30 ++ 22 files changed, 1319 insertions(+), 37 deletions(-) create mode 100644 cli/src/agent/contextDetails.test.ts create mode 100644 cli/src/agent/contextDetails.ts create mode 100644 shared/src/contextDetails.ts create mode 100644 web/src/components/AssistantChat/ContextDetailsDialog.tsx diff --git a/cli/src/agent/contextDetails.test.ts b/cli/src/agent/contextDetails.test.ts new file mode 100644 index 0000000000..6cc1c86617 --- /dev/null +++ b/cli/src/agent/contextDetails.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import type { ContextDetails } from '@hapi/protocol' +import { + buildClaudeContextDetails, + buildCodexContextDetails, + mergeContextDetails +} from './contextDetails' + +describe('Claude context details', () => { + it('keeps detailed SDK init lists when no /context payload is available', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + system: { + model: 'claude-sonnet', + tools: ['Read', 'Bash'], + skills: ['find-docs', 'web-search'], + slash_commands: ['/context', '/compact'] + } + }) + + expect(details).toMatchObject({ + claude: { + systemTools: ['Read', 'Bash'], + skills: [{ name: 'find-docs' }, { name: 'web-search' }], + slashCommands: ['/context', '/compact'] + } + }) + }) + + it('keeps only displayed inventories without prompt or resource content', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + system: { + model: 'claude-sonnet', + tools: ['Read', 'Bash'], + slash_commands: ['/context', '/compact'] + }, + contextUsage: { + model: 'claude-sonnet', + total_tokens: 26_697, + raw_max_tokens: 262_144, + categories: [{ name: 'System tools', tokens: 19_740, kind: 'used' }], + memory_files: [{ path: '/workspace/AGENTS.md', type: 'Project', tokens: 2_998 }], + skills: [{ name: 'find-docs', source: 'user', tokens: 2_604 }], + agents: [{ agent_type: 'probe', source: 'flagSettings', tokens: 13 }], + mcp_tools: [{ name: 'mcp__probe__echo', server_name: 'probe' }] + } + }) + + expect(details).toMatchObject({ + provider: 'claude', + model: 'claude-sonnet', + contextWindow: 262_144, + usage: { contextTokens: 26_697 }, + claude: { + systemTools: ['Read', 'Bash'], + slashCommands: ['/context', '/compact'], + skills: [{ name: 'find-docs' }], + mcpTools: [{ name: 'mcp__probe__echo', serverName: 'probe' }] + } + }) + expect(details?.claude).not.toHaveProperty('categories') + expect(details?.claude).not.toHaveProperty('memoryFiles') + expect(details?.claude).not.toHaveProperty('agents') + expect(JSON.stringify(details)).not.toContain('prompt') + }) + + it('can seed usage from a result when /context has not been requested', () => { + const details = buildClaudeContextDetails({ + updatedAt: 100, + result: { + model: 'claude-opus', + usage: { input_tokens: 4_000, output_tokens: 500, cache_read_input_tokens: 3_000, cache_creation_input_tokens: 100 }, + modelUsage: { + 'claude-opus': { contextWindow: 200_000 } + } + } + }) + + expect(details).toMatchObject({ + model: 'claude-opus', + contextWindow: 200_000, + usage: { cacheReadTokens: 3_000 } + }) + }) +}) + +describe('Codex context details', () => { + it('normalizes last and cumulative usage plus runtime inventories', () => { + const details = buildCodexContextDetails({ + updatedAt: 100, + info: { + modelContextWindow: 258_400, + contextTokens: 11_000, + last: { + inputTokens: 11_000, + cachedInputTokens: 8_000, + cacheWriteInputTokens: 200, + outputTokens: 900, + reasoningOutputTokens: 300, + totalTokens: 11_900 + }, + total: { + inputTokens: 22_000, + cachedInputTokens: 16_000, + outputTokens: 1_800, + totalTokens: 23_800 + } + }, + model: 'gpt-5.6-codex', + threadResponse: { + model: 'gpt-5.6-codex', + instructionSources: ['/home/user/AGENTS.md'], + thread: { id: 'thread-1' } + }, + threadParams: { model: 'gpt-5.6-codex' }, + slashCommands: ['clear', '/compact', 'clear'], + skills: [{ + name: 'find-docs', + description: 'Find docs', + path: '/home/user/.codex/skills/find-docs/SKILL.md', + scope: 'user', + enabled: true + }], + mcpServers: { + hapi: { + command: 'node', + args: ['mcp'], + tools: { change_title: {}, list_peers: {} } + } + } + }) + + expect(details).toMatchObject({ + provider: 'codex', + model: 'gpt-5.6-codex', + contextWindow: 258_400, + usage: { contextTokens: 11_000, cacheReadTokens: 8_000 }, + codex: { + slashCommands: ['clear', '/compact'], + skills: [{ name: 'find-docs' }], + mcpServers: [{ name: 'hapi', toolNames: ['change_title', 'list_peers'] }] + } + }) + }) +}) + +describe('mergeContextDetails', () => { + it('keeps static provider details while replacing newer usage values', () => { + const first = buildCodexContextDetails({ + updatedAt: 100, + info: { modelContextWindow: 100_000, last: { inputTokens: 10 } }, + model: 'gpt-5', + threadResponse: { modelProvider: 'openai', instructionSources: ['AGENTS.md'] }, + threadParams: { sandbox: 'workspace-write' }, + mcpServers: { hapi: { command: 'node', args: ['mcp'] } } + }) + const second = buildCodexContextDetails({ + updatedAt: 200, + info: { modelContextWindow: 100_000, last: { inputTokens: 20 } }, + model: 'gpt-5', + threadId: 'thread-1' + }) + const merged = mergeContextDetails(first, second) + + expect(merged.updatedAt).toBe(100) + expect(merged.usage?.contextTokens).toBeUndefined() + expect(merged.codex?.mcpServers).toEqual([{ name: 'hapi' }]) + }) + + it('compacts obsolete provider fields when updating existing metadata', () => { + const legacy = { + version: 1, + updatedAt: 100, + provider: 'codex', + codex: { + sandbox: 'workspace-write', + instructionSources: ['AGENTS.md'], + skills: [{ + name: 'find-docs', + scope: 'user', + path: '/home/user/.codex/skills/find-docs/SKILL.md', + description: 'Find docs' + }] + } + } as unknown as ContextDetails + const next = buildCodexContextDetails({ + updatedAt: 200, + model: 'gpt-5', + slashCommands: ['clear'] + }) + + const compacted = mergeContextDetails(legacy, next) + + expect(compacted.codex?.skills).toEqual([{ name: 'find-docs' }]) + expect(JSON.stringify(compacted)).not.toContain('workspace-write') + expect(JSON.stringify(compacted)).not.toContain('AGENTS.md') + expect(JSON.stringify(compacted)).not.toContain('Find docs') + }) +}) diff --git a/cli/src/agent/contextDetails.ts b/cli/src/agent/contextDetails.ts new file mode 100644 index 0000000000..af86eee73f --- /dev/null +++ b/cli/src/agent/contextDetails.ts @@ -0,0 +1,320 @@ +import type { + ClaudeContextDetails, + ContextDetails, + ContextUsageSnapshot, + CodexContextDetails, + Metadata +} from '@hapi/protocol' +import type { SkillMetadata, ThreadStartParams } from '@/codex/appServerTypes' +import type { McpServersConfig } from '@/codex/utils/buildHapiMcpBridge' + +type JsonRecord = Record + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function asTokenCount(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined + return Math.round(value) +} + +function asStringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const values = value + .map(asString) + .filter((value): value is string => value !== undefined) + return values.length > 0 ? values : undefined +} + +function firstRecord(value: unknown): JsonRecord | null { + if (!Array.isArray(value)) return null + for (const item of value) { + const record = asRecord(item) + if (record) return record + } + return null +} + +function normalizeUsageSnapshot(value: unknown): ContextUsageSnapshot | undefined { + const record = asRecord(value) + if (!record) return undefined + + const usage: ContextUsageSnapshot = { + contextTokens: asTokenCount(record.contextTokens ?? record.context_tokens), + cacheReadTokens: asTokenCount( + record.cachedInputTokens + ?? record.cached_input_tokens + ?? record.cacheReadInputTokens + ?? record.cache_read_input_tokens + ), + } + + const hasValue = Object.values(usage).some((value) => value !== undefined) + return hasValue ? usage : undefined +} + +function getClaudeModelUsageContextWindow(result: JsonRecord | null): number | undefined { + const modelUsage = asRecord(result?.modelUsage ?? result?.model_usage) + if (!modelUsage) return undefined + for (const usage of Object.values(modelUsage)) { + const contextWindow = asTokenCount(asRecord(usage)?.contextWindow ?? asRecord(usage)?.context_window) + if (contextWindow !== undefined) return contextWindow + } + return undefined +} + +function buildClaudeSkills(value: unknown): ClaudeContextDetails['skills'] { + if (!Array.isArray(value)) return undefined + const skills = value.flatMap((item) => { + const record = asRecord(item) + const name = typeof item === 'string' ? asString(item) : asString(record?.name) + if (!name) return [] + return [{ name }] + }) + return skills.length > 0 ? skills : undefined +} + +function buildClaudeMcpTools(value: unknown): ClaudeContextDetails['mcpTools'] { + if (!Array.isArray(value)) return undefined + const tools = value.flatMap((item) => { + const record = asRecord(item) + const name = asString(record?.name ?? record?.tool_name ?? record?.toolName) + if (!name) return [] + return [{ + name, + serverName: asString(record?.server_name ?? record?.serverName ?? record?.server) + }] + }) + return tools.length > 0 ? tools : undefined +} + +export function buildClaudeContextDetails(args: { + contextUsage?: unknown + system?: unknown + result?: unknown + messageUsage?: unknown + model?: string | null + updatedAt?: number +}): ContextDetails | null { + const contextUsage = asRecord(args.contextUsage) + const system = asRecord(args.system) + const result = asRecord(args.result) + const resultModelUsage = firstRecord(result?.modelUsage ?? result?.model_usage) + const contextWindow = asTokenCount( + contextUsage?.raw_max_tokens + ?? contextUsage?.rawMaxTokens + ?? contextUsage?.context_window + ?? contextUsage?.contextWindow + ?? resultModelUsage?.contextWindow + ?? resultModelUsage?.context_window + ?? getClaudeModelUsageContextWindow(result) + ) + const messageUsage = normalizeUsageSnapshot(args.messageUsage ?? result?.usage) + const contextTokens = asTokenCount( + contextUsage?.total_tokens + ?? contextUsage?.totalTokens + ?? contextUsage?.context_tokens + ?? contextUsage?.contextTokens + ) + const usage: ContextUsageSnapshot = { + ...messageUsage, + ...(contextTokens !== undefined ? { contextTokens } : {}) + } + const hasUsage = Object.values(usage).some((value) => value !== undefined) + + const skills = buildClaudeSkills(contextUsage?.skills) ?? buildClaudeSkills(system?.skills) + const mcpTools = buildClaudeMcpTools(contextUsage?.mcp_tools ?? contextUsage?.mcpTools) + const systemTools = asStringList(system?.tools) + const slashCommands = asStringList(system?.slash_commands) + const claude: ClaudeContextDetails = { + ...(skills ? { skills } : {}), + ...(mcpTools ? { mcpTools } : {}), + ...(systemTools ? { systemTools } : {}), + ...(slashCommands ? { slashCommands } : {}) + } + const hasClaudeDetails = Object.keys(claude).length > 0 + const model = asString(contextUsage?.model) ?? asString(args.model) ?? asString(system?.model) ?? asString(result?.model) + + if (!model && contextWindow === undefined && !hasUsage && !hasClaudeDetails) return null + + return { + version: 1, + updatedAt: args.updatedAt ?? Date.now(), + provider: 'claude', + ...(model ? { model } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(hasUsage ? { usage } : {}), + ...(hasClaudeDetails ? { claude } : {}) + } +} + +function buildCodexUsage(value: unknown, fallbackContextTokens?: number): ContextUsageSnapshot | undefined { + const usage = normalizeUsageSnapshot(value) + if (!usage) return undefined + if (usage.contextTokens === undefined && fallbackContextTokens !== undefined) { + return { ...usage, contextTokens: fallbackContextTokens } + } + return usage +} + +function getCodexThreadRecord(response: unknown): JsonRecord | null { + return asRecord(asRecord(response)?.thread) +} + +export function buildCodexContextDetails(args: { + info?: unknown + model?: string | null + threadId?: string | null + threadResponse?: unknown + threadParams?: ThreadStartParams + slashCommands?: readonly string[] + skills?: readonly SkillMetadata[] + mcpServers?: McpServersConfig + updatedAt?: number +}): ContextDetails { + const info = asRecord(args.info) + const last = asRecord(info?.last ?? info?.lastTokenUsage ?? info?.last_token_usage) + const infoContextTokens = asTokenCount(info?.contextTokens ?? info?.context_tokens) + const usage = buildCodexUsage(last ?? info, infoContextTokens) + const response = asRecord(args.threadResponse) + const thread = getCodexThreadRecord(args.threadResponse) + const contextWindow = asTokenCount( + info?.modelContextWindow + ?? info?.model_context_window + ?? response?.modelContextWindow + ?? response?.model_context_window + ?? thread?.modelContextWindow + ?? thread?.model_context_window + ) + const model = asString(args.model) ?? asString(response?.model) ?? asString(thread?.model) + const skills = args.skills + ?.filter((skill) => skill.enabled) + .map((skill) => ({ + name: skill.name + })) + const mcpServers = args.mcpServers + ? Object.entries(args.mcpServers).map(([name, server]) => ({ + name, + toolNames: server.tools ? Object.keys(server.tools) : undefined + })) + : undefined + const codex: CodexContextDetails = { + ...(args.slashCommands && args.slashCommands.length > 0 + ? { slashCommands: Array.from(new Set(args.slashCommands.filter((command) => command.trim()).map((command) => command.trim()))) } + : {}), + ...(skills && skills.length > 0 ? { skills } : {}), + ...(mcpServers && mcpServers.length > 0 ? { mcpServers } : {}) + } + + return { + version: 1, + updatedAt: args.updatedAt ?? Date.now(), + provider: 'codex', + ...(model ? { model } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(usage ? { usage } : {}), + ...(Object.keys(codex).length > 0 ? { codex } : {}) + } +} + +function withoutUpdatedAt(details: ContextDetails): Omit { + const { updatedAt: _updatedAt, ...rest } = details + return rest +} + +function compactContextDetails(details: ContextDetails): ContextDetails { + const claude = details.claude + ? { + ...(claudeSkills(details.claude.skills) ? { skills: claudeSkills(details.claude.skills) } : {}), + ...(details.claude.mcpTools + ? { + mcpTools: details.claude.mcpTools.map((tool) => ({ + name: tool.name, + ...(tool.serverName ? { serverName: tool.serverName } : {}) + })) + } + : {}), + ...(details.claude.systemTools ? { systemTools: [...details.claude.systemTools] } : {}), + ...(details.claude.slashCommands ? { slashCommands: [...details.claude.slashCommands] } : {}) + } + : undefined + const codex = details.codex + ? { + ...(details.codex.slashCommands ? { slashCommands: [...details.codex.slashCommands] } : {}), + ...(details.codex.skills + ? { skills: details.codex.skills.map((skill) => ({ name: skill.name })) } + : {}), + ...(details.codex.mcpServers + ? { + mcpServers: details.codex.mcpServers.map((server) => ({ + name: server.name, + ...(server.toolNames ? { toolNames: [...server.toolNames] } : {}), + ...(server.status ? { status: server.status } : {}) + })) + } + : {}) + } + : undefined + + return { + version: 1, + updatedAt: details.updatedAt, + provider: details.provider, + ...(details.model ? { model: details.model } : {}), + ...(details.contextWindow !== undefined ? { contextWindow: details.contextWindow } : {}), + ...(details.usage ? { usage: { ...details.usage } } : {}), + ...(claude && Object.keys(claude).length > 0 ? { claude } : {}), + ...(codex && Object.keys(codex).length > 0 ? { codex } : {}) + } +} + +function claudeSkills(value: ClaudeContextDetails['skills']): ClaudeContextDetails['skills'] { + return value?.map((skill) => ({ name: skill.name })) +} + +export function mergeContextDetails( + previous: ContextDetails | null | undefined, + next: ContextDetails +): ContextDetails { + const compactPrevious = previous ? compactContextDetails(previous) : null + const compactNext = compactContextDetails(next) + if (!previous || !compactPrevious || compactPrevious.provider !== compactNext.provider) return compactNext + + const merged: ContextDetails = { + ...compactPrevious, + ...compactNext, + ...(compactPrevious.usage || compactNext.usage ? { usage: { ...compactPrevious.usage, ...compactNext.usage } } : {}), + ...(compactPrevious.claude || compactNext.claude ? { claude: { ...compactPrevious.claude, ...compactNext.claude } } : {}), + ...(compactPrevious.codex || compactNext.codex ? { codex: { ...compactPrevious.codex, ...compactNext.codex } } : {}) + } + const unchanged = JSON.stringify(withoutUpdatedAt(compactPrevious)) === JSON.stringify(withoutUpdatedAt(merged)) + const previousWasCompact = JSON.stringify(withoutUpdatedAt(previous)) === JSON.stringify(withoutUpdatedAt(compactPrevious)) + return unchanged && previousWasCompact + ? previous + : unchanged + ? compactPrevious + : merged +} + +export interface ContextDetailsClient { + getMetadata?: () => Readonly | null + updateMetadata?: (handler: (metadata: Metadata) => Metadata) => void +} + +export function publishContextDetails(client: ContextDetailsClient, next: ContextDetails): void { + if (!client.updateMetadata) return + const current = client.getMetadata?.()?.contextDetails + const merged = mergeContextDetails(current, next) + if (merged === current) return + client.updateMetadata((metadata) => ({ + ...metadata, + contextDetails: merged + })) +} diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index db4bf6c198..cba433aefd 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -4,6 +4,7 @@ import { createSessionScanner } from "./utils/sessionScanner"; import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; import { BaseLocalLauncher } from "@/modules/common/launcher/BaseLocalLauncher"; import { applySessionTitleFallback } from './utils/sessionTitleFallback'; +import { buildClaudeContextDetails, publishContextDetails } from '@/agent/contextDetails'; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { @@ -12,6 +13,20 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | sessionId: session.sessionId, workingDirectory: session.path, onMessage: (message) => { + const rawMessage = message as unknown as Record; + if (rawMessage.type === 'assistant' || rawMessage.type === 'system' || rawMessage.type === 'result') { + const details = buildClaudeContextDetails({ + contextUsage: rawMessage.context_usage, + system: rawMessage.type === 'system' ? rawMessage : undefined, + result: rawMessage.type === 'result' ? rawMessage : undefined, + messageUsage: (rawMessage.message as Record | undefined)?.usage ?? rawMessage.usage, + model: typeof rawMessage.model === 'string' ? rawMessage.model : session.getModel() + }); + if (details) { + publishContextDetails(session.client, details); + } + } + // Preserve the AI-generated title emitted by Claude Code's native // interactive CLI. It is metadata, not a visible chat message. if (message.type === 'ai-title') { diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index e4fda90fe9..96ea6a1562 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -4,7 +4,7 @@ import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay"; import { claudeRemote } from "./claudeRemote"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; -import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; +import { SDKAssistantMessage, SDKMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; @@ -18,6 +18,7 @@ import { type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; +import { buildClaudeContextDetails, publishContextDetails } from '@/agent/contextDetails'; interface PermissionsField { date: number; @@ -161,6 +162,36 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { let ongoingToolCalls = new Map(); function onMessage(message: SDKMessage) { + if (message.type === 'system') { + const systemMessage = message as SDKSystemMessage; + const details = buildClaudeContextDetails({ system: systemMessage, model: systemMessage.model }); + if (details) { + publishContextDetails(session.client, details); + } + } else if (message.type === 'assistant') { + const assistantMessage = message as SDKAssistantMessage; + const rawAssistantPayload = assistantMessage as unknown as Record; + const rawMessage = assistantMessage.message as unknown as Record; + const details = buildClaudeContextDetails({ + contextUsage: assistantMessage.context_usage ?? rawAssistantPayload.context_usage, + messageUsage: rawMessage.usage, + model: assistantMessage.model ?? (typeof rawAssistantPayload.model === 'string' ? rawAssistantPayload.model : null) + }); + if (details) { + publishContextDetails(session.client, details); + } + } else if (message.type === 'result') { + const resultMessage = message as SDKResultMessage; + const details = buildClaudeContextDetails({ + result: resultMessage, + messageUsage: resultMessage.usage, + model: resultMessage.model + }); + if (details) { + publishContextDetails(session.client, details); + } + } + formatClaudeMessageForInk(message, messageBuffer); permissionHandler.onMessage(message); diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index b8bccece8b..2a600101a0 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -28,6 +28,7 @@ import { toConversationHistoryCapabilities } from '@hapi/protocol/conversationHistory'; import { listSkills, type SkillSummary } from '@/modules/common/skills'; +import { buildClaudeContextDetails, publishContextDetails } from '@/agent/contextDetails'; export interface StartOptions { model?: string @@ -107,6 +108,18 @@ export async function runClaude(options: StartOptions = {}): Promise { catalogPromise = loadCatalog().then((result) => { const { sdkMetadata, catalog } = result; logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); + const staticContextDetails = buildClaudeContextDetails({ + model: options.model, + system: { + model: options.model, + tools: sdkMetadata.tools, + skills: catalog.skills, + slash_commands: catalog.commands + } + }); + if (staticContextDetails) { + publishContextDetails(session, staticContextDetails); + } if (sdkMetadata.slashCommands === undefined) { catalogPromise = null; if (sdkMetadata.tools !== undefined) { diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index a05bbf6694..cfe6b64544 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -39,6 +39,8 @@ export interface SDKUserMessage extends SDKMessage { export interface SDKAssistantMessage extends SDKMessage { type: 'assistant' parent_tool_use_id?: string + model?: string + context_usage?: unknown message: { role: 'assistant' content: Array<{ @@ -96,6 +98,7 @@ export interface SDKResultMessage extends SDKMessage { duration_api_ms: number is_error: boolean session_id: string + model?: string } export interface SDKControlResponse extends SDKMessage { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 7cc58645e0..74e9512894 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -18,6 +18,8 @@ import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; import { createCodexTranscriptLocator, type CodexTranscriptLocator } from './utils/codexTranscriptLocator'; import { CodexToolHookBridge, isCodexToolHookEvent } from './utils/codexToolHookBridge'; import { countHookCoveredExecCalls } from './utils/codexExecWrapper'; +import { buildCodexContextDetails, publishContextDetails } from '@/agent/contextDetails'; +import { listSlashCommands } from '@/modules/common/slashCommands'; type ProposedPlanMessage = Extract; type ToolCallMessage = Extract; @@ -79,10 +81,18 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch : session.codexArgs; const cwdOverride = parseCodexCliOverrides(session.codexArgs).cwd; const effectiveCodexCwd = cwdOverride ? resolve(session.path, cwdOverride) : session.path; + let availableSlashCommands: string[] = []; // Start hapi hub for MCP bridge (same as remote mode) const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); logger.debug(`[codex-local]: Started hapi MCP bridge server at ${happyServer.url}`); + const slashCommandsTask = listSlashCommands('codex', effectiveCodexCwd) + .then((commands) => { + availableSlashCommands = commands.map((command) => command.name); + }) + .catch((error) => { + logger.debug(`[codex-local]: Failed to list slash commands: ${error instanceof Error ? error.message : String(error)}`); + }); const reportTranscriptSyncFailure = (transcriptPath: string, error: unknown): void => { const detail = error instanceof Error ? error.message : String(error); @@ -214,6 +224,15 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch flushPendingExecWrapper(message.callId, message); } } else { + if (message.type === 'token_count') { + publishContextDetails(session.client, buildCodexContextDetails({ + info: message.info, + model: transcriptModel, + threadId: primarySessionId, + slashCommands: availableSlashCommands, + mcpServers + })); + } const scopedMessage = message.type !== 'token_count' ? message : context.replayedHistory @@ -444,6 +463,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch session.addTranscriptPathCallback(handleTranscriptPathCallback); try { + await slashCommandsTask; return await launcher.run(); } finally { shuttingDown = true; diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 7d325e8bbd..b33212dbd8 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -1090,6 +1090,7 @@ function createSessionStub( requests: {}, completedRequests: {} }; + let metadata: Record = {}; const rpcHandlers = new Map unknown>(); const client = { @@ -1098,7 +1099,12 @@ function createSessionStub( rpcHandlers.set(method, handler); } }, - updateMetadata(_handler: (metadata: Record) => Record) {}, + getMetadata() { + return metadata; + }, + updateMetadata(handler: (current: Record) => Record) { + metadata = handler(metadata); + }, updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) { agentState = handler(agentState); }, @@ -1181,7 +1187,8 @@ function createSessionStub( getModelReasoningEffort: () => currentModelReasoningEffort, getCollaborationMode: () => currentCollaborationMode, collaborationModes, - getAgentState: () => agentState + getAgentState: () => agentState, + getMetadata: () => metadata }; } @@ -1311,7 +1318,7 @@ describe('codexRemoteLauncher', () => { }); it('uses the native skill catalog for completion and structured turn input', async () => { - const { session, rpcHandlers } = createSessionStub(['$hapi inspect']); + const { session, rpcHandlers, getMetadata } = createSessionStub(['$hapi inspect']); await codexRemoteLauncher(session as never); @@ -1324,6 +1331,9 @@ describe('codexRemoteLauncher', () => { success: true, skills: [{ name: 'hapi', description: 'Manage HAPI' }] }); + expect((getMetadata().contextDetails as { codex?: { skills?: unknown[] } }).codex?.skills).toEqual([{ + name: 'hapi' + }]); expect(harness.startTurnParams[0]?.input).toEqual([ { type: 'skill', name: 'hapi', path: '/home/user/.agents/skills/hapi/SKILL.md' }, { type: 'text', text: ' inspect' } diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2e14504470..4e4b259f08 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -16,10 +16,12 @@ import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; -import type { SkillMetadata, ThreadGoal, ThreadGoalStatus } from './appServerTypes'; +import type { SkillMetadata, ThreadGoal, ThreadGoalStatus, ThreadStartParams } from './appServerTypes'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { extractErrorInfo } from '@/utils/errorUtils'; +import { buildCodexContextDetails, publishContextDetails } from '@/agent/contextDetails'; +import { listSlashCommands } from '@/modules/common/slashCommands'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { RemoteLauncherBase, @@ -336,6 +338,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const messageBuffer = this.messageBuffer; const appServerClient = this.appServerClient; const appServerEventConverter = new AppServerEventConverter(); + let latestCodexThreadResponse: unknown = null; + let latestCodexThreadParams: ThreadStartParams | undefined; + let availableSlashCommands: string[] = []; + let publishCodexInventoryContext: (() => void) | null = null; const normalizeCommand = (value: unknown): string | undefined => { if (typeof value === 'string') { @@ -2884,6 +2890,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (msgType === 'token_count') { const threadId = eventThreadId ?? this.currentThreadId; + const details = buildCodexContextDetails({ + info: msg.info, + model: asString(msg.model) ?? usageModel, + threadId, + threadResponse: latestCodexThreadResponse, + threadParams: latestCodexThreadParams, + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers + }); + publishContextDetails(session.client, details); session.sendAgentMessage({ ...addCodexEventScope(msg, 'parent', threadId), model: asString(msg.model) ?? usageModel, @@ -3134,6 +3151,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { })) })); } + publishCodexInventoryContext?.(); }; appServerClient.setNotificationHandler((method, params) => { @@ -3189,6 +3207,48 @@ class CodexRemoteLauncher extends RemoteLauncherBase { emitTitleSummary: false }); this.happyServer = happyServer; + try { + availableSlashCommands = (await listSlashCommands('codex', session.path)).map((command) => command.name); + } catch (error) { + logger.debug(`[Codex] failed to list slash commands: ${errorMessage(error)}`); + } + + const publishCodexThreadContext = (response: unknown, params: ThreadStartParams, threadId?: string): void => { + latestCodexThreadResponse = response; + latestCodexThreadParams = params; + publishContextDetails(session.client, buildCodexContextDetails({ + model: asString(asRecord(response)?.model), + threadResponse: response, + threadParams: params, + threadId: threadId ?? asString(asRecord(asRecord(response)?.thread)?.id), + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers + })); + }; + + publishCodexInventoryContext = () => { + const response = latestCodexThreadResponse; + publishContextDetails(session.client, buildCodexContextDetails({ + model: asString(asRecord(response)?.model), + threadResponse: response, + threadParams: latestCodexThreadParams, + threadId: this.currentThreadId, + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers + })); + }; + + const initialCodexContextDetails = buildCodexContextDetails({ + threadParams: undefined, + slashCommands: availableSlashCommands, + skills: nativeSkills, + mcpServers + }); + if (initialCodexContextDetails.codex) { + publishContextDetails(session.client, initialCodexContextDetails); + } this.setupAbortHandlers(session.client.rpcHandlerManager, { onAbort: () => this.handleAbort(), @@ -3386,6 +3446,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; const threadId = asString(resumeThread?.id) ?? resumeCandidate; applyResolvedModel(resumeRecord?.model); + publishCodexThreadContext(resumeResponse, threadParams, threadId); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3450,6 +3511,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; const threadId = asString(resumeThread?.id) ?? resumeCandidate; applyResolvedModel(resumeRecord?.model); + publishCodexThreadContext(resumeResponse, threadParams, threadId); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3483,6 +3545,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } + publishCodexThreadContext(threadResponse, threadParams, threadId); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3742,6 +3805,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const responseThread = responseRecord ? asRecord(responseRecord.thread) : null; threadId = asString(responseThread?.id) ?? resumeCandidate; applyResolvedModel(responseRecord?.model); + publishCodexThreadContext(response, threadParams, threadId); logger.debug(shouldForkImportedSource ? `[Codex] Forked imported app-server thread ${resumeCandidate} -> ${threadId}` : `[Codex] Resumed app-server thread ${threadId}`); @@ -3770,6 +3834,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } + publishCodexThreadContext(threadResponse, threadParams, threadId); } if (!threadId) { diff --git a/shared/src/contextDetails.ts b/shared/src/contextDetails.ts new file mode 100644 index 0000000000..666f232be8 --- /dev/null +++ b/shared/src/contextDetails.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' + +const TokenCountSchema = z.number().int().nonnegative() + +export const ContextUsageSnapshotSchema = z.object({ + contextTokens: TokenCountSchema.optional(), + cacheReadTokens: TokenCountSchema.optional() +}) + +export type ContextUsageSnapshot = z.infer + +const ClaudeSkillSchema = z.object({ + name: z.string() +}) + +const ClaudeMcpToolSchema = z.object({ + name: z.string(), + serverName: z.string().optional() +}) + +export const ClaudeContextDetailsSchema = z.object({ + skills: z.array(ClaudeSkillSchema).optional(), + mcpTools: z.array(ClaudeMcpToolSchema).optional(), + systemTools: z.array(z.string()).optional(), + slashCommands: z.array(z.string()).optional() +}) + +export type ClaudeContextDetails = z.infer + +const CodexSkillSchema = z.object({ + name: z.string() +}) + +const CodexMcpServerSchema = z.object({ + name: z.string(), + toolNames: z.array(z.string()).optional(), + status: z.string().optional() +}) + +export const CodexContextDetailsSchema = z.object({ + slashCommands: z.array(z.string()).optional(), + skills: z.array(CodexSkillSchema).optional(), + mcpServers: z.array(CodexMcpServerSchema).optional() +}) + +export type CodexContextDetails = z.infer + +export const ContextDetailsSchema = z.object({ + version: z.literal(1), + updatedAt: z.number().int().nonnegative(), + provider: z.enum(['claude', 'codex']), + model: z.string().optional(), + contextWindow: TokenCountSchema.optional(), + usage: ContextUsageSnapshotSchema.optional(), + claude: ClaudeContextDetailsSchema.optional(), + codex: CodexContextDetailsSchema.optional() +}) + +export type ContextDetails = z.infer diff --git a/shared/src/index.ts b/shared/src/index.ts index 4fa14330f9..a6b0b92507 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -21,6 +21,7 @@ export * from './piThinkingLevel' export * from './runnerCapabilities' export * from './agentConfig' export * from './copilotModes' +export * from './contextDetails' export * from './slashCommands' export * from './utils' export * from './usage' diff --git a/shared/src/schemas.metadata.test.ts b/shared/src/schemas.metadata.test.ts index d3e0189463..318b1d255d 100644 --- a/shared/src/schemas.metadata.test.ts +++ b/shared/src/schemas.metadata.test.ts @@ -24,4 +24,33 @@ describe('MetadataSchema cursorSessionProtocol', () => { expect(result.success).toBe(true); expect(result.data?.conversationHistoryEntryIds).toEqual({ 'local-user-id': 'pi-entry-id' }); }); + + it('accepts versioned Claude and Codex context details', () => { + const result = MetadataSchema.safeParse({ + ...base, + contextDetails: { + version: 1, + updatedAt: 123, + provider: 'codex', + model: 'gpt-5-codex', + contextWindow: 258_400, + usage: { contextTokens: 12_000, cacheReadTokens: 8_000 }, + codex: { + slashCommands: ['clear'], + skills: [{ name: 'find-docs', scope: 'user' }], + mcpServers: [{ name: 'hapi', toolNames: ['change_title'] }] + }, + claude: { + systemTools: ['Read', 'Bash'], + slashCommands: ['/context', '/compact'] + } + } + }); + + expect(result.success).toBe(true); + expect(result.data?.contextDetails?.codex?.skills?.[0]?.name).toBe('find-docs'); + expect(result.data?.contextDetails?.codex?.slashCommands).toEqual(['clear']); + expect(result.data?.contextDetails?.claude?.systemTools).toEqual(['Read', 'Bash']); + expect('sandbox' in (result.data?.contextDetails?.codex ?? {})).toBe(false); + }); }); diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index ec49667191..367d4221f1 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { COPILOT_AGENT_MODES, type CopilotAgentMode } from './copilotModes' import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' import { AgentConfigDescriptorSchema } from './agentConfig' +import { ContextDetailsSchema } from './contextDetails' export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) @@ -159,7 +160,8 @@ export const MetadataSchema = z.object({ // field stores only modelId (shared across all flavors); this preserves // the provider so web can resolve the exact model when two providers // share a modelId. - piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional() + piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional(), + contextDetails: ContextDetailsSchema.optional() }) export type Metadata = z.infer diff --git a/shared/src/types.ts b/shared/src/types.ts index df693464ca..1bdf6656d7 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -27,6 +27,12 @@ export type { export type { SessionSummary, SessionSummaryMetadata, PendingRequest, PendingRequestKind } from './sessionSummary' export { PENDING_REQUEST_SUMMARY_CAP } from './sessionSummary' +export type { + ClaudeContextDetails, + CodexContextDetails, + ContextDetails, + ContextUsageSnapshot +} from './contextDetails' export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' export type { diff --git a/web/src/components/AssistantChat/ContextDetailsDialog.tsx b/web/src/components/AssistantChat/ContextDetailsDialog.tsx new file mode 100644 index 0000000000..673698ee86 --- /dev/null +++ b/web/src/components/AssistantChat/ContextDetailsDialog.tsx @@ -0,0 +1,197 @@ +import { useState, type ReactNode } from 'react' +import type { ContextDetails } from '@hapi/protocol' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { useTranslation } from '@/lib/use-translation' + +function formatSlashCommand(value: string): string { + return value.startsWith('/') ? value : `/${value}` +} + +function SimpleList(props: { items: readonly string[] }) { + return ( +
+ {props.items.map((item, index) => ( +
{item}
+ ))} +
+ ) +} + +function Section(props: { title: string; count?: number; children: ReactNode }) { + return ( +
+
+

{props.title}

+ {props.count !== undefined ? ( + + {props.count} + + ) : null} +
+
+ {props.children} +
+
+ ) +} + +function ClaudeDetails(props: { details: ContextDetails }) { + const { t } = useTranslation() + const claude = props.details.claude + if (!claude) return null + return ( + <> + {claude.slashCommands?.length ? ( +
+ +
+ ) : null} + {claude.skills?.length ? ( +
+ skill.name)} /> +
+ ) : null} + {claude.mcpTools?.length ? ( +
+ +
+ ) : null} + {claude.systemTools?.length ? ( +
+ +
+ ) : null} + + ) +} + +function ClaudeMcpTools(props: { tools: NonNullable['mcpTools']> }) { + const groups = new Map() + for (const tool of props.tools) { + const serverName = tool.serverName ?? '' + const names = groups.get(serverName) ?? [] + names.push(tool.name) + groups.set(serverName, names) + } + + return ( +
+ {Array.from(groups.entries()).map(([serverName, tools]) => ( +
+ {serverName ?
{serverName}
: null} +
+ {tools.map((tool, index) =>
{tool}
)} +
+
+ ))} +
+ ) +} + +function CodexDetails(props: { details: ContextDetails }) { + const { t } = useTranslation() + const codex = props.details.codex + if (!codex) return null + return ( + <> + {codex.slashCommands?.length ? ( +
+ +
+ ) : null} + {codex.skills?.length ? ( +
+ skill.name)} /> +
+ ) : null} + {codex.mcpServers?.length ? ( +
+
+ {codex.mcpServers.map((server) => ( +
+
+ {server.name} + {server.status ? {server.status} : null} +
+ {server.toolNames?.length ? ( +
+ {server.toolNames.map((toolName) =>
{toolName}
)} +
+ ) : ( +
{t('misc.contextMcpServer')}
+ )} +
+ ))} +
+
+ ) : null} + + ) +} + +function hasVisibleDetails(details: ContextDetails | null | undefined): boolean { + if (!details) return false + if (details.provider === 'claude') { + return Boolean( + details.claude?.systemTools?.length + || details.claude?.slashCommands?.length + || details.claude?.skills?.length + || details.claude?.mcpTools?.length + ) + } + return Boolean( + details.codex?.slashCommands?.length + || details.codex?.skills?.length + || details.codex?.mcpServers?.length + ) +} + +export function ContextDetailsDialog(props: { + details?: ContextDetails | null + triggerClassName: string + triggerContent: ReactNode + triggerAriaLabel?: string +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const details = props.details + + return ( + + + + + + + {t('misc.contextAgentDetailsTitle')} + + +
+
+ {details?.provider === 'claude' ? : null} + {details?.provider === 'codex' ? : null} + {!hasVisibleDetails(details) ? ( +
{t('misc.contextNoDetails')}
+ ) : null} +
+
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 6f01979c12..0fd41768f7 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,6 +1,7 @@ import { getCodexCollaborationModeOptions, getCopilotAgentModeOptions, + type ContextDetails, getPermissionModeOptionsForFlavor, type CopilotAgentMode } from '@hapi/protocol' @@ -297,6 +298,10 @@ export function HappyComposer(props: { contextSize?: number contextCacheRead?: number contextWindow?: number | null + contextDetails?: ContextDetails | null + /** Legacy Claude capability lists stored at the metadata top level. */ + legacyTools?: readonly string[] | null + legacySlashCommands?: readonly string[] | null /** Model for the context-window heuristic; see StatusBar.contextModel. */ contextModel?: string | null controlledByUser?: boolean @@ -402,6 +407,9 @@ export function HappyComposer(props: { contextSize, contextCacheRead, contextWindow, + contextDetails, + legacyTools, + legacySlashCommands, contextModel, controlledByUser = false, agentFlavor, @@ -2107,6 +2115,9 @@ export function HappyComposer(props: { contextSize={contextSize} contextCacheRead={contextCacheRead} contextWindow={contextWindow} + contextDetails={contextDetails} + legacyTools={legacyTools} + legacySlashCommands={legacySlashCommands} contextModel={contextModel} model={model} modelReasoningEffort={modelReasoningEffort} diff --git a/web/src/components/AssistantChat/StatusBar.popover.test.tsx b/web/src/components/AssistantChat/StatusBar.popover.test.tsx index 4e0acea608..3d379accc5 100644 --- a/web/src/components/AssistantChat/StatusBar.popover.test.tsx +++ b/web/src/components/AssistantChat/StatusBar.popover.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' import { StatusBar } from './StatusBar' -describe('StatusBar context details popover', () => { +describe('StatusBar context details dialog', () => { beforeEach(() => { localStorage.clear() }) @@ -214,7 +214,7 @@ describe('StatusBar context details popover', () => { const cacheLine = await screen.findByText('缓存:86k') const details = cacheLine.parentElement expect(details?.textContent).toBe('缓存:86k使用:90k(35%)剩余:168k(65%)') - expect(screen.queryByText('上下文详情')).toBeNull() + expect(screen.queryByRole('heading', { name: '上下文详情' })).not.toBeInTheDocument() }) it('localizes the popover content without localizing the external left label', async () => { @@ -240,4 +240,166 @@ describe('StatusBar context details popover', () => { const cacheLine = await screen.findByText('Cache: 86k') expect(cacheLine.parentElement?.textContent).toBe('Cache: 86kUsed: 90k (35%)Remaining: 168k (65%)') }) + + it('renders Claude system tools, slash commands, and skills from the agent status entry', async () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Agent context details' })) + + expect(screen.getByRole('heading', { name: 'Agent details' })).toBeInTheDocument() + const dialog = screen.getByRole('dialog') + expect(dialog.className.split(' ')).toContain('overflow-hidden') + expect(dialog.querySelector('.agent-details-scroll-y')?.className.split(' ')).toEqual( + expect.arrayContaining(['min-h-0', 'flex-1', 'overflow-y-auto']) + ) + expect(screen.getByRole('dialog').className.split(' ')).toEqual(expect.arrayContaining(['px-2', 'py-4'])) + expect(screen.getByRole('dialog').style.paddingBottom).toBe('') + expect(screen.getByRole('button', { name: 'Close' }).className.split(' ')).toEqual( + expect.arrayContaining(['top-2', 'z-20']) + ) + expect(screen.getByRole('heading', { name: 'Agent details' }).parentElement?.className.split(' ')).toEqual( + expect.arrayContaining(['items-center', 'bg-[var(--app-dialog-bg)]', 'pb-4', 'pr-0', 'text-center', 'sm:text-center']) + ) + expect(screen.queryByText('Claude')).not.toBeInTheDocument() + expect(screen.queryByText('claude-sonnet')).not.toBeInTheDocument() + expect(screen.getByText('System tools')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + expect(screen.getByText('Bash')).toBeInTheDocument() + expect(screen.getByText('Agent commands')).toBeInTheDocument() + expect(screen.getByText('/context')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + expect(screen.getByText('Skills')).toBeInTheDocument() + expect(screen.getByText(/find-docs/)).toBeInTheDocument() + expect(screen.queryByText('Find docs')).not.toBeInTheDocument() + expect(screen.getByText('MCP tools')).toBeInTheDocument() + expect(screen.getByText('mcp__hapi__list_peers')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Agent commands' }).parentElement?.textContent).toContain('2') + expect(screen.getByRole('heading', { name: 'Skills' }).parentElement?.textContent).toContain('1') + expect(screen.getByRole('heading', { name: 'MCP tools' }).parentElement?.textContent).toContain('1') + expect(screen.getByRole('heading', { name: 'System tools' }).parentElement?.textContent).toContain('2') + expect(screen.queryByText('Command')).not.toBeInTheDocument() + expect(screen.queryByText('Tool')).not.toBeInTheDocument() + expect(screen.getByRole('dialog').querySelector('.divide-y')).toBeNull() + expect(Array.from(screen.getByRole('dialog').querySelectorAll('section h3')).map((heading) => heading.textContent)).toEqual([ + 'Agent commands', + 'Skills', + 'MCP tools', + 'System tools' + ]) + expect(screen.queryByText('Context window')).not.toBeInTheDocument() + expect(screen.queryByText('Input')).not.toBeInTheDocument() + expect(screen.queryByText('Context categories')).not.toBeInTheDocument() + expect(screen.queryByText('Resources')).not.toBeInTheDocument() + }) + + it('recovers Claude lists from legacy top-level session metadata', () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Agent context details' })) + + expect(screen.getByText('System tools')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + expect(screen.getByText('Agent commands')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + expect(screen.queryByText('No detailed context information available.')).not.toBeInTheDocument() + }) + + it('renders detailed Codex skills and MCP tools from the agent status entry', async () => { + localStorage.setItem('hapi-lang', 'en') + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Agent context details' })) + + expect(screen.getByText('Agent commands')).toBeInTheDocument() + expect(screen.getByText('/clear')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + expect(screen.getByText('find-docs')).toBeInTheDocument() + expect(screen.queryByText('Find docs')).not.toBeInTheDocument() + expect(screen.getByText('MCP servers')).toBeInTheDocument() + expect(screen.getByText('change_title')).toBeInTheDocument() + expect(screen.getByText('list_peers')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Agent commands' }).parentElement?.textContent).toContain('2') + expect(screen.getByRole('heading', { name: 'Skills' }).parentElement?.textContent).toContain('1') + expect(screen.getByRole('heading', { name: 'MCP servers' }).parentElement?.textContent).toContain('1') + expect(screen.queryByText('Command')).not.toBeInTheDocument() + expect(screen.getByRole('dialog').querySelector('.divide-y')).toBeNull() + expect(Array.from(screen.getByRole('dialog').querySelectorAll('section h3')).map((heading) => heading.textContent)).toEqual([ + 'Agent commands', + 'Skills', + 'MCP servers' + ]) + expect(screen.queryByText('System tools')).not.toBeInTheDocument() + expect(screen.queryByText('Context window')).not.toBeInTheDocument() + expect(screen.queryByText('Input')).not.toBeInTheDocument() + expect(screen.queryByText('Runtime')).not.toBeInTheDocument() + }) + }) diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 5b1f71cbb0..55702f85ac 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -5,8 +5,7 @@ import { getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' -import type { PermissionModeTone } from '@hapi/protocol' -import * as Popover from '@radix-ui/react-popover' +import type { ContextDetails, PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' import type { ConversationStatus } from '@/realtime/types' @@ -17,9 +16,11 @@ import { getReasoningEffortForFlavor, shouldShowReasoningStatusLabel } from '@/lib/codexStatusLabels' +import * as Popover from '@radix-ui/react-popover' import { isFastServiceTier } from './codexFastMode' import { useTranslation } from '@/lib/use-translation' import { useSessionHeaderMetadata } from '@/hooks/useSessionHeaderMetadata' +import { ContextDetailsDialog } from './ContextDetailsDialog' // Vibing messages for thinking state const VIBING_MESSAGES = [ @@ -50,6 +51,14 @@ const PERMISSION_TONE_CLASSES: Record = { const CONTEXT_WARNING_THRESHOLD_PERCENT = 70 const CONTEXT_DANGER_THRESHOLD_PERCENT = 90 +type ContextUsageSummary = { + cacheRead: string | null + used: string + usedPercentage: number | null + remaining: string | null + remainingPercentage: number | null +} + function getConnectionStatus( active: boolean, thinking: boolean, @@ -191,6 +200,43 @@ export function shouldShowCodexFastBadge( return agentFlavor === 'codex' && isFastServiceTier(serviceTier) } +function normalizeLegacyList(values: readonly string[] | null | undefined): string[] | undefined { + if (!values) return undefined + const normalized = Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) + return normalized.length > 0 ? normalized : undefined +} + +function addLegacyClaudeLists(args: { + details: ContextDetails | null | undefined + agentFlavor: string | null | undefined + tools: readonly string[] | null | undefined + slashCommands: readonly string[] | null | undefined +}): ContextDetails | null | undefined { + const isClaude = args.details?.provider === 'claude' || (!args.details && args.agentFlavor === 'claude') + if (!isClaude) return args.details + + const legacyTools = normalizeLegacyList(args.tools) + const legacySlashCommands = normalizeLegacyList(args.slashCommands) + const claude = args.details?.claude + const systemTools = claude?.systemTools?.length ? claude.systemTools : legacyTools + const slashCommands = claude?.slashCommands?.length ? claude.slashCommands : legacySlashCommands + if (!systemTools && !slashCommands) return args.details + + return { + ...(args.details ?? { version: 1, updatedAt: 0, provider: 'claude' as const }), + provider: 'claude', + claude: { + ...claude, + ...(systemTools + ? { systemTools } + : {}), + ...(slashCommands + ? { slashCommands } + : {}) + } + } +} + export function StatusBar(props: { active: boolean thinking: boolean @@ -199,6 +245,10 @@ export function StatusBar(props: { contextSize?: number contextCacheRead?: number contextWindow?: number | null + contextDetails?: ContextDetails | null + /** Legacy Claude metadata retained on sessions created before nested lists were added. */ + legacyTools?: readonly string[] | null + legacySlashCommands?: readonly string[] | null /** * Model to use for the context-window fallback heuristic when * contextWindow is absent. Falls back to `model`. Callers pass the @@ -223,32 +273,44 @@ export function StatusBar(props: { [props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t] ) - const contextHeuristicModel = props.contextModel ?? props.model + const effectiveContextSize = props.contextSize ?? props.contextDetails?.usage?.contextTokens + const effectiveContextWindow = props.contextWindow ?? props.contextDetails?.contextWindow + const effectiveContextCacheRead = props.contextCacheRead ?? props.contextDetails?.usage?.cacheReadTokens + const contextHeuristicModel = props.contextModel ?? props.contextDetails?.model ?? props.model const contextWarning = useMemo( () => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) if (!maxContextSize) return null - return getContextWarning(props.contextSize, maxContextSize) + return getContextWarning(effectiveContextSize, maxContextSize) }, - [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor] + [effectiveContextSize, effectiveContextWindow, contextHeuristicModel, props.agentFlavor] ) const contextUsageLabel = useMemo(() => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) - return formatContextUsageLabel(props.contextSize, maxContextSize) - }, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor]) + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + return formatContextUsageLabel(effectiveContextSize, maxContextSize) + }, [effectiveContextSize, effectiveContextWindow, contextHeuristicModel, props.agentFlavor]) const compactContextUsageLabel = useMemo(() => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) - return formatCompactContextUsageLabel(props.contextSize, maxContextSize) - }, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor]) - const contextUsageDetails = useMemo(() => { - if (props.contextSize === undefined) return null - const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) - return getContextUsageDetails(props.contextSize, maxContextSize, props.contextCacheRead) - }, [props.contextSize, props.contextCacheRead, props.contextWindow, contextHeuristicModel, props.agentFlavor]) + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + return formatCompactContextUsageLabel(effectiveContextSize, maxContextSize) + }, [effectiveContextSize, effectiveContextWindow, contextHeuristicModel, props.agentFlavor]) + const contextUsageDetails: ContextUsageSummary | null = useMemo(() => { + if (effectiveContextSize === undefined) return null + const maxContextSize = effectiveContextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor) + return getContextUsageDetails(effectiveContextSize, maxContextSize, effectiveContextCacheRead) + }, [effectiveContextSize, effectiveContextCacheRead, effectiveContextWindow, contextHeuristicModel, props.agentFlavor]) const contextUsedPercentage = contextUsageDetails?.usedPercentage ?? null + const displayContextDetails = useMemo( + () => addLegacyClaudeLists({ + details: props.contextDetails, + agentFlavor: props.agentFlavor, + tools: props.legacyTools, + slashCommands: props.legacySlashCommands + }), + [props.contextDetails, props.agentFlavor, props.legacyTools, props.legacySlashCommands] + ) const permissionMode = props.permissionMode // Copilot always shows permission (including Default) so model=auto sessions @@ -294,14 +356,22 @@ export function StatusBar(props: { return (
-
- - - {connectionStatus.text} - -
+ +