diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 58763fd0..a1f9743a 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -34,6 +34,110 @@ export type RecentProject = { sessionCount: number } +export type SessionUsageSnapshot = { + source?: 'current_process' | 'transcript' + totalCostUSD: number + costDisplay: string + hasUnknownModelCost: boolean + totalAPIDuration: number + totalDuration: number + totalLinesAdded: number + totalLinesRemoved: number + totalInputTokens: number + totalOutputTokens: number + totalCacheReadInputTokens: number + totalCacheCreationInputTokens: number + totalWebSearchRequests: number + models: Array<{ + model: string + displayName: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + webSearchRequests: number + costUSD: number + costDisplay: string + contextWindow: number + maxOutputTokens: number + }> +} + +export type SessionContextSnapshot = { + categories: Array<{ + name: string + tokens: number + color: string + isDeferred?: boolean + }> + totalTokens: number + maxTokens: number + rawMaxTokens: number + percentage: number + gridRows: Array> + model: string + memoryFiles: Array<{ path: string; type: string; tokens: number }> + mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }> + deferredBuiltinTools?: Array<{ name: string; tokens: number; isLoaded: boolean }> + systemTools?: Array<{ name: string; tokens: number }> + systemPromptSections?: Array<{ name: string; tokens: number }> + agents: Array<{ agentType: string; source: string; tokens: number }> + slashCommands?: { + totalCommands: number + includedCommands: number + tokens: number + } + skills?: { + totalSkills: number + includedSkills: number + tokens: number + skillFrontmatter: Array<{ name: string; source: string; tokens: number }> + } + messageBreakdown?: { + toolCallTokens: number + toolResultTokens: number + attachmentTokens: number + assistantMessageTokens: number + userMessageTokens: number + toolCallsByType: Array<{ name: string; callTokens: number; resultTokens: number }> + attachmentsByType: Array<{ name: string; tokens: number }> + } + apiUsage?: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number + } | null +} + +export type SessionInspectionResponse = { + active: boolean + status: { + sessionId: string + workDir: string + permissionMode: string + version?: string + cwd?: string + model?: string + apiKeySource?: string + outputStyle?: string + tools?: string[] + mcpServers?: Array<{ name: string; status: string }> + slashCommandCount?: number + skillCount?: number + } + usage?: SessionUsageSnapshot + context?: SessionContextSnapshot + errors?: Record +} + export const sessionsApi = { list(params?: { project?: string; limit?: number; offset?: number }) { const query = new URLSearchParams() @@ -73,6 +177,12 @@ export const sessionsApi = { return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`) }, + getInspection(sessionId: string) { + return api.get(`/api/sessions/${sessionId}/inspection`, { + timeout: 25_000, + }) + }, + rewind(sessionId: string, body: { targetUserMessageId?: string userMessageIndex?: number diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2686d306..7ba03253 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -545,6 +545,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
setLocalSlashPanel(null)} diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index 44581ce2..3f7b83f0 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useState } from 'react' import { skillsApi } from '../../api/skills' import { mcpApi } from '../../api/mcp' -import { useTranslation } from '../../i18n' +import { + sessionsApi, + type SessionContextSnapshot, + type SessionInspectionResponse, + type SessionUsageSnapshot, +} from '../../api/sessions' +import { useTranslation, type TranslationKey } from '../../i18n' import { useUIStore } from '../../stores/uiStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useMcpStore } from '../../stores/mcpStore' @@ -10,15 +16,19 @@ import type { McpServerRecord } from '../../types/mcp' import type { SkillMeta } from '../../types/skill' import type { SlashCommandOption } from './composerUtils' -export type LocalSlashCommandName = 'mcp' | 'skills' | 'help' +export type LocalSlashCommandName = 'mcp' | 'skills' | 'help' | 'status' | 'cost' | 'context' type Props = { command: LocalSlashCommandName + sessionId?: string cwd?: string commands?: SlashCommandOption[] onClose: () => void } +type SessionInspectorTab = 'status' | 'usage' | 'context' +type Translate = ReturnType + function toneForStatus(status: McpServerRecord['status']) { switch (status) { case 'connected': @@ -79,7 +89,7 @@ function PanelShell({ close
-
{children}
+
{children}
) } @@ -110,6 +120,601 @@ function ErrorState({ message }: { message: string }) { ) } +const inspector = { + bg: '#fbfaf6', + panel: '#f6f4ee', + line: '#d8b3a8', + rust: '#8f3217', + ink: '#1f1713', + muted: '#7b665f', + green: '#25451b', + greenBg: '#d8f2b6', + red: '#c51616', + redBg: '#ffd9d3', +} + +function formatNumber(value: number | undefined) { + return new Intl.NumberFormat().format(value ?? 0) +} + +function formatDuration(seconds: number | undefined) { + const total = Math.max(0, Math.round(seconds ?? 0)) + if (total < 60) return `${total}s` + const minutes = Math.floor(total / 60) + const remaining = total % 60 + return remaining ? `${minutes}m ${remaining}s` : `${minutes}m` +} + +function formatPercent(value: number | undefined) { + const percent = Math.max(0, Math.min(100, value ?? 0)) + return `${percent.toFixed(percent >= 10 || Number.isInteger(percent) ? 0 : 1)}%` +} + +function sessionInspectorInitialTab(command: LocalSlashCommandName): SessionInspectorTab { + if (command === 'cost') return 'usage' + if (command === 'context') return 'context' + return 'status' +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object') +} + +function isSessionInspectionResponse(value: unknown): value is SessionInspectionResponse { + if (!isRecord(value)) return false + if (typeof value.active !== 'boolean') return false + if (!isRecord(value.status)) return false + return ( + typeof value.status.sessionId === 'string' && + typeof value.status.workDir === 'string' && + typeof value.status.permissionMode === 'string' + ) +} + +function assertSessionInspectionResponse(value: unknown, t: Translate): SessionInspectionResponse { + if (isSessionInspectionResponse(value)) return value + throw new Error(t('slash.inspector.error.unavailable')) +} + +function InspectorSectionTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
{children}
+ {action} +
+ ) +} + +function MetricCard({ label, value, detail }: { label: string; value: React.ReactNode; detail?: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+ {detail &&
{detail}
} +
+ ) +} + +function InspectorNotice({ children }: { children: React.ReactNode }) { + return ( +
+ info + {children} +
+ ) +} + +function KeyValueRows({ rows }: { rows: Array<[string, React.ReactNode]> }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+ ) +} + +function UsageTab({ + usage, + context, + error, + t, +}: { + usage?: SessionUsageSnapshot + context?: SessionContextSnapshot + error?: string + t: Translate +}) { + if (error && !usage) return + if (!usage) { + return + } + + const usageHasTokens = ( + usage.totalInputTokens + + usage.totalOutputTokens + + usage.totalCacheReadInputTokens + + usage.totalCacheCreationInputTokens + ) > 0 + const apiUsage = context?.apiUsage + const useContextUsageFallback = !usageHasTokens && !!apiUsage + const totalInputTokens = useContextUsageFallback ? apiUsage.input_tokens : usage.totalInputTokens + const totalOutputTokens = useContextUsageFallback ? apiUsage.output_tokens : usage.totalOutputTokens + const totalCacheReadInputTokens = useContextUsageFallback ? apiUsage.cache_read_input_tokens : usage.totalCacheReadInputTokens + const totalCacheCreationInputTokens = useContextUsageFallback ? apiUsage.cache_creation_input_tokens : usage.totalCacheCreationInputTokens + const models = Array.isArray(usage.models) && usage.models.length > 0 + ? usage.models + : useContextUsageFallback + ? [{ + model: context?.model ?? 'current-model', + displayName: context?.model ?? t('slash.inspector.status.activeModel'), + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadInputTokens: totalCacheReadInputTokens, + cacheCreationInputTokens: totalCacheCreationInputTokens, + webSearchRequests: 0, + costUSD: 0, + costDisplay: 'n/a', + contextWindow: context?.rawMaxTokens ?? 0, + maxOutputTokens: 0, + }] + : [] + const sourceLabel = useContextUsageFallback + ? t('slash.inspector.usage.source.contextSnapshot') + : usage.source === 'transcript' + ? t('slash.inspector.usage.source.transcript') + : t('slash.inspector.usage.source.currentProcess') + + return ( +
+ {useContextUsageFallback && ( + + {t('slash.inspector.usage.contextSnapshotNotice')} + + )} + {usage.source === 'transcript' && ( +
+ {t('slash.inspector.usage.transcriptNotice')} +
+ )} + {usage.hasUnknownModelCost && ( +
+ {t('slash.inspector.usage.unknownCost')} +
+ )} +
+ + + + + + + + + +
+
+
{t('slash.inspector.usage.byModel')}
+ {models.length === 0 ? ( + + ) : ( +
+ {models.map((model) => ( +
+
+
{model.displayName || model.model}
+
{t('slash.inspector.usage.tokens')}
+
+
+
{t('slash.inspector.usage.input')}
+
+
+
+
{formatNumber(model.inputTokens)}
+
+
+
{t('slash.inspector.usage.output')}
+
+
+
+
{formatNumber(model.outputTokens)}
+
+
+ ))} +
+ )} +
+
+ ) +} + +type ContextCategory = SessionContextSnapshot['categories'][number] + +function isCapacityCategory(category: ContextCategory) { + const name = category.name.toLowerCase() + return category.isDeferred || name.includes('free') || name.includes('autocompact') +} + +function ContextStackedBar({ categories, rawMaxTokens }: { categories: ContextCategory[]; rawMaxTokens: number }) { + const activeCategories = categories.filter((category) => !isCapacityCategory(category) && category.tokens > 0) + if (activeCategories.length === 0) return null + + return ( +
+
+ {activeCategories.map((category) => ( +
+ ))} +
+
+ ) +} + +function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: ContextCategory[]; rawMaxTokens: number; t: Translate }) { + const visibleCategories = categories.filter((category) => category.tokens > 0) + if (visibleCategories.length === 0) { + return + } + + return ( +
+ {t('slash.inspector.context.categoryTitle')} +
+ {visibleCategories.map((category) => { + const percent = rawMaxTokens > 0 ? (category.tokens / rawMaxTokens) * 100 : 0 + const muted = isCapacityCategory(category) + return ( +
+
+
+ + {category.name} + +
+
+
{formatNumber(category.tokens)}
+
+
+
+
+
+
+ ) + })} +
+
+ ) +} + +function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) { + return ( +
+
{label}
+
{value}
+ {detail &&
{detail}
} +
+ ) +} + +function statusDisplayLabel(status: string, t: Translate) { + const normalized = status.toLowerCase() + if (normalized === 'connected') return t('slash.inspector.status.connected') + if (normalized === 'failed') return t('slash.inspector.status.failed') + return status +} + +function InspectorStatusBadge({ status, t }: { status: string; t: Translate }) { + const normalized = status.toLowerCase() + const isConnected = normalized === 'connected' + const isFailed = normalized === 'failed' + const badgeClass = isConnected + ? 'bg-[#d8f2b6] text-[#25451b]' + : isFailed + ? 'bg-[#ffd9d3] text-[#c51616]' + : 'bg-[#ebe7df] text-[#5f514c]' + const dotClass = isConnected ? 'bg-[#25451b]' : isFailed ? 'bg-[#c51616]' : 'bg-[#7b665f]' + + return ( + + + {statusDisplayLabel(status, t)} + + ) +} + +function McpServerIcon({ status }: { status: string }) { + const isFailed = status === 'failed' + const icon = isFailed ? 'power_off' : 'dns' + return ( + + {icon} + + ) +} + +function ContextOverview({ context, categories, t }: { context: SessionContextSnapshot; categories: ContextCategory[]; t: Translate }) { + const usedPercent = Math.min(100, Math.max(0, context.percentage)) + const freeTokens = Math.max(0, context.rawMaxTokens - context.totalTokens) + return ( +
+
+ {t('slash.inspector.context.windowUsage')} + {context.model} +
+
+ {formatNumber(context.totalTokens)} + / + {formatNumber(context.rawMaxTokens)} + [{formatPercent(usedPercent)} {t('slash.inspector.context.used')}] +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +function ContextTab({ context, error, t }: { context?: SessionContextSnapshot; error?: string; t: Translate }) { + if (error && !context) return + if (!context) { + return + } + + const categories = Array.isArray(context.categories) ? context.categories : [] + return ( +
+ + +
+ ) +} + +function StatusTab({ + data, + commands, + t, +}: { + data: SessionInspectionResponse + commands?: SlashCommandOption[] + t: Translate +}) { + const mcpServers = Array.isArray(data.status.mcpServers) ? data.status.mcpServers : [] + const tools = Array.isArray(data.status.tools) ? data.status.tools : [] + const model = data.status.model ?? data.context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown') + const slashCommandCount = (data.status.slashCommandCount ?? 0) > 0 + ? data.status.slashCommandCount + : commands?.length ?? 0 + const connectedMcp = mcpServers.filter((server) => server.status === 'connected').length + const failedMcp = mcpServers.filter((server) => server.status === 'failed').length + return ( +
+
+ + + {data.active ? t('slash.inspector.status.running') : t('slash.inspector.status.notRunning')} + + )} + /> + + + {formatNumber(connectedMcp)} + / + {formatNumber(failedMcp)} + + )} + detail={( + + {t('slash.inspector.status.connected')} + + {t('slash.inspector.status.failed')} + + )} + /> + +
+
+ {t('slash.inspector.status.sessionMetadata')} + {data.status.sessionId}], + [t('slash.inspector.status.workingDirectory'), {data.status.cwd ?? data.status.workDir}], + [t('slash.inspector.status.permissionMode'), {data.status.permissionMode}], + [t('slash.inspector.status.authToken'), data.status.apiKeySource ?? t('slash.inspector.status.unknown')], + [t('slash.inspector.status.outputStyle'), data.status.outputStyle ?? t('slash.inspector.status.default')], + ]} + /> +
+ {mcpServers.length > 0 && ( +
+ ↻ {t('slash.inspector.status.refresh')}} + > + {t('slash.inspector.status.mcpServers')} + +
+ {mcpServers.map((server) => ( +
+
+ + {server.name} +
+ +
+ ))} +
+
+ )} +
+ ) +} + +function SessionInspectorShell({ + selectedTab, + tabs, + onSelectTab, + onClose, + children, + t, +}: { + selectedTab: SessionInspectorTab + tabs: Array<{ id: SessionInspectorTab; label: string }> + onSelectTab: (tab: SessionInspectorTab) => void + onClose: () => void + children: React.ReactNode + t: Translate +}) { + return ( +
+
+
{t('slash.inspector.title')}
+
+ {tabs.map((tab) => ( + + ))} +
+
+ +
+
+
{children}
+
+ ) +} + +function SessionInspectorPanel({ + command, + sessionId, + commands, + onClose, +}: { + command: LocalSlashCommandName + sessionId?: string + commands?: SlashCommandOption[] + onClose: () => void +}) { + const t = useTranslation() + const [selectedTab, setSelectedTab] = useState(() => sessionInspectorInitialTab(command)) + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (command !== 'status' && command !== 'cost' && command !== 'context') return + setSelectedTab(sessionInspectorInitialTab(command)) + }, [command]) + + useEffect(() => { + if (!sessionId) { + setError(t('slash.inspector.error.noActiveSession')) + return + } + let cancelled = false + setData(null) + setError(null) + sessionsApi.getInspection(sessionId) + .then((response) => { + if (!cancelled) setData(assertSessionInspectionResponse(response, t)) + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [sessionId]) + + const tabs: Array<{ id: SessionInspectorTab; label: string }> = [ + { id: 'status', label: t('slash.inspector.tab.status') }, + { id: 'usage', label: t('slash.inspector.tab.usage') }, + { id: 'context', label: t('slash.inspector.tab.context') }, + ] + + return ( + + {error ? ( + + ) : data === null ? ( + + ) : selectedTab === 'usage' ? ( + + ) : selectedTab === 'context' ? ( + + ) : ( + + )} + + ) +} + function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { const t = useTranslation() const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) @@ -287,18 +892,18 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { const COMMAND_GROUPS = [ { - title: 'Context', + titleKey: 'slash.help.group.context', names: ['clear', 'compact', 'context', 'cost'], }, { - title: 'Project', + titleKey: 'slash.help.group.project', names: ['init', 'review', 'commit', 'pr'], }, { - title: 'Desktop', + titleKey: 'slash.help.group.desktop', names: ['mcp', 'skills', 'plugin', 'help'], }, -] +] satisfies Array<{ titleKey: TranslationKey; names: string[] }> function HelpPanel({ commands, @@ -307,6 +912,7 @@ function HelpPanel({ commands?: SlashCommandOption[] onClose: () => void }) { + const t = useTranslation() const commandMap = useMemo(() => { const map = new Map() for (const command of commands ?? []) { @@ -333,8 +939,8 @@ function HelpPanel({ return (
@@ -344,8 +950,8 @@ function HelpPanel({ .filter((command): command is SlashCommandOption => Boolean(command)) if (entries.length === 0) return null return ( -
-
{group.title}
+
+
{t(group.titleKey)}
{entries.map(renderCommand)}
@@ -355,13 +961,13 @@ function HelpPanel({ {otherCommands.length > 0 && (
-
More
+
{t('slash.help.group.more')}
{otherCommands.map(renderCommand)}
{hiddenOtherCommandCount > 0 && (

- {hiddenOtherCommandCount} more commands available. Type / to search the full command list. + {t('slash.help.moreAvailable', { count: hiddenOtherCommandCount })}

)}
@@ -371,8 +977,11 @@ function HelpPanel({ ) } -export function LocalSlashCommandPanel({ command, cwd, commands, onClose }: Props) { +export function LocalSlashCommandPanel({ command, sessionId, cwd, commands, onClose }: Props) { if (command === 'mcp') return if (command === 'skills') return + if (command === 'status' || command === 'cost' || command === 'context') { + return + } return } diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index a3561a56..ff39605d 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -60,4 +60,10 @@ describe('composerUtils', () => { expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins') }) + + it('routes session inspection commands to the desktop panel', () => { + expect(resolveSlashUiAction('cost')).toEqual({ type: 'panel', command: 'cost' }) + expect(resolveSlashUiAction('context')).toEqual({ type: 'panel', command: 'context' }) + expect(resolveSlashUiAction('status')).toEqual({ type: 'panel', command: 'status' }) + }) }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 57e4faa3..50292392 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -4,6 +4,9 @@ export const PANEL_SLASH_COMMANDS = [ { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, { name: 'skills', description: 'Browse user-invocable skills for the current chat context' }, { name: 'help', description: 'Show available desktop and agent commands' }, + { name: 'status', description: 'Show session status, usage, and context' }, + { name: 'cost', description: 'Show session usage and costs' }, + { name: 'context', description: 'Show current context usage' }, ] as const export const SETTINGS_SLASH_COMMANDS = [ @@ -25,15 +28,12 @@ export const FALLBACK_SLASH_COMMANDS = [ { name: 'init', description: 'Initialize project CLAUDE.md' }, { name: 'bug', description: 'Report a bug' }, { name: 'config', description: 'Open configuration' }, - { name: 'context', description: 'Show current context usage' }, - { name: 'cost', description: 'Show token usage and costs' }, { name: 'doctor', description: 'Diagnose installation issues' }, { name: 'login', description: 'Switch Anthropic accounts' }, { name: 'logout', description: 'Sign out of current account' }, { name: 'memory', description: 'Edit CLAUDE.md memory files' }, { name: 'model', description: 'Switch AI model' }, { name: 'permissions', description: 'View or manage tool permissions' }, - { name: 'status', description: 'Show project and session status' }, { name: 'terminal-setup', description: 'Set up terminal integration' }, { name: 'vim', description: 'Toggle vim editing mode' }, ] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1df1c39e..9bdadb8c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -509,6 +509,75 @@ export const en = { 'slash.plugins.subtitle': 'Plugin management will move into this slash-command surface.', 'slash.plugins.emptyTitle': 'Plugins panel coming soon', 'slash.plugins.emptyBody': 'This shared slash-command panel is wired up now; plugin data will be connected next.', + 'slash.help.title': 'Slash commands', + 'slash.help.subtitle': 'Run agent workflows, inspect session state, or open desktop panels.', + 'slash.help.group.context': 'Context', + 'slash.help.group.project': 'Project', + 'slash.help.group.desktop': 'Desktop', + 'slash.help.group.more': 'More', + 'slash.help.moreAvailable': '{count} more commands available. Type / to search the full command list.', + 'slash.inspector.title': 'Session inspector', + 'slash.inspector.close': 'Close session inspector', + 'slash.inspector.loading': 'Loading session data', + 'slash.inspector.error.noActiveSession': 'No active session selected.', + 'slash.inspector.error.unavailable': 'Session inspector data is unavailable. Restart the desktop server so /api/sessions/:id/inspection is loaded.', + 'slash.inspector.tab.status': 'Status', + 'slash.inspector.tab.usage': 'Usage', + 'slash.inspector.tab.context': 'Context', + 'slash.inspector.status.cliStatus': 'CLI Status', + 'slash.inspector.status.running': 'Running', + 'slash.inspector.status.notRunning': 'Not running', + 'slash.inspector.status.activeModel': 'Active Model', + 'slash.inspector.status.unknown': 'Unknown', + 'slash.inspector.status.mcpConnections': 'MCP Connections', + 'slash.inspector.status.connected': 'connected', + 'slash.inspector.status.failed': 'failed', + 'slash.inspector.status.registeredTools': 'Registered Tools', + 'slash.inspector.status.commands': 'commands', + 'slash.inspector.status.sessionMetadata': 'Session metadata', + 'slash.inspector.status.version': 'Version', + 'slash.inspector.status.sessionId': 'Session ID', + 'slash.inspector.status.workingDirectory': 'Working Directory', + 'slash.inspector.status.permissionMode': 'Permission Mode', + 'slash.inspector.status.authToken': 'Auth Token', + 'slash.inspector.status.outputStyle': 'Output Style', + 'slash.inspector.status.default': 'Default', + 'slash.inspector.status.mcpServers': 'MCP servers', + 'slash.inspector.status.refresh': 'Refresh', + 'slash.inspector.usage.emptyTitle': 'No usage data yet', + 'slash.inspector.usage.emptyBody': 'Usage appears after the CLI session starts and completes at least one turn.', + 'slash.inspector.usage.contextSnapshotNotice': 'Showing token usage from the current context snapshot. Usage updates dynamically as context is fetched.', + 'slash.inspector.usage.transcriptNotice': 'Showing usage reconstructed from the session transcript. API duration is only available for the currently running CLI process.', + 'slash.inspector.usage.unknownCost': 'Costs may be inaccurate because one or more models have unknown pricing.', + 'slash.inspector.usage.totalCost': 'Total cost', + 'slash.inspector.usage.source': 'Source', + 'slash.inspector.usage.source.contextSnapshot': 'Context snapshot', + 'slash.inspector.usage.source.transcript': 'Transcript', + 'slash.inspector.usage.source.currentProcess': 'Current process', + 'slash.inspector.usage.apiDuration': 'API duration', + 'slash.inspector.usage.wallDuration': 'Wall duration', + 'slash.inspector.usage.usageSpan': 'Usage span', + 'slash.inspector.usage.codeChanges': 'Code changes', + 'slash.inspector.usage.input': 'Input', + 'slash.inspector.usage.output': 'Output', + 'slash.inspector.usage.cacheReadWrite': 'Cache read / write', + 'slash.inspector.usage.webSearch': 'Web search', + 'slash.inspector.usage.byModel': 'Usage by Model', + 'slash.inspector.usage.tokens': 'Tokens', + 'slash.inspector.usage.noModelTitle': 'No model usage', + 'slash.inspector.usage.noModelBody': 'Token usage will appear here after a model response is recorded.', + 'slash.inspector.context.emptyTitle': 'No context data yet', + 'slash.inspector.context.emptyBody': 'Context usage requires an active CLI session.', + 'slash.inspector.context.windowUsage': 'Context window usage', + 'slash.inspector.context.used': 'used', + 'slash.inspector.context.free': 'Free', + 'slash.inspector.context.messages': 'Messages', + 'slash.inspector.context.assistant': 'assistant', + 'slash.inspector.context.toolResults': 'Tool results', + 'slash.inspector.context.context': 'Context', + 'slash.inspector.context.categoryTitle': 'Estimated usage by category', + 'slash.inspector.context.noCategoriesTitle': 'No context categories', + 'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.', 'chat.navigate': 'navigate', 'chat.select': 'select', 'chat.dismiss': 'dismiss', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 54f55deb..94c29c67 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -511,6 +511,75 @@ export const zh: Record = { 'slash.plugins.subtitle': '插件管理后续会接到这个斜杠命令面板里。', 'slash.plugins.emptyTitle': '插件面板即将接入', 'slash.plugins.emptyBody': '这套共享的斜杠命令面板已经接通,插件数据下一步接进来。', + 'slash.help.title': '斜杠命令', + 'slash.help.subtitle': '运行 Agent 工作流、查看会话状态,或打开桌面端面板。', + 'slash.help.group.context': '上下文', + 'slash.help.group.project': '项目', + 'slash.help.group.desktop': '桌面端', + 'slash.help.group.more': '更多', + 'slash.help.moreAvailable': '还有 {count} 条命令可用。输入 / 可以搜索完整命令列表。', + 'slash.inspector.title': '会话检查器', + 'slash.inspector.close': '关闭会话检查器', + 'slash.inspector.loading': '正在加载会话数据', + 'slash.inspector.error.noActiveSession': '当前没有选中的活跃会话。', + 'slash.inspector.error.unavailable': '会话检查器数据不可用。请重启桌面端后端,让 /api/sessions/:id/inspection 接口生效。', + 'slash.inspector.tab.status': '状态', + 'slash.inspector.tab.usage': '用量', + 'slash.inspector.tab.context': '上下文', + 'slash.inspector.status.cliStatus': 'CLI 状态', + 'slash.inspector.status.running': '运行中', + 'slash.inspector.status.notRunning': '未运行', + 'slash.inspector.status.activeModel': '当前模型', + 'slash.inspector.status.unknown': '未知', + 'slash.inspector.status.mcpConnections': 'MCP 连接', + 'slash.inspector.status.connected': '已连接', + 'slash.inspector.status.failed': '失败', + 'slash.inspector.status.registeredTools': '注册工具', + 'slash.inspector.status.commands': '条命令', + 'slash.inspector.status.sessionMetadata': '会话元数据', + 'slash.inspector.status.version': '版本', + 'slash.inspector.status.sessionId': '会话 ID', + 'slash.inspector.status.workingDirectory': '工作目录', + 'slash.inspector.status.permissionMode': '权限模式', + 'slash.inspector.status.authToken': '认证令牌', + 'slash.inspector.status.outputStyle': '输出样式', + 'slash.inspector.status.default': '默认', + 'slash.inspector.status.mcpServers': 'MCP 服务', + 'slash.inspector.status.refresh': '刷新', + 'slash.inspector.usage.emptyTitle': '暂无用量数据', + 'slash.inspector.usage.emptyBody': 'CLI 会话启动并完成至少一轮回复后,这里会显示用量。', + 'slash.inspector.usage.contextSnapshotNotice': '当前展示来自上下文快照的 token 用量。上下文数据刷新后会同步更新。', + 'slash.inspector.usage.transcriptNotice': '当前展示从会话记录重建的用量。API 耗时只对正在运行的 CLI 进程可用。', + 'slash.inspector.usage.unknownCost': '部分模型价格未知,费用统计可能不准确。', + 'slash.inspector.usage.totalCost': '总费用', + 'slash.inspector.usage.source': '来源', + 'slash.inspector.usage.source.contextSnapshot': '上下文快照', + 'slash.inspector.usage.source.transcript': '会话记录', + 'slash.inspector.usage.source.currentProcess': '当前进程', + 'slash.inspector.usage.apiDuration': 'API 耗时', + 'slash.inspector.usage.wallDuration': '总耗时', + 'slash.inspector.usage.usageSpan': '用量跨度', + 'slash.inspector.usage.codeChanges': '代码改动', + 'slash.inspector.usage.input': '输入', + 'slash.inspector.usage.output': '输出', + 'slash.inspector.usage.cacheReadWrite': '缓存读 / 写', + 'slash.inspector.usage.webSearch': '网页搜索', + 'slash.inspector.usage.byModel': '按模型统计', + 'slash.inspector.usage.tokens': 'Tokens', + 'slash.inspector.usage.noModelTitle': '暂无模型用量', + 'slash.inspector.usage.noModelBody': '记录到模型回复后,这里会显示 token 用量。', + 'slash.inspector.context.emptyTitle': '暂无上下文数据', + 'slash.inspector.context.emptyBody': '上下文用量需要活跃的 CLI 会话。', + 'slash.inspector.context.windowUsage': '上下文窗口用量', + 'slash.inspector.context.used': '已使用', + 'slash.inspector.context.free': '剩余', + 'slash.inspector.context.messages': '消息', + 'slash.inspector.context.assistant': '助手', + 'slash.inspector.context.toolResults': '工具结果', + 'slash.inspector.context.context': '上下文', + 'slash.inspector.context.categoryTitle': '按类别估算', + 'slash.inspector.context.noCategoriesTitle': '暂无上下文分类', + 'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。', 'chat.navigate': '导航', 'chat.select': '选择', 'chat.dismiss': '关闭', diff --git a/src/cli/print.ts b/src/cli/print.ts index 60472575..a71d3245 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -259,6 +259,7 @@ import { } from 'src/utils/messages/mappers.js' import { createModelSwitchBreadcrumbs } from 'src/utils/messages.js' import { collectContextData } from 'src/commands/context/context-noninteractive.js' +import { getSessionUsageSnapshot } from 'src/cost-tracker.js' import { LOCAL_COMMAND_STDOUT_TAG } from 'src/constants/xml.js' import { statusListeners, @@ -2958,6 +2959,8 @@ function runHeadlessStreaming( sendControlResponseSuccess(message, { mcpServers: buildMcpServerStatuses(), }) + } else if (message.request.subtype === 'get_session_usage') { + sendControlResponseSuccess(message, getSessionUsageSnapshot()) } else if (message.request.subtype === 'get_context_usage') { try { const appState = getAppState() diff --git a/src/cost-tracker.ts b/src/cost-tracker.ts index b03184c6..218f8985 100644 --- a/src/cost-tracker.ts +++ b/src/cost-tracker.ts @@ -68,6 +68,34 @@ export { getUsageForModel, } +export type SessionUsageSnapshot = { + totalCostUSD: number + costDisplay: string + hasUnknownModelCost: boolean + totalAPIDuration: number + totalDuration: number + totalLinesAdded: number + totalLinesRemoved: number + totalInputTokens: number + totalOutputTokens: number + totalCacheReadInputTokens: number + totalCacheCreationInputTokens: number + totalWebSearchRequests: number + models: Array<{ + model: string + displayName: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + webSearchRequests: number + costUSD: number + costDisplay: string + contextWindow: number + maxOutputTokens: number + }> +} + type StoredCostState = { totalCostUSD: number totalAPIDuration: number @@ -243,6 +271,36 @@ ${modelUsageDisplay}`, ) } +export function getSessionUsageSnapshot(): SessionUsageSnapshot { + return { + totalCostUSD: getTotalCostUSD(), + costDisplay: formatCost(getTotalCostUSD()), + hasUnknownModelCost: hasUnknownModelCost(), + totalAPIDuration: getTotalAPIDuration(), + totalDuration: getTotalDuration(), + totalLinesAdded: getTotalLinesAdded(), + totalLinesRemoved: getTotalLinesRemoved(), + totalInputTokens: getTotalInputTokens(), + totalOutputTokens: getTotalOutputTokens(), + totalCacheReadInputTokens: getTotalCacheReadInputTokens(), + totalCacheCreationInputTokens: getTotalCacheCreationInputTokens(), + totalWebSearchRequests: getTotalWebSearchRequests(), + models: Object.entries(getModelUsage()).map(([model, usage]) => ({ + model, + displayName: getCanonicalName(model), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + webSearchRequests: usage.webSearchRequests, + costUSD: usage.costUSD, + costDisplay: formatCost(usage.costUSD), + contextWindow: usage.contextWindow, + maxOutputTokens: usage.maxOutputTokens, + })), + } +} + function round(number: number, precision: number): number { return Math.round(number * precision) / precision } diff --git a/src/entrypoints/sdk/controlSchemas.ts b/src/entrypoints/sdk/controlSchemas.ts index fccf13e1..74b5792f 100644 --- a/src/entrypoints/sdk/controlSchemas.ts +++ b/src/entrypoints/sdk/controlSchemas.ts @@ -182,6 +182,48 @@ export const SDKControlGetContextUsageRequestSchema = lazySchema(() => ), ) +export const SDKControlGetSessionUsageRequestSchema = lazySchema(() => + z + .object({ + subtype: z.literal('get_session_usage'), + }) + .describe('Requests accumulated cost and token usage for the current session.'), +) + +export const SDKControlGetSessionUsageResponseSchema = lazySchema(() => + z + .object({ + totalCostUSD: z.number(), + costDisplay: z.string(), + hasUnknownModelCost: z.boolean(), + totalAPIDuration: z.number(), + totalDuration: z.number(), + totalLinesAdded: z.number(), + totalLinesRemoved: z.number(), + totalInputTokens: z.number(), + totalOutputTokens: z.number(), + totalCacheReadInputTokens: z.number(), + totalCacheCreationInputTokens: z.number(), + totalWebSearchRequests: z.number(), + models: z.array( + z.object({ + model: z.string(), + displayName: z.string(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadInputTokens: z.number(), + cacheCreationInputTokens: z.number(), + webSearchRequests: z.number(), + costUSD: z.number(), + costDisplay: z.string(), + contextWindow: z.number(), + maxOutputTokens: z.number(), + }), + ), + }) + .describe('Accumulated cost, duration, code change, and model usage data.'), +) + const ContextCategorySchema = lazySchema(() => z.object({ name: z.string(), @@ -559,6 +601,7 @@ export const SDKControlRequestInnerSchema = lazySchema(() => SDKControlSetMaxThinkingTokensRequestSchema(), SDKControlMcpStatusRequestSchema(), SDKControlGetContextUsageRequestSchema(), + SDKControlGetSessionUsageRequestSchema(), SDKHookCallbackRequestSchema(), SDKControlMcpMessageRequestSchema(), SDKControlRewindFilesRequestSchema(), diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 2d6dc7e7..c0d1f168 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -11,6 +11,7 @@ import * as path from 'path' import * as os from 'os' import { fileURLToPath } from 'node:url' import { ConversationService, conversationService } from '../services/conversationService.js' +import { SessionService } from '../services/sessionService.js' import { ProviderService } from '../services/providerService.js' // ============================================================================ @@ -177,6 +178,106 @@ describe('ConversationService', () => { ;(svc as any).handleProcessExit('session-restart', newProc, 0) expect(svc.hasSession('session-restart')).toBe(false) }) + + it('should retain SDK init metadata after recent message trimming', () => { + const svc = new ConversationService() + + ;(svc as any).sessions.set('session-init-retention', { + proc: { pid: 1 }, + outputCallbacks: [], + workDir: process.cwd(), + permissionMode: 'default', + sdkToken: 'token', + sdkSocket: null, + pendingOutbound: [], + stderrLines: [], + sdkMessages: [], + initMessage: null, + pendingPermissionRequests: new Map(), + }) + + ;(svc as any).handleSdkPayload('session-init-retention', JSON.stringify({ + type: 'system', + subtype: 'init', + model: 'mock-opus', + claude_code_version: 'test-version', + slash_commands: ['help', 'context'], + })) + + for (let i = 0; i < 45; i++) { + ;(svc as any).handleSdkPayload('session-init-retention', JSON.stringify({ + type: 'stream_event', + event: { type: 'message_delta', index: i }, + })) + } + + expect(svc.getRecentSdkMessages('session-init-retention').some((message) => message.subtype === 'init')).toBe(false) + expect(svc.getSessionInitMessage('session-init-retention')).toMatchObject({ + model: 'mock-opus', + claude_code_version: 'test-version', + slash_commands: ['help', 'context'], + }) + }) + + it('should reconstruct usage and metadata from a persisted transcript', async () => { + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-')) + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + + try { + const svc = new SessionService() + const { sessionId } = await svc.createSession(workDir) + const found = await svc.findSessionFile(sessionId) + expect(found).not.toBeNull() + + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-04-27T12:00:00.000Z', + cwd: workDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'mock-model', + content: [{ type: 'text', text: 'hello' }], + usage: { + input_tokens: 1234, + output_tokens: 56, + cache_read_input_tokens: 7, + cache_creation_input_tokens: 8, + server_tool_use: { web_search_requests: 1 }, + }, + }, + }) + '\n') + + const metadata = await svc.getTranscriptMetadata(sessionId) + const usage = await svc.getTranscriptUsage(sessionId) + + expect(metadata).toMatchObject({ + cwd: workDir, + version: '999.0.0-test', + model: 'mock-model', + }) + expect(usage).toMatchObject({ + source: 'transcript', + totalInputTokens: 1234, + totalOutputTokens: 56, + totalCacheReadInputTokens: 7, + totalCacheCreationInputTokens: 8, + totalWebSearchRequests: 1, + }) + expect(usage?.models[0]?.model).toBe('mock-model') + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }) + await fs.rm(workDir, { recursive: true, force: true }) + } + }) }) // ============================================================================ @@ -490,6 +591,53 @@ describe('WebSocket Chat Integration', () => { ).toBe(true) }) + it('should display CLI /cost local command output', async () => { + const messages = await runTurn(`chat-cost-${crypto.randomUUID()}`, '/cost') + + expect(messages.some((m) => m.type === 'error')).toBe(false) + expect( + messages.some( + (m) => + m.type === 'content_delta' && + typeof m.text === 'string' && + m.text.includes('Total cost: $0.0000'), + ), + ).toBe(true) + expect(messages.some((m) => m.type === 'message_complete')).toBe(true) + }) + + it('should display CLI /context local command output', async () => { + const messages = await runTurn(`chat-context-${crypto.randomUUID()}`, '/context') + + expect(messages.some((m) => m.type === 'error')).toBe(false) + expect( + messages.some( + (m) => + m.type === 'content_delta' && + typeof m.text === 'string' && + m.text.includes('## Context Usage'), + ), + ).toBe(true) + expect(messages.some((m) => m.type === 'message_complete')).toBe(true) + }) + + it('should expose structured session inspection data from the active CLI', async () => { + const sessionId = `chat-inspection-${crypto.randomUUID()}` + await runTurn(sessionId, 'hello before inspection') + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection`) + expect(res.status).toBe(200) + const body = await res.json() as any + + expect(body.active).toBe(true) + expect(body.status.model).toBe('mock-opus') + expect(body.status.slashCommandCount).toBe(1) + expect(body.usage.costDisplay).toBe('$0.1234') + expect(body.usage.source).toBe('current_process') + expect(body.context.model).toBe('mock-opus') + expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }]) + }) + it('should complete the client turn when the CLI exits after startup', async () => { const messages = await withMockExitAfterFirstUser(50, () => runTurnUntilComplete(`chat-late-exit-${crypto.randomUUID()}`, 'trigger late exit'), diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts index 20ebcabd..582999c5 100644 --- a/src/server/__tests__/fixtures/mock-sdk-cli.ts +++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts @@ -75,6 +75,41 @@ ws.addEventListener('message', (event) => { continue } const text = extractUserText(parsed) + const slashCommand = text.trim() + if (slashCommand === '/cost') { + emit(ws, { + type: 'system', + subtype: 'local_command_output', + content: 'Total cost: $0.0000\nTotal duration: 0s', + session_id: sessionId, + }) + emit(ws, { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Total cost: $0.0000', + usage: { input_tokens: 0, output_tokens: 0 }, + session_id: sessionId, + }) + continue + } + if (slashCommand === '/context') { + emit(ws, { + type: 'system', + subtype: 'local_command_output', + content: '## Context Usage\n\n| Type | Tokens |\n| --- | ---: |\n| System prompt | 123 |', + session_id: sessionId, + }) + emit(ws, { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Context usage', + usage: { input_tokens: 0, output_tokens: 0 }, + session_id: sessionId, + }) + continue + } emit(ws, { type: 'stream_event', event: { type: 'message_start' }, @@ -134,6 +169,101 @@ ws.addEventListener('message', (event) => { session_id: sessionId, }) } + + if (parsed.type === 'control_request' && parsed.request?.subtype === 'get_session_usage') { + emit(ws, { + type: 'control_response', + response: { + subtype: 'success', + request_id: parsed.request_id, + response: { + totalCostUSD: 0.1234, + costDisplay: '$0.1234', + hasUnknownModelCost: false, + totalAPIDuration: 4, + totalDuration: 43, + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalInputTokens: 27000, + totalOutputTokens: 41, + totalCacheReadInputTokens: 0, + totalCacheCreationInputTokens: 0, + totalWebSearchRequests: 0, + models: [{ + model: 'mock-opus', + displayName: 'mock-opus', + inputTokens: 27000, + outputTokens: 41, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.1234, + costDisplay: '$0.1234', + contextWindow: 200000, + maxOutputTokens: 8192, + }], + }, + }, + session_id: sessionId, + }) + } + + if (parsed.type === 'control_request' && parsed.request?.subtype === 'get_context_usage') { + emit(ws, { + type: 'control_response', + response: { + subtype: 'success', + request_id: parsed.request_id, + response: { + categories: [ + { name: 'System prompt', tokens: 6800, color: '#8a8a8a' }, + { name: 'MCP tools', tokens: 5900, color: '#06b6d4' }, + { name: 'Messages', tokens: 2400, color: '#7c3aed' }, + { name: 'Free space', tokens: 132000, color: '#a1a1aa' }, + ], + totalTokens: 27000, + maxTokens: 200000, + rawMaxTokens: 200000, + percentage: 13, + gridRows: Array.from({ length: 10 }, (_, row) => + Array.from({ length: 10 }, (_, col) => { + const index = row * 10 + col + return { + color: index < 13 ? '#06b6d4' : '#a1a1aa', + isFilled: index < 13, + categoryName: index < 13 ? 'Used' : 'Free space', + tokens: 2000, + percentage: 1, + squareFullness: index < 13 ? 1 : 0, + } + }), + ), + model: 'mock-opus', + memoryFiles: [], + mcpTools: [{ name: 'mock_tool', serverName: 'mock', tokens: 144, isLoaded: true }], + agents: [], + skills: { totalSkills: 1, includedSkills: 1, tokens: 3000, skillFrontmatter: [] }, + isAutoCompactEnabled: true, + apiUsage: null, + }, + }, + session_id: sessionId, + }) + } + + if (parsed.type === 'control_request' && parsed.request?.subtype === 'mcp_status') { + emit(ws, { + type: 'control_response', + response: { + subtype: 'success', + request_id: parsed.request_id, + response: { + mcpServers: [{ name: 'mock', status: 'connected' }], + }, + }, + session_id: sessionId, + }) + } } })() }) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index c9a908c4..6ea38921 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -13,6 +13,7 @@ */ import { sessionService } from '../services/sessionService.js' +import { conversationService } from '../services/conversationService.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { getSlashCommands } from '../ws/handler.js' import { getCommandName } from '../../commands.js' @@ -98,6 +99,16 @@ export async function handleSessionsApi( return await getSessionSlashCommands(sessionId) } + if (subResource === 'inspection') { + if (req.method !== 'GET') { + return Response.json( + { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, + { status: 405 } + ) + } + return await getSessionInspection(sessionId) + } + // Route to conversations handler if sub-resource is 'chat' if (subResource === 'chat') { // This is handled by the conversations API, but in case the router @@ -206,6 +217,123 @@ async function getSessionSlashCommands(sessionId: string): Promise { return Response.json({ commands: slashCommands }) } +async function getSessionInspection(sessionId: string): Promise { + const workDir = + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) + + if (!workDir) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + const active = conversationService.hasSession(sessionId) + const initMessage = conversationService.getSessionInitMessage(sessionId) ?? + [...conversationService.getRecentSdkMessages(sessionId)] + .reverse() + .find((message) => message?.type === 'system' && message.subtype === 'init') + const transcriptMetadata = await sessionService.getTranscriptMetadata(sessionId) + const cachedSlashCommands = getSlashCommands(sessionId) + const fallbackSlashCommands = cachedSlashCommands.length > 0 + ? cachedSlashCommands + : (await getSkillDirCommands(workDir)) + .filter((command) => command.userInvocable !== false) + .map((command) => ({ + name: getCommandName(command), + description: command.description || '', + })) + const slashCommandCount = Array.isArray(initMessage?.slash_commands) + ? initMessage.slash_commands.length + : fallbackSlashCommands.length + + const response: Record = { + active, + status: { + sessionId, + workDir, + permissionMode: conversationService.getSessionPermissionMode(sessionId), + version: typeof initMessage?.claude_code_version === 'string' ? initMessage.claude_code_version : transcriptMetadata?.version, + cwd: typeof initMessage?.cwd === 'string' ? initMessage.cwd : transcriptMetadata?.cwd ?? workDir, + model: typeof initMessage?.model === 'string' ? initMessage.model : transcriptMetadata?.model, + apiKeySource: typeof initMessage?.apiKeySource === 'string' ? initMessage.apiKeySource : undefined, + outputStyle: typeof initMessage?.output_style === 'string' ? initMessage.output_style : undefined, + tools: Array.isArray(initMessage?.tools) ? initMessage.tools : [], + mcpServers: Array.isArray(initMessage?.mcp_servers) ? initMessage.mcp_servers : [], + slashCommandCount, + skillCount: Array.isArray(initMessage?.skills) ? initMessage.skills.length : 0, + }, + errors: {}, + } + const transcriptUsage = await sessionService.getTranscriptUsage(sessionId) + + if (!active) { + if (transcriptUsage) { + response.usage = transcriptUsage + } + response.errors = { + ...(transcriptUsage ? {} : { usage: 'CLI session is not running' }), + context: 'CLI session is not running', + } + return Response.json(response) + } + + const [usageResult, contextResult, mcpResult] = await Promise.allSettled([ + conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }), + conversationService.requestControl(sessionId, { subtype: 'get_context_usage' }, 20_000), + conversationService.requestControl(sessionId, { subtype: 'mcp_status' }), + ]) + + const errors: Record = {} + if (usageResult.status === 'fulfilled') { + response.usage = chooseRicherUsage( + { ...usageResult.value, source: 'current_process' }, + transcriptUsage, + ) + } else { + if (transcriptUsage) { + response.usage = transcriptUsage + } else { + errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason) + } + } + + if (contextResult.status === 'fulfilled') { + response.context = contextResult.value + } else { + errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason) + } + + if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') { + response.status = { + ...response.status, + mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record).mcpServers, + } + } + + response.errors = errors + return Response.json(response) +} + +function usageTokenTotal(usage: unknown): number { + if (!usage || typeof usage !== 'object') return 0 + const record = usage as Record + return [ + record.totalInputTokens, + record.totalOutputTokens, + record.totalCacheReadInputTokens, + record.totalCacheCreationInputTokens, + ].reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0) +} + +function chooseRicherUsage( + currentUsage: Record, + transcriptUsage: Record | null, +): Record { + if (!transcriptUsage) return currentUsage + return usageTokenTotal(transcriptUsage) > usageTokenTotal(currentUsage) + ? transcriptUsage + : currentUsage +} + async function getGitInfo(sessionId: string): Promise { const workDir = await sessionService.getSessionWorkDir(sessionId) if (!workDir) { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 127d5532..4a3a2ab2 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -34,6 +34,7 @@ type SessionProcess = { pendingOutbound: string[] stderrLines: string[] sdkMessages: any[] + initMessage: any | null pendingPermissionRequests: Map< string, { @@ -173,6 +174,7 @@ export class ConversationService { pendingOutbound: [], stderrLines: [], sdkMessages: [], + initMessage: null, pendingPermissionRequests: new Map(), } this.sessions.set(sessionId, session) @@ -232,10 +234,20 @@ export class ConversationService { } } + removeOutputCallback(sessionId: string, callback: (msg: any) => void): void { + const session = this.sessions.get(sessionId) + if (!session) return + session.outputCallbacks = session.outputCallbacks.filter((entry) => entry !== callback) + } + getRecentSdkMessages(sessionId: string): any[] { return [...(this.sessions.get(sessionId)?.sdkMessages ?? [])] } + getSessionInitMessage(sessionId: string): any | null { + return this.sessions.get(sessionId)?.initMessage ?? null + } + sendMessage( sessionId: string, content: string, @@ -309,6 +321,60 @@ export class ConversationService { }) } + requestControl( + sessionId: string, + request: Record, + timeoutMs = 10_000, + ): Promise> { + if (!this.sessions.has(sessionId)) { + return Promise.reject(new Error('CLI session is not running')) + } + + const requestId = crypto.randomUUID() + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.removeOutputCallback(sessionId, handleOutput) + reject(new Error(`Timed out waiting for ${String(request.subtype ?? 'control')} response`)) + }, timeoutMs) + + const finish = (fn: () => void) => { + clearTimeout(timeout) + this.removeOutputCallback(sessionId, handleOutput) + fn() + } + + const handleOutput = (msg: any) => { + if ( + msg?.type !== 'control_response' || + msg.response?.request_id !== requestId + ) { + return + } + + if (msg.response.subtype === 'error') { + finish(() => reject(new Error(String(msg.response.error || 'Control request failed')))) + return + } + + finish(() => resolve( + msg.response.response && typeof msg.response.response === 'object' + ? msg.response.response as Record + : {}, + )) + } + + this.onOutput(sessionId, handleOutput) + const sent = this.sendSdkMessage(sessionId, { + type: 'control_request', + request_id: requestId, + request, + }) + if (!sent) { + finish(() => reject(new Error('CLI session is not running'))) + } + }) + } + hasSession(sessionId: string): boolean { return this.sessions.has(sessionId) } @@ -371,6 +437,9 @@ export class ConversationService { if (session.sdkMessages.length > 40) { session.sdkMessages.splice(0, 20) } + if (msg?.type === 'system' && msg.subtype === 'init') { + session.initMessage = msg + } if ( msg?.type === 'control_request' && msg.request?.subtype === 'can_use_tool' && diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index c656871d..651daa97 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -11,6 +11,9 @@ import * as os from 'node:os' import { ApiError } from '../middleware/errorHandler.js' import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' +import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js' +import { getContextWindowForModel, getModelMaxOutputTokens } from '../../utils/context.js' +import { getCanonicalName } from '../../utils/model/model.js' // ============================================================================ // Types @@ -55,6 +58,41 @@ export type MessageEntry = { isSidechain?: boolean } +export type TranscriptUsageSnapshot = { + source: 'transcript' + totalCostUSD: number + costDisplay: string + hasUnknownModelCost: boolean + totalAPIDuration: number + totalDuration: number + totalLinesAdded: number + totalLinesRemoved: number + totalInputTokens: number + totalOutputTokens: number + totalCacheReadInputTokens: number + totalCacheCreationInputTokens: number + totalWebSearchRequests: number + models: Array<{ + model: string + displayName: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + webSearchRequests: number + costUSD: number + costDisplay: string + contextWindow: number + maxOutputTokens: number + }> +} + +export type TranscriptMetadataSnapshot = { + model?: string + cwd?: string + version?: string +} + /** Raw entry parsed from a single JSONL line */ type RawEntry = { type?: string @@ -71,8 +109,19 @@ type RawEntry = { model?: string id?: string type?: string + usage?: { + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + server_tool_use?: { + web_search_requests?: number + } + speed?: string + } } timestamp?: string + version?: string snapshot?: { messageId?: string trackedFileBackups?: Record @@ -510,6 +559,153 @@ export class SessionService { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) } + private formatCost(cost: number): string { + return `$${cost > 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}` + } + + async getTranscriptMetadata(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + const entries = await this.readJsonlFile(found.filePath) + const metadata: TranscriptMetadataSnapshot = {} + + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]! + if (!metadata.model && typeof entry.message?.model === 'string') { + metadata.model = entry.message.model + } + if (!metadata.cwd && typeof entry.cwd === 'string') { + metadata.cwd = entry.cwd + } + if (!metadata.version && typeof entry.version === 'string') { + metadata.version = entry.version + } + if (metadata.model && metadata.cwd && metadata.version) break + } + + return metadata + } + + async getTranscriptUsage(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + const entries = await this.readJsonlFile(found.filePath) + const models = new Map() + let totalCostUSD = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheReadInputTokens = 0 + let totalCacheCreationInputTokens = 0 + let totalWebSearchRequests = 0 + let hasUnknownModelCost = false + let firstUsageAt: number | null = null + let lastUsageAt: number | null = null + + for (const entry of entries) { + const usage = entry.message?.usage + const model = entry.message?.model + if (!usage || typeof model !== 'string') continue + + const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 + const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 + const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 + const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 + const webSearchRequests = typeof usage.server_tool_use?.web_search_requests === 'number' + ? usage.server_tool_use.web_search_requests + : 0 + + if ( + inputTokens === 0 && + outputTokens === 0 && + cacheReadInputTokens === 0 && + cacheCreationInputTokens === 0 && + webSearchRequests === 0 + ) { + continue + } + + const canonical = getCanonicalName(model) + if (!Object.prototype.hasOwnProperty.call(MODEL_COSTS, canonical)) { + hasUnknownModelCost = true + } + + const costUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_input_tokens: cacheReadInputTokens, + cache_creation_input_tokens: cacheCreationInputTokens, + server_tool_use: { web_search_requests: webSearchRequests }, + speed: usage.speed, + } as Parameters[1] + const costUSD = calculateUSDCost(model, costUsage) + + let modelUsage = models.get(model) + if (!modelUsage) { + modelUsage = { + model, + displayName: canonical, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + costDisplay: '$0.0000', + contextWindow: getContextWindowForModel(model), + maxOutputTokens: getModelMaxOutputTokens(model).default, + } + models.set(model, modelUsage) + } + + modelUsage.inputTokens += inputTokens + modelUsage.outputTokens += outputTokens + modelUsage.cacheReadInputTokens += cacheReadInputTokens + modelUsage.cacheCreationInputTokens += cacheCreationInputTokens + modelUsage.webSearchRequests += webSearchRequests + modelUsage.costUSD += costUSD + modelUsage.costDisplay = this.formatCost(modelUsage.costUSD) + + totalCostUSD += costUSD + totalInputTokens += inputTokens + totalOutputTokens += outputTokens + totalCacheReadInputTokens += cacheReadInputTokens + totalCacheCreationInputTokens += cacheCreationInputTokens + totalWebSearchRequests += webSearchRequests + + if (entry.timestamp) { + const time = Date.parse(entry.timestamp) + if (!Number.isNaN(time)) { + firstUsageAt = firstUsageAt === null ? time : Math.min(firstUsageAt, time) + lastUsageAt = lastUsageAt === null ? time : Math.max(lastUsageAt, time) + } + } + } + + if (models.size === 0) return null + + return { + source: 'transcript', + totalCostUSD, + costDisplay: this.formatCost(totalCostUSD), + hasUnknownModelCost, + totalAPIDuration: 0, + totalDuration: + firstUsageAt !== null && lastUsageAt !== null + ? Math.max(0, Math.round((lastUsageAt - firstUsageAt) / 1000)) + : 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalInputTokens, + totalOutputTokens, + totalCacheReadInputTokens, + totalCacheCreationInputTokens, + totalWebSearchRequests, + models: Array.from(models.values()), + } + } + // -------------------------------------------------------------------------- // Public API // -------------------------------------------------------------------------- diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 3a795819..49a99996 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1056,6 +1056,17 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { // Hook 执行中 — 不转发给前端 return [] } + if (subtype === 'local_command' || subtype === 'local_command_output') { + const localCommandOutput = extractLocalCommandOutput( + cliMsg.content ?? cliMsg.message, + { allowUntagged: subtype === 'local_command_output' }, + ) + if (!localCommandOutput) return [] + return [ + { type: 'content_start', blockType: 'text' }, + { type: 'content_delta', text: localCommandOutput }, + ] + } // Bug #7: 处理 task/team system 消息 if (subtype === 'task_notification') { return [{ @@ -1124,7 +1135,10 @@ function getDesktopSlashCommand(content: string): ReturnType