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

{title}

{subtitle}

{children}
) } function LoadingState({ label }: { label: string }) { return (
{label}
) } function EmptyState({ title, body }: { title: string; body: string }) { return (
{title}
{body}
) } function ErrorState({ message }: { message: string }) { return (
{message}
) } const inspector = { bg: '#fbfaf6', panel: '#f6f4ee', line: '#d8b3a8', rust: '#8f3217', ink: '#1f1713', muted: '#7b665f', green: '#25451b', greenBg: '#d8f2b6', red: '#c51616', redBg: '#ffd9d3', } function formatNumber(value: number | undefined) { return new Intl.NumberFormat().format(value ?? 0) } function formatDuration(seconds: number | undefined) { const total = Math.max(0, Math.round(seconds ?? 0)) if (total < 60) return `${total}s` const minutes = Math.floor(total / 60) const remaining = total % 60 return remaining ? `${minutes}m ${remaining}s` : `${minutes}m` } function formatPercent(value: number | undefined) { const percent = Math.max(0, Math.min(100, value ?? 0)) return `${percent.toFixed(percent >= 10 || Number.isInteger(percent) ? 0 : 1)}%` } function sessionInspectorInitialTab(command: LocalSlashCommandName): SessionInspectorTab { if (command === 'cost') return 'usage' if (command === 'context') return 'context' return 'status' } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object') } function isSessionInspectionResponse(value: unknown): value is SessionInspectionResponse { if (!isRecord(value)) return false if (typeof value.active !== 'boolean') return false if (!isRecord(value.status)) return false return ( typeof value.status.sessionId === 'string' && typeof value.status.workDir === 'string' && typeof value.status.permissionMode === 'string' ) } function assertSessionInspectionResponse(value: unknown, t: Translate): SessionInspectionResponse { if (isSessionInspectionResponse(value)) return value throw new Error(t('slash.inspector.error.unavailable')) } function InspectorSectionTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) { return (
{children}
{action}
) } function MetricCard({ label, value, detail }: { label: string; value: React.ReactNode; detail?: React.ReactNode }) { return (
{label}
{value}
{detail &&
{detail}
}
) } function InspectorNotice({ children }: { children: React.ReactNode }) { return (
info {children}
) } function KeyValueRows({ rows }: { rows: Array<[string, React.ReactNode]> }) { return (
{rows.map(([label, value]) => (
{label}
{value}
))}
) } function UsageTab({ usage, context, error, t, }: { usage?: SessionUsageSnapshot context?: SessionContextSnapshot error?: string t: Translate }) { if (error && !usage) return if (!usage) { return } const usageHasTokens = ( usage.totalInputTokens + usage.totalOutputTokens + usage.totalCacheReadInputTokens + usage.totalCacheCreationInputTokens ) > 0 const apiUsage = context?.apiUsage const useContextUsageFallback = !usageHasTokens && !!apiUsage const totalInputTokens = useContextUsageFallback ? apiUsage.input_tokens : usage.totalInputTokens const totalOutputTokens = useContextUsageFallback ? apiUsage.output_tokens : usage.totalOutputTokens const totalCacheReadInputTokens = useContextUsageFallback ? apiUsage.cache_read_input_tokens : usage.totalCacheReadInputTokens const totalCacheCreationInputTokens = useContextUsageFallback ? apiUsage.cache_creation_input_tokens : usage.totalCacheCreationInputTokens const models = Array.isArray(usage.models) && usage.models.length > 0 ? usage.models : useContextUsageFallback ? [{ model: context?.model ?? 'current-model', displayName: context?.model ?? t('slash.inspector.status.activeModel'), inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cacheReadInputTokens: totalCacheReadInputTokens, cacheCreationInputTokens: totalCacheCreationInputTokens, webSearchRequests: 0, costUSD: 0, costDisplay: 'n/a', contextWindow: context?.rawMaxTokens ?? 0, maxOutputTokens: 0, }] : [] const sourceLabel = useContextUsageFallback ? t('slash.inspector.usage.source.contextSnapshot') : usage.source === 'transcript' ? t('slash.inspector.usage.source.transcript') : t('slash.inspector.usage.source.currentProcess') return (
{useContextUsageFallback && ( {t('slash.inspector.usage.contextSnapshotNotice')} )} {usage.source === 'transcript' && (
{t('slash.inspector.usage.transcriptNotice')}
)} {usage.hasUnknownModelCost && (
{t('slash.inspector.usage.unknownCost')}
)}
{t('slash.inspector.usage.byModel')}
{models.length === 0 ? ( ) : (
{models.map((model) => (
{model.displayName || model.model}
{t('slash.inspector.usage.tokens')}
{t('slash.inspector.usage.input')}
{formatNumber(model.inputTokens)}
{t('slash.inspector.usage.output')}
{formatNumber(model.outputTokens)}
))}
)}
) } type ContextCategory = SessionContextSnapshot['categories'][number] function isCapacityCategory(category: ContextCategory) { const name = category.name.toLowerCase() return category.isDeferred || name.includes('free') || name.includes('autocompact') } function ContextStackedBar({ categories, rawMaxTokens }: { categories: ContextCategory[]; rawMaxTokens: number }) { const activeCategories = categories.filter((category) => !isCapacityCategory(category) && category.tokens > 0) if (activeCategories.length === 0) return null return (
{activeCategories.map((category) => (
))}
) } function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: ContextCategory[]; rawMaxTokens: number; t: Translate }) { const visibleCategories = categories.filter((category) => category.tokens > 0) if (visibleCategories.length === 0) { return } return (
{t('slash.inspector.context.categoryTitle')}
{visibleCategories.map((category) => { const percent = rawMaxTokens > 0 ? (category.tokens / rawMaxTokens) * 100 : 0 const muted = isCapacityCategory(category) return (
{category.name}
{formatNumber(category.tokens)}
{formatPercent(percent)}
) })}
) } function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) { return (
{label}
{value}
{detail &&
{detail}
}
) } function statusDisplayLabel(status: string, t: Translate) { const normalized = status.toLowerCase() if (normalized === 'connected') return t('slash.inspector.status.connected') if (normalized === 'failed') return t('slash.inspector.status.failed') return status } function InspectorStatusBadge({ status, t }: { status: string; t: Translate }) { const normalized = status.toLowerCase() const isConnected = normalized === 'connected' const isFailed = normalized === 'failed' const badgeClass = isConnected ? 'bg-[#d8f2b6] text-[#25451b]' : isFailed ? 'bg-[#ffd9d3] text-[#c51616]' : 'bg-[#ebe7df] text-[#5f514c]' const dotClass = isConnected ? 'bg-[#25451b]' : isFailed ? 'bg-[#c51616]' : 'bg-[#7b665f]' return ( {statusDisplayLabel(status, t)} ) } function McpServerIcon({ status }: { status: string }) { const isFailed = status === 'failed' const icon = isFailed ? 'power_off' : 'dns' return ( {icon} ) } function ContextOverview({ context, categories, t }: { context: SessionContextSnapshot; categories: ContextCategory[]; t: Translate }) { const usedPercent = Math.min(100, Math.max(0, context.percentage)) const freeTokens = Math.max(0, context.rawMaxTokens - context.totalTokens) const freePercent = context.rawMaxTokens > 0 ? (freeTokens / context.rawMaxTokens) * 100 : 0 return (
{t('slash.inspector.context.windowUsage')} {context.model}
{formatNumber(context.totalTokens)} / {formatNumber(context.rawMaxTokens)} [{formatPercent(usedPercent)} {t('slash.inspector.context.used')}]
) } function ContextTab({ context, error, loading, t, }: { context?: SessionContextSnapshot error?: string loading?: boolean t: Translate }) { if (error && !context) return if (loading && !context) return if (!context) { return } const categories = Array.isArray(context.categories) ? context.categories : [] return (
) } function StatusTab({ data, commands, t, }: { data: SessionInspectionResponse commands?: SlashCommandOption[] t: Translate }) { const mcpServers = Array.isArray(data.status.mcpServers) ? data.status.mcpServers : [] const tools = Array.isArray(data.status.tools) ? data.status.tools : [] const model = data.status.model ?? data.context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown') const slashCommandCount = (data.status.slashCommandCount ?? 0) > 0 ? data.status.slashCommandCount : commands?.length ?? 0 const connectedMcp = mcpServers.filter((server) => server.status === 'connected').length const failedMcp = mcpServers.filter((server) => server.status === 'failed').length return (
{data.active ? t('slash.inspector.status.running') : t('slash.inspector.status.notRunning')} )} /> {formatNumber(connectedMcp)} / {formatNumber(failedMcp)} )} detail={( {t('slash.inspector.status.connected')} {t('slash.inspector.status.failed')} )} />
{t('slash.inspector.status.sessionMetadata')} {data.status.sessionId}], [t('slash.inspector.status.workingDirectory'), {data.status.cwd ?? data.status.workDir}], [t('slash.inspector.status.permissionMode'), {data.status.permissionMode}], [t('slash.inspector.status.authToken'), data.status.apiKeySource ?? t('slash.inspector.status.unknown')], [t('slash.inspector.status.outputStyle'), data.status.outputStyle ?? t('slash.inspector.status.default')], ]} />
{mcpServers.length > 0 && (
↻ {t('slash.inspector.status.refresh')}} > {t('slash.inspector.status.mcpServers')}
{mcpServers.map((server) => (
{server.name}
))}
)}
) } function SessionInspectorShell({ selectedTab, tabs, onSelectTab, onClose, children, t, }: { selectedTab: SessionInspectorTab tabs: Array<{ id: SessionInspectorTab; label: string }> onSelectTab: (tab: SessionInspectorTab) => void onClose: () => void children: React.ReactNode t: Translate }) { return (
{t('slash.inspector.title')}
{tabs.map((tab) => ( ))}
{children}
) } function SessionInspectorPanel({ command, sessionId, commands, onClose, }: { command: LocalSlashCommandName sessionId?: string commands?: SlashCommandOption[] onClose: () => void }) { const t = useTranslation() const [selectedTab, setSelectedTab] = useState(() => sessionInspectorInitialTab(command)) const [data, setData] = useState(null) const [error, setError] = useState(null) const [contextLoading, setContextLoading] = useState(false) const [contextError, setContextError] = useState(null) const contextRequestSessionRef = useRef(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 ( {error ? ( ) : data === null ? ( ) : selectedTab === 'usage' ? ( ) : selectedTab === 'context' ? ( ) : ( )} ) } 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(null) const [error, setError] = useState(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() 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() 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 ( {error ? ( ) : servers === null ? ( ) : servers.length === 0 ? ( ) : (
{['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => (
{scopeLabel(scope, t)}
{grouped.get(scope)?.length ?? 0}
{grouped.get(scope)?.map((server) => ( ))}
))}
)}
) } 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(null) const [error, setError] = useState(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 ( {error ? ( ) : skills === null ? ( ) : skills.length === 0 ? ( ) : (
{skills.map((skill) => ( ))}
)}
) } 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() 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) => (
/{command.name}
{command.description}
) return (
{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 (
{t(group.titleKey)}
{entries.map(renderCommand)}
) })} {otherCommands.length > 0 && (
{t('slash.help.group.more')}
{otherCommands.map(renderCommand)}
{hiddenOtherCommandCount > 0 && (

{t('slash.help.moreAvailable', { count: hiddenOtherCommandCount })}

)}
)}
) } export function LocalSlashCommandPanel({ command, sessionId, cwd, commands, onClose }: Props) { if (command === 'mcp') return if (command === 'skills') return if (command === 'status' || command === 'cost' || command === 'context') { return } return }