cc-haha/desktop/src/components/chat/LocalSlashCommandPanel.tsx
程序员阿江(Relakkes) f57f163604 fix: prevent session inspector context stalls
Session inspection was coupling quick status and usage rendering to live context control requests, so a slow get_context_usage path could leave the desktop inspector stuck on a loading state. The desktop panel now loads basic inspection data first, renders a transcript-based context estimate immediately, and only asks for live context details as a background refinement.

The translation hook now returns a stable function per locale so effects that depend on translation do not reset and re-fetch after every render.

Constraint: Third-party provider sessions may not expose reliable live context capabilities through the control request path.
Rejected: Keep waiting on live get_context_usage for the context tab | it recreates the user-visible loading stall.
Rejected: Hardcode provider-specific context windows | provider capabilities are not always known from the desktop session record.
Confidence: high
Scope-risk: moderate
Directive: Do not make the inspector first render depend on live CLI control requests without a transcript or cached fallback.
Tested: bun test src/server/__tests__/conversations.test.ts -t "structured session inspection|Sonnet 4.6 transcript usage"
Tested: cd desktop && bun run test -- --run src/i18n/index.test.tsx
Tested: cd desktop && bun run build
Tested: git diff --check
Tested: agent-browser verified /context renders transcript estimate without Loading context data
Not-tested: Provider-specific true context-window discovery for every third-party vendor
2026-04-28 22:52:18 +08:00

1046 lines
43 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { skillsApi } from '../../api/skills'
import { mcpApi } from '../../api/mcp'
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'
import { useSkillStore } from '../../stores/skillStore'
import type { McpServerRecord } from '../../types/mcp'
import type { SkillMeta } from '../../types/skill'
import type { SlashCommandOption } from './composerUtils'
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':
return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
case 'needs-auth':
return 'bg-amber-500/10 text-amber-600 border-amber-500/20'
case 'failed':
return 'bg-rose-500/10 text-rose-600 border-rose-500/20'
case 'disabled':
return 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]'
default:
return ''
}
}
function scopeLabel(scope: string, t: ReturnType<typeof useTranslation>) {
switch (scope) {
case 'user':
return t('settings.mcp.scope.user')
case 'local':
return t('settings.mcp.scope.local')
case 'project':
return t('settings.mcp.scope.project')
default:
return scope
}
}
function projectBadge(path?: string, t?: ReturnType<typeof useTranslation>) {
if (!path || !t) return null
const label = path.replace(/\/$/, '').split('/').pop() || path
return t('slash.mcp.projectBadge', { name: label })
}
function PanelShell({
title,
subtitle,
children,
onClose,
}: {
title: string
subtitle: string
children: React.ReactNode
onClose: () => void
}) {
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-3 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
</div>
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
</div>
)
}
function LoadingState({ label }: { label: string }) {
return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-tertiary)]">
<div className="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
{label}
</div>
)
}
function EmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-10 text-center">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{body}</div>
</div>
)
}
function ErrorState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-5 py-4 text-sm text-[var(--color-error)]">
{message}
</div>
)
}
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 className="mt-0.5 text-[12px] text-[#7b665f]">{formatPercent(percent)}</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)
const freePercent = context.rawMaxTokens > 0 ? (freeTokens / context.rawMaxTokens) * 100 : 0
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)} detail={formatPercent(freePercent)} />
</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,
loading,
t,
}: {
context?: SessionContextSnapshot
error?: string
loading?: boolean
t: Translate
}) {
if (error && !context) return <ErrorState message={error} />
if (loading && !context) return <LoadingState label={t('slash.inspector.context.loading')} />
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)
const [contextLoading, setContextLoading] = useState(false)
const [contextError, setContextError] = useState<string | null>(null)
const contextRequestSessionRef = useRef<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)
setContextLoading(false)
setContextError(null)
contextRequestSessionRef.current = null
sessionsApi.getInspection(sessionId, { includeContext: false })
.then((response) => {
if (!cancelled) setData(assertSessionInspectionResponse(response, t))
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [sessionId, t])
useEffect(() => {
if (!sessionId || selectedTab !== 'context' || data === null || data.context) return
if (contextRequestSessionRef.current === sessionId) return
contextRequestSessionRef.current = sessionId
let cancelled = false
setContextLoading(true)
setContextError(null)
sessionsApi.getInspection(sessionId, { includeContext: true, timeout: 45_000 })
.then((response) => {
if (cancelled) return
const inspected = assertSessionInspectionResponse(response, t)
setData((current) => current
? {
...current,
context: inspected.context,
errors: {
...(current.errors ?? {}),
...(inspected.errors ?? {}),
},
}
: inspected)
setContextError(inspected.errors?.context ?? null)
})
.catch((err) => {
if (!cancelled) setContextError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setContextLoading(false)
})
return () => {
cancelled = true
}
}, [data, selectedTab, sessionId, t])
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 ?? data.contextEstimate}
error={contextError ?? data.errors?.context}
loading={contextLoading && !data.contextEstimate}
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)
const selectServer = useMcpStore((s) => s.selectServer)
const [servers, setServers] = useState<McpServerRecord[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
mcpApi.list(cwd)
.then(async (response) => {
if (cancelled) return
const visibleServers = response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project')
setServers(visibleServers)
const statusResults = await Promise.allSettled(
visibleServers.map((server) => mcpApi.status(server.name, cwd)),
)
if (cancelled) return
const liveServers = new Map<string, McpServerRecord>()
for (const result of statusResults) {
if (result.status === 'fulfilled') {
liveServers.set(result.value.server.name, result.value.server)
}
}
if (liveServers.size > 0) {
setServers((current) =>
current?.map((server) => liveServers.get(server.name) ?? server) ?? current,
)
}
})
.catch((err) => {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [cwd])
const grouped = useMemo(() => {
const groups = new Map<string, McpServerRecord[]>()
for (const server of servers ?? []) {
const key = server.scope
const existing = groups.get(key) ?? []
existing.push(server)
groups.set(key, existing)
}
return groups
}, [servers])
return (
<PanelShell
title={t('slash.mcp.title')}
subtitle={cwd ? t('slash.mcp.subtitleWithProject', { path: cwd }) : t('slash.mcp.subtitle')}
onClose={onClose}
>
{error ? (
<ErrorState message={error} />
) : servers === null ? (
<LoadingState label={t('common.loading')} />
) : servers.length === 0 ? (
<EmptyState title={t('slash.mcp.emptyTitle')} body={t('slash.mcp.emptyBody')} />
) : (
<div className="space-y-5">
{['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => (
<section key={scope}>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{scopeLabel(scope, t)}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{grouped.get(scope)?.length ?? 0}</div>
</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{grouped.get(scope)?.map((server) => (
<button
type="button"
key={`${server.scope}:${server.projectPath ?? 'global'}:${server.name}`}
onClick={() => {
selectServer(server)
setPendingSettingsTab('mcp')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{server.name}</div>
<span className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-semibold ${toneForStatus(server.status)}`}>
{server.statusLabel}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1">{server.transport}</span>
{server.projectPath && (
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1" title={server.projectPath}>
{projectBadge(server.projectPath, t)}
</span>
)}
<span className="truncate">{server.summary}</span>
</div>
</button>
))}
</div>
</section>
))}
</div>
)}
</PanelShell>
)
}
function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
const t = useTranslation()
const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab)
const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail)
const [skills, setSkills] = useState<SkillMeta[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
skillsApi.list(cwd)
.then((response) => {
if (cancelled) return
setSkills(response.skills.filter((skill) => skill.userInvocable))
})
.catch((err) => {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [cwd])
return (
<PanelShell
title={t('slash.skills.title')}
subtitle={cwd ? t('slash.skills.subtitleWithProject', { path: cwd }) : t('slash.skills.subtitle')}
onClose={onClose}
>
{error ? (
<ErrorState message={error} />
) : skills === null ? (
<LoadingState label={t('common.loading')} />
) : skills.length === 0 ? (
<EmptyState title={t('slash.skills.emptyTitle')} body={t('slash.skills.emptyBody')} />
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{skills.map((skill) => (
<button
type="button"
key={`${skill.source}:${skill.name}`}
onClick={async () => {
await fetchSkillDetail(skill.source, skill.name, cwd, 'skills')
setPendingSettingsTab('skills')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">/{skill.name}</div>
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]">
{skill.source}
</span>
</div>
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{skill.description}</div>
</button>
))}
</div>
)}
</PanelShell>
)
}
const COMMAND_GROUPS = [
{
titleKey: 'slash.help.group.context',
names: ['clear', 'compact', 'context', 'cost'],
},
{
titleKey: 'slash.help.group.project',
names: ['init', 'review', 'commit', 'pr'],
},
{
titleKey: 'slash.help.group.desktop',
names: ['mcp', 'skills', 'plugin', 'help'],
},
] satisfies Array<{ titleKey: TranslationKey; names: string[] }>
function HelpPanel({
commands,
onClose,
}: {
commands?: SlashCommandOption[]
onClose: () => void
}) {
const t = useTranslation()
const commandMap = useMemo(() => {
const map = new Map<string, SlashCommandOption>()
for (const command of commands ?? []) {
map.set(command.name, command)
}
return map
}, [commands])
const groupedNames = new Set(COMMAND_GROUPS.flatMap((group) => group.names))
const otherCommands = (commands ?? [])
.filter((command) => !groupedNames.has(command.name))
.slice(0, 12)
const hiddenOtherCommandCount = Math.max(
0,
(commands ?? []).filter((command) => !groupedNames.has(command.name)).length - otherCommands.length,
)
const renderCommand = (command: SlashCommandOption) => (
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-4 py-3 first:border-t-0">
<div className="shrink-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{command.description}</div>
</div>
)
return (
<PanelShell
title={t('slash.help.title')}
subtitle={t('slash.help.subtitle')}
onClose={onClose}
>
<div className="space-y-4">
{COMMAND_GROUPS.map((group) => {
const entries = group.names
.map((name) => commandMap.get(name))
.filter((command): command is SlashCommandOption => Boolean(command))
if (entries.length === 0) return null
return (
<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>
</section>
)
})}
{otherCommands.length > 0 && (
<section>
<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)]">
{t('slash.help.moreAvailable', { count: hiddenOtherCommandCount })}
</p>
)}
</section>
)}
</div>
</PanelShell>
)
}
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} />
}