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.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-28 00:25:11 +08:00
parent bc93493ed1
commit b4142c8221
16 changed files with 1679 additions and 19 deletions

View File

@ -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<Array<{
color: string
isFilled: boolean
categoryName: string
tokens: number
percentage: number
squareFullness: number
}>>
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<string, string>
}
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<SessionInspectionResponse>(`/api/sessions/${sessionId}/inspection`, {
timeout: 25_000,
})
},
rewind(sessionId: string, body: {
targetUserMessageId?: string
userMessageIndex?: number

View File

@ -545,6 +545,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
<div ref={slashMenuRef}>
<LocalSlashCommandPanel
command={localSlashPanel}
sessionId={activeTabId ?? undefined}
cwd={resolvedWorkDir}
commands={allSlashCommands}
onClose={() => setLocalSlashPanel(null)}

View File

@ -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<typeof useTranslation>
function toneForStatus(status: McpServerRecord['status']) {
switch (status) {
case 'connected':
@ -79,7 +89,7 @@ function PanelShell({
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<div className="max-h-[420px] overflow-y-auto px-5 py-4">{children}</div>
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
</div>
)
}
@ -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<string, unknown> {
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 (
<div className="mb-3 flex items-center justify-between gap-4">
<div className="font-mono text-[12px] font-semibold uppercase tracking-[0.24em] text-[#2b1b15]">{children}</div>
{action}
</div>
)
}
function MetricCard({ label, value, detail }: { label: string; value: React.ReactNode; detail?: React.ReactNode }) {
return (
<div className="min-h-[82px] rounded-md border border-[#d8b3a8] bg-[#f4f2ed] px-4 py-4 font-mono">
<div className="text-[12px] uppercase tracking-[0.2em] text-[#2b1b15]">{label}</div>
<div className="mt-3 whitespace-pre-line text-[15px] leading-6 text-[#1f1713]">{value}</div>
{detail && <div className="mt-1 text-[13px] leading-5 text-[#7b665f]">{detail}</div>}
</div>
)
}
function InspectorNotice({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-md border border-[#d8b3a8] bg-[#fbfaf6] px-4 py-3 text-[14px] text-[#2b1b15]">
<span className="material-symbols-outlined text-[18px] text-[#7b665f]">info</span>
<span>{children}</span>
</div>
)
}
function KeyValueRows({ rows }: { rows: Array<[string, React.ReactNode]> }) {
return (
<div className="overflow-hidden rounded-md border border-[#d8b3a8] bg-[#fbfaf6] font-mono">
{rows.map(([label, value]) => (
<div key={label} className="grid grid-cols-[220px_minmax(0,1fr)] border-t border-[#d8b3a8] first:border-t-0">
<div className="border-r border-[#d8b3a8] bg-[#f4f2ed] px-4 py-3 text-[12px] font-semibold uppercase tracking-[0.24em] text-[#2b1b15]">
{label}
</div>
<div className="min-w-0 break-words px-4 py-3 text-[14px] text-[#1f1713]">{value}</div>
</div>
))}
</div>
)
}
function UsageTab({
usage,
context,
error,
t,
}: {
usage?: SessionUsageSnapshot
context?: SessionContextSnapshot
error?: string
t: Translate
}) {
if (error && !usage) return <ErrorState message={error} />
if (!usage) {
return <EmptyState title={t('slash.inspector.usage.emptyTitle')} body={t('slash.inspector.usage.emptyBody')} />
}
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 (
<div className="space-y-7">
{useContextUsageFallback && (
<InspectorNotice>
{t('slash.inspector.usage.contextSnapshotNotice')}
</InspectorNotice>
)}
{usage.source === 'transcript' && (
<div className="rounded-md border border-[#d8b3a8] bg-[#fbfaf6] px-4 py-3 text-sm text-[#5f514c]">
{t('slash.inspector.usage.transcriptNotice')}
</div>
)}
{usage.hasUnknownModelCost && (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-700">
{t('slash.inspector.usage.unknownCost')}
</div>
)}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard label={t('slash.inspector.usage.totalCost')} value={useContextUsageFallback ? 'n/a' : usage.costDisplay} />
<MetricCard label={t('slash.inspector.usage.source')} value={sourceLabel} />
<MetricCard label={t('slash.inspector.usage.apiDuration')} value={usage.source === 'transcript' || useContextUsageFallback ? '0ms' : formatDuration(usage.totalAPIDuration)} />
<MetricCard label={usage.source === 'transcript' ? t('slash.inspector.usage.usageSpan') : t('slash.inspector.usage.wallDuration')} value={useContextUsageFallback ? '0ms' : formatDuration(usage.totalDuration)} />
<MetricCard
label={t('slash.inspector.usage.codeChanges')}
value={`${formatNumber(usage.totalLinesAdded)}/${formatNumber(usage.totalLinesRemoved)}`}
/>
<MetricCard label={t('slash.inspector.usage.input')} value={formatNumber(totalInputTokens)} />
<MetricCard label={t('slash.inspector.usage.output')} value={formatNumber(totalOutputTokens)} />
<MetricCard label={t('slash.inspector.usage.cacheReadWrite')} value={`${formatNumber(totalCacheReadInputTokens)} / ${formatNumber(totalCacheCreationInputTokens)}`} />
<MetricCard label={t('slash.inspector.usage.webSearch')} value={formatNumber(usage.totalWebSearchRequests)} />
</div>
<section>
<div className="mb-3 text-[22px] font-semibold text-[#1f1713]">{t('slash.inspector.usage.byModel')}</div>
{models.length === 0 ? (
<EmptyState title={t('slash.inspector.usage.noModelTitle')} body={t('slash.inspector.usage.noModelBody')} />
) : (
<div className="overflow-hidden rounded-md border border-[#d8b3a8] bg-[#fbfaf6] font-mono">
{models.map((model) => (
<div key={model.model} className="border-t border-[#d8b3a8] first:border-t-0">
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-4 border-b border-[#d8b3a8] px-4 py-3">
<div className="min-w-0 truncate text-[13px] font-semibold text-[#1f1713]">{model.displayName || model.model}</div>
<div className="text-right text-[12px] font-semibold uppercase tracking-[0.18em] text-[#2b1b15]">{t('slash.inspector.usage.tokens')}</div>
</div>
<div className="grid grid-cols-[160px_minmax(0,1fr)_120px] items-center gap-4 border-b border-[#d8b3a8] px-4 py-3 last:border-b-0">
<div className="text-[12px] uppercase tracking-[0.18em] text-[#2b1b15]">{t('slash.inspector.usage.input')}</div>
<div className="h-1 overflow-hidden rounded-full bg-[#ebe7df]">
<div className="h-full rounded-full bg-[#8f3217]" style={{ width: '95%' }} />
</div>
<div className="text-right text-[13px] text-[#1f1713]">{formatNumber(model.inputTokens)}</div>
</div>
<div className="grid grid-cols-[160px_minmax(0,1fr)_120px] items-center gap-4 px-4 py-3">
<div className="text-[12px] uppercase tracking-[0.18em] text-[#2b1b15]">{t('slash.inspector.usage.output')}</div>
<div className="h-1 overflow-hidden rounded-full bg-[#ebe7df]">
<div className="h-full rounded-full bg-[#0f5c8f]" style={{ width: `${Math.max(4, Math.min(100, (model.outputTokens / Math.max(1, model.inputTokens)) * 100))}%` }} />
</div>
<div className="text-right text-[13px] text-[#1f1713]">{formatNumber(model.outputTokens)}</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
)
}
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 (
<div className="overflow-hidden rounded-full bg-[#ebe7df]">
<div className="flex h-2.5 w-full">
{activeCategories.map((category) => (
<div
key={category.name}
title={`${category.name}: ${formatNumber(category.tokens)} tokens`}
style={{
width: `${Math.max(0.5, (category.tokens / rawMaxTokens) * 100)}%`,
backgroundColor: category.color,
}}
/>
))}
</div>
</div>
)
}
function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: ContextCategory[]; rawMaxTokens: number; t: Translate }) {
const visibleCategories = categories.filter((category) => category.tokens > 0)
if (visibleCategories.length === 0) {
return <EmptyState title={t('slash.inspector.context.noCategoriesTitle')} body={t('slash.inspector.context.noCategoriesBody')} />
}
return (
<div className="rounded-md border border-[#d8b3a8] bg-[#f4f2ed] px-5 py-5 font-mono">
<InspectorSectionTitle>{t('slash.inspector.context.categoryTitle')}</InspectorSectionTitle>
<div className="grid gap-x-10 gap-y-5 sm:grid-cols-2">
{visibleCategories.map((category) => {
const percent = rawMaxTokens > 0 ? (category.tokens / rawMaxTokens) * 100 : 0
const muted = isCapacityCategory(category)
return (
<div
key={category.name}
className="min-w-0"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className={`min-w-0 truncate text-[14px] font-semibold ${muted ? 'text-[#5f514c]' : 'text-[#1f1713]'}`}>
{category.name}
</span>
</div>
<div className="shrink-0 text-right leading-tight">
<div className="text-sm text-[#1f1713]">{formatNumber(category.tokens)}</div>
</div>
</div>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-[#ebe7df]">
<div
className={muted ? 'h-full rounded-full opacity-65' : 'h-full rounded-full'}
style={{
width: `${Math.min(100, Math.max(0.5, percent))}%`,
backgroundColor: muted ? '#9b928c' : '#8f3217',
}}
/>
</div>
</div>
)
})}
</div>
</div>
)
}
function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<div className="min-w-0 font-mono">
<div className="truncate text-[12px] font-semibold uppercase tracking-[0.22em] text-[#7b665f]">{label}</div>
<div className="mt-2 truncate text-[16px] font-semibold text-[#1f1713]">{value}</div>
{detail && <div className="mt-1 truncate text-[13px] text-[#7b665f]">{detail}</div>}
</div>
)
}
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 (
<span className={`inline-flex items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.08em] ${badgeClass}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
{statusDisplayLabel(status, t)}
</span>
)
}
function McpServerIcon({ status }: { status: string }) {
const isFailed = status === 'failed'
const icon = isFailed ? 'power_off' : 'dns'
return (
<span className={`material-symbols-outlined text-[20px] ${isFailed ? 'text-[#c51616]' : 'text-[#25451b]'}`}>
{icon}
</span>
)
}
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 (
<div className="rounded-md border border-[#d8b3a8] bg-[#f4f2ed] px-5 py-6">
<div className="mb-8 flex items-start justify-between gap-4">
<InspectorSectionTitle>{t('slash.inspector.context.windowUsage')}</InspectorSectionTitle>
<span className="rounded-sm border border-[#d8b3a8] bg-[#ebe7df] px-2 py-1 font-mono text-xs text-[#5f514c]">{context.model}</span>
</div>
<div className="font-mono text-[24px] font-semibold text-[#1f1713]">
{formatNumber(context.totalTokens)}
<span className="mx-1.5 text-[#1f1713]">/</span>
<span>{formatNumber(context.rawMaxTokens)}</span>
<span className="ml-3 align-middle text-sm font-normal text-[#0f5c8f]">[{formatPercent(usedPercent)} {t('slash.inspector.context.used')}]</span>
</div>
<div className="mt-7">
<ContextStackedBar categories={categories} rawMaxTokens={context.rawMaxTokens} />
</div>
<div className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-md border border-[#d8b3a8] bg-[#fbfaf6] px-4 py-3">
<ContextStatPill label={t('slash.inspector.context.free')} value={formatNumber(freeTokens)} />
</div>
<div className="rounded-md border border-[#d8b3a8] bg-[#fbfaf6] px-4 py-3">
<ContextStatPill label={t('slash.inspector.context.messages')} value={formatNumber(context.messageBreakdown?.assistantMessageTokens ?? 0)} detail={t('slash.inspector.context.assistant')} />
</div>
<div className="rounded-md border border-[#d8b3a8] bg-[#fbfaf6] px-4 py-3">
<ContextStatPill label={t('slash.inspector.context.toolResults')} value={formatNumber(context.messageBreakdown?.toolResultTokens ?? 0)} />
</div>
<div className="rounded-md border border-[#d8b3a8] bg-[#fbfaf6] px-4 py-3">
<ContextStatPill label={t('slash.inspector.context.context')} value={formatPercent(usedPercent)} />
</div>
</div>
</div>
)
}
function ContextTab({ context, error, t }: { context?: SessionContextSnapshot; error?: string; t: Translate }) {
if (error && !context) return <ErrorState message={error} />
if (!context) {
return <EmptyState title={t('slash.inspector.context.emptyTitle')} body={t('slash.inspector.context.emptyBody')} />
}
const categories = Array.isArray(context.categories) ? context.categories : []
return (
<div className="space-y-6">
<ContextOverview context={context} categories={categories} t={t} />
<CategoryBreakdown categories={categories} rawMaxTokens={context.rawMaxTokens} t={t} />
</div>
)
}
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 (
<div className="space-y-8">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard
label={t('slash.inspector.status.cliStatus')}
value={(
<span className="inline-flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${data.active ? 'bg-[#25451b]' : 'bg-[#c51616]'}`} />
{data.active ? t('slash.inspector.status.running') : t('slash.inspector.status.notRunning')}
</span>
)}
/>
<MetricCard label={t('slash.inspector.status.activeModel')} value={model} />
<MetricCard
label={t('slash.inspector.status.mcpConnections')}
value={(
<span>
<span className="text-[#25451b]">{formatNumber(connectedMcp)}</span>
<span className="mx-5 text-[#1f1713]">/</span>
<span className="text-[#c51616]">{formatNumber(failedMcp)}</span>
</span>
)}
detail={(
<span>
<span className="text-[#25451b]">{t('slash.inspector.status.connected')}</span>
<span className="mx-5 text-[#1f1713]" />
<span className="text-[#c51616]">{t('slash.inspector.status.failed')}</span>
</span>
)}
/>
<MetricCard label={t('slash.inspector.status.registeredTools')} value={`${formatNumber(tools.length)} / ${formatNumber(slashCommandCount)} ${t('slash.inspector.status.commands')}`} />
</div>
<section>
<InspectorSectionTitle>{t('slash.inspector.status.sessionMetadata')}</InspectorSectionTitle>
<KeyValueRows
rows={[
[t('slash.inspector.status.version'), data.status.version ?? t('slash.inspector.status.unknown')],
[t('slash.inspector.status.sessionId'), <span className="font-mono text-[13px]">{data.status.sessionId}</span>],
[t('slash.inspector.status.workingDirectory'), <span className="font-mono text-[13px]">{data.status.cwd ?? data.status.workDir}</span>],
[t('slash.inspector.status.permissionMode'), <span className="rounded-sm bg-[#ebe7df] px-1.5 py-1">{data.status.permissionMode}</span>],
[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')],
]}
/>
</section>
{mcpServers.length > 0 && (
<section>
<InspectorSectionTitle
action={<button type="button" className="font-mono text-[12px] tracking-[0.18em] text-[#8f3217] hover:text-[#5b1e0d]"> {t('slash.inspector.status.refresh')}</button>}
>
{t('slash.inspector.status.mcpServers')}
</InspectorSectionTitle>
<div className="grid gap-3 lg:grid-cols-2">
{mcpServers.map((server) => (
<div
key={`${server.name}:${server.status}`}
className={`flex min-h-[48px] items-center justify-between gap-4 rounded-md border px-4 py-3 font-mono ${
server.status === 'failed' ? 'border-[#f1b8b0] bg-[#fff7f5]' : 'border-[#d8b3a8] bg-[#f4f2ed]'
}`}
>
<div className="flex min-w-0 items-center gap-3">
<McpServerIcon status={server.status} />
<span className="min-w-0 truncate text-[14px] text-[#1f1713]">{server.name}</span>
</div>
<InspectorStatusBadge status={server.status} t={t} />
</div>
))}
</div>
</section>
)}
</div>
)
}
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 (
<div
className="absolute bottom-full left-0 right-0 z-50 mb-4 overflow-hidden rounded-[10px] border bg-[#fbfaf6] shadow-[0_28px_80px_rgba(65,54,48,0.22)]"
style={{ borderColor: inspector.line, color: inspector.ink }}
>
<div className="grid min-h-[64px] grid-cols-[1fr_auto_1fr] items-center border-b border-[#d8b3a8] bg-[#fbfaf6] px-6">
<div className="font-mono text-[16px] font-semibold uppercase text-[#8f3217]">{t('slash.inspector.title')}</div>
<div className="flex items-center gap-8">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onSelectTab(tab.id)}
className={`relative h-10 px-0 font-sans text-sm transition-colors ${
selectedTab === tab.id ? 'text-[#8f3217]' : 'text-[#5f514c] hover:text-[#8f3217]'
}`}
>
{tab.label}
{selectedTab === tab.id && <span className="absolute bottom-1 left-0 right-0 h-[2px] bg-[#8f3217]" />}
</button>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
aria-label={t('slash.inspector.close')}
className="flex h-10 w-10 items-center justify-center text-[#8f3217] transition-colors hover:text-[#5b1e0d]"
>
<span className="material-symbols-outlined text-[24px]">close</span>
</button>
</div>
</div>
<div className="max-h-[min(540px,58vh)] overflow-y-auto bg-[#fbfaf6] px-6 py-6">{children}</div>
</div>
)
}
function SessionInspectorPanel({
command,
sessionId,
commands,
onClose,
}: {
command: LocalSlashCommandName
sessionId?: string
commands?: SlashCommandOption[]
onClose: () => void
}) {
const t = useTranslation()
const [selectedTab, setSelectedTab] = useState<SessionInspectorTab>(() => sessionInspectorInitialTab(command))
const [data, setData] = useState<SessionInspectionResponse | null>(null)
const [error, setError] = useState<string | null>(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 (
<SessionInspectorShell selectedTab={selectedTab} tabs={tabs} onSelectTab={setSelectedTab} onClose={onClose} t={t}>
{error ? (
<ErrorState message={error} />
) : data === null ? (
<LoadingState label={t('slash.inspector.loading')} />
) : selectedTab === 'usage' ? (
<UsageTab usage={data.usage} context={data.context} error={data.errors?.usage} t={t} />
) : selectedTab === 'context' ? (
<ContextTab context={data.context} error={data.errors?.context} t={t} />
) : (
<StatusTab data={data} commands={commands} t={t} />
)}
</SessionInspectorShell>
)
}
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<string, SlashCommandOption>()
for (const command of commands ?? []) {
@ -333,8 +939,8 @@ function HelpPanel({
return (
<PanelShell
title="Slash commands"
subtitle="Run agent workflows, inspect session state, or open desktop panels."
title={t('slash.help.title')}
subtitle={t('slash.help.subtitle')}
onClose={onClose}
>
<div className="space-y-4">
@ -344,8 +950,8 @@ function HelpPanel({
.filter((command): command is SlashCommandOption => Boolean(command))
if (entries.length === 0) return null
return (
<section key={group.title}>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{group.title}</div>
<section key={group.titleKey}>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{t(group.titleKey)}</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{entries.map(renderCommand)}
</div>
@ -355,13 +961,13 @@ function HelpPanel({
{otherCommands.length > 0 && (
<section>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">More</div>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{t('slash.help.group.more')}</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{otherCommands.map(renderCommand)}
</div>
{hiddenOtherCommandCount > 0 && (
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{hiddenOtherCommandCount} more commands available. Type / to search the full command list.
{t('slash.help.moreAvailable', { count: hiddenOtherCommandCount })}
</p>
)}
</section>
@ -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 <McpPanel cwd={cwd} onClose={onClose} />
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
if (command === 'status' || command === 'cost' || command === 'context') {
return <SessionInspectorPanel command={command} sessionId={sessionId} commands={commands} onClose={onClose} />
}
return <HelpPanel commands={commands} onClose={onClose} />
}

View File

@ -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' })
})
})

View File

@ -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' },
]

View File

@ -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',

View File

@ -511,6 +511,75 @@ export const zh: Record<TranslationKey, string> = {
'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': '关闭',

View File

@ -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()

View File

@ -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
}

View File

@ -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(),

View File

@ -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'),

View File

@ -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,
})
}
}
})()
})

View File

@ -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<Response> {
return Response.json({ commands: slashCommands })
}
async function getSessionInspection(sessionId: string): Promise<Response> {
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<string, unknown> = {
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<string, string> = {}
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<string, unknown>).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<string, unknown>
return [
record.totalInputTokens,
record.totalOutputTokens,
record.totalCacheReadInputTokens,
record.totalCacheCreationInputTokens,
].reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0)
}
function chooseRicherUsage(
currentUsage: Record<string, unknown>,
transcriptUsage: Record<string, unknown> | null,
): Record<string, unknown> {
if (!transcriptUsage) return currentUsage
return usageTokenTotal(transcriptUsage) > usageTokenTotal(currentUsage)
? transcriptUsage
: currentUsage
}
async function getGitInfo(sessionId: string): Promise<Response> {
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {

View File

@ -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<string, unknown>,
timeoutMs = 10_000,
): Promise<Record<string, unknown>> {
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<string, unknown>
: {},
))
}
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' &&

View File

@ -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<string, unknown>
@ -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<TranscriptMetadataSnapshot | null> {
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<TranscriptUsageSnapshot | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
const models = new Map<string, TranscriptUsageSnapshot['models'][number]>()
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<typeof calculateUSDCost>[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
// --------------------------------------------------------------------------

View File

@ -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<typeof parseSlashCo
return parsed
}
function extractLocalCommandOutput(content: unknown): string | null {
function extractLocalCommandOutput(
content: unknown,
options: { allowUntagged?: boolean } = {},
): string | null {
const raw = typeof content === 'string'
? content
: Array.isArray(content)
@ -1143,7 +1157,14 @@ function extractLocalCommandOutput(content: unknown): string | null {
if (stdout !== null) return stdout
const stderr = extractTaggedContent(raw, LOCAL_COMMAND_STDERR_TAG)
return stderr
if (stderr !== null) return stderr
if (options.allowUntagged) {
const normalized = raw.trim()
return normalized || null
}
return null
}
function extractTaggedContent(raw: string, tag: string): string | null {