From b4142c82217476b2191a3e6061affbc36e8ced5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 28 Apr 2026 00:25:11 +0800 Subject: [PATCH] Make desktop slash commands useful outside the terminal Desktop slash commands now separate local UI panels from CLI turn execution. The session inspector exposes status, usage, and context data from the active session, including transcript and context fallbacks, so /status, /cost, and /context can render structured desktop UI instead of raw terminal text. The inspector and help surfaces now use the existing desktop i18n catalogs for English and Chinese labels. Constraint: Desktop read-only slash commands must not spawn duplicate CLI processes or depend on submitting a normal user turn. Rejected: Render raw CLI command text in chat | it keeps terminal-specific layout constraints and does not fit the desktop panel UX. Confidence: high Scope-risk: moderate Directive: Keep /status, /cost, and /context routed through the local inspector unless the CLI exposes a structured interactive command protocol. Tested: cd desktop && bun run lint Tested: cd desktop && bun test src/components/chat/composerUtils.test.ts Tested: bun test src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run build Tested: agent-browser smoke test on http://127.0.0.1:2024/ for /context localized inspector Tested: git diff --check Not-tested: Full packaged Tauri desktop build. --- desktop/src/api/sessions.ts | 110 +++ desktop/src/components/chat/ChatInput.tsx | 1 + .../chat/LocalSlashCommandPanel.tsx | 637 +++++++++++++++++- .../src/components/chat/composerUtils.test.ts | 6 + desktop/src/components/chat/composerUtils.ts | 6 +- desktop/src/i18n/locales/en.ts | 69 ++ desktop/src/i18n/locales/zh.ts | 69 ++ src/cli/print.ts | 3 + src/cost-tracker.ts | 58 ++ src/entrypoints/sdk/controlSchemas.ts | 43 ++ src/server/__tests__/conversations.test.ts | 148 ++++ src/server/__tests__/fixtures/mock-sdk-cli.ts | 130 ++++ src/server/api/sessions.ts | 128 ++++ src/server/services/conversationService.ts | 69 ++ src/server/services/sessionService.ts | 196 ++++++ src/server/ws/handler.ts | 25 +- 16 files changed, 1679 insertions(+), 19 deletions(-) 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