mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
bc93493ed1
commit
b4142c8221
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '关闭',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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' &&
|
||||
|
||||
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user