Download desktop/src/components/chat/LocalSlashCommandPanel.tsx from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 50.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/chat/LocalSlashCommandPanel.tsx
- Command line
-
hf download hf://chenbhao/codev@e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/chat/LocalSlashCommandPanel.tsx
-
curl -L -o LocalSlashCommandPanel.tsx https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/chat/LocalSlashCommandPanel.tsx
50.5 kB
| 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-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-inspector-border)]' | |
| case 'needs-auth': | |
| return 'bg-[var(--color-surface-container-low)] text-[var(--color-warning)] border-[var(--color-border)]' | |
| case 'failed': | |
| return 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)] border-[var(--color-inspector-border)]' | |
| 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-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-4 text-sm text-[var(--color-inspector-danger)]"> | |
| {message} | |
| </div> | |
| ) | |
| } | |
| 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-[var(--color-inspector-heading)]">{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-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-4 font-mono"> | |
| <div className="text-[12px] uppercase tracking-[0.2em] text-[var(--color-inspector-heading)]">{label}</div> | |
| <div className="mt-3 whitespace-pre-line text-[15px] leading-6 text-[var(--color-inspector-text)]">{value}</div> | |
| {detail && <div className="mt-1 text-[13px] leading-5 text-[var(--color-inspector-muted)]">{detail}</div>} | |
| </div> | |
| ) | |
| } | |
| function InspectorNotice({ children }: { children: React.ReactNode }) { | |
| return ( | |
| <div className="flex items-center gap-3 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3 text-[14px] text-[var(--color-inspector-heading)]"> | |
| <span className="material-symbols-outlined text-[18px] text-[var(--color-inspector-muted)]">info</span> | |
| <span>{children}</span> | |
| </div> | |
| ) | |
| } | |
| function KeyValueRows({ rows }: { rows: Array<[string, React.ReactNode]> }) { | |
| return ( | |
| <div className="overflow-hidden rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] font-mono"> | |
| {rows.map(([label, value]) => ( | |
| <div key={label} className="grid grid-cols-[220px_minmax(0,1fr)] border-t border-[var(--color-inspector-border)] first:border-t-0"> | |
| <div className="border-r border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-3 text-[12px] font-semibold uppercase tracking-[0.24em] text-[var(--color-inspector-heading)]"> | |
| {label} | |
| </div> | |
| <div className="min-w-0 break-words px-4 py-3 text-[14px] text-[var(--color-inspector-text)]">{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-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3 text-sm text-[var(--color-inspector-muted-strong)]"> | |
| {t('slash.inspector.usage.transcriptNotice')} | |
| </div> | |
| )} | |
| {usage.hasUnknownModelCost && ( | |
| <div className="rounded-xl border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-3 text-sm text-[var(--color-warning)]"> | |
| {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-[var(--color-inspector-text)]">{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-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] font-mono"> | |
| {models.map((model) => ( | |
| <div key={model.model} className="border-t border-[var(--color-inspector-border)] first:border-t-0"> | |
| <div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-inspector-border)] px-4 py-3"> | |
| <div className="min-w-0"> | |
| <div className="truncate text-[13px] font-semibold text-[var(--color-inspector-text)]">{model.displayName || model.model}</div> | |
| {(model.contextWindow > 0 || context?.rawMaxTokens) && ( | |
| <div className="mt-1 truncate text-[11px] text-[var(--color-inspector-muted)]"> | |
| {t('slash.inspector.status.contextWindow')}: {formatNumber(model.contextWindow || context?.rawMaxTokens)} | |
| </div> | |
| )} | |
| </div> | |
| <div className="text-right text-[12px] font-semibold uppercase tracking-[0.18em] text-[var(--color-inspector-heading)]">{t('slash.inspector.usage.tokens')}</div> | |
| </div> | |
| <div className="grid grid-cols-[160px_minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-inspector-border)] px-4 py-3 last:border-b-0"> | |
| <div className="text-[12px] uppercase tracking-[0.18em] text-[var(--color-inspector-heading)]">{t('slash.inspector.usage.input')}</div> | |
| <div className="h-1 overflow-hidden rounded-full bg-[var(--color-inspector-chip)]"> | |
| <div className="h-full rounded-full bg-[var(--color-inspector-accent)]" style={{ width: '95%' }} /> | |
| </div> | |
| <div className="text-right text-[13px] text-[var(--color-inspector-text)]">{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-[var(--color-inspector-heading)]">{t('slash.inspector.usage.output')}</div> | |
| <div className="h-1 overflow-hidden rounded-full bg-[var(--color-inspector-chip)]"> | |
| <div className="h-full rounded-full bg-[var(--color-inspector-accent-secondary)]" style={{ width: `${Math.max(4, Math.min(100, (model.outputTokens / Math.max(1, model.inputTokens)) * 100))}%` }} /> | |
| </div> | |
| <div className="text-right text-[13px] text-[var(--color-inspector-text)]">{formatNumber(model.outputTokens)}</div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </section> | |
| </div> | |
| ) | |
| } | |
| type ContextCategory = SessionContextSnapshot['categories'][number] | |
| type ContextMemoryFile = SessionContextSnapshot['memoryFiles'][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-[var(--color-inspector-chip)]"> | |
| <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-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] 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-[var(--color-inspector-muted-strong)]' : 'text-[var(--color-inspector-text)]'}`}> | |
| {category.name} | |
| </span> | |
| </div> | |
| <div className="shrink-0 text-right leading-tight"> | |
| <div className="text-sm text-[var(--color-inspector-text)]">{formatNumber(category.tokens)}</div> | |
| <div className="mt-0.5 text-[12px] text-[var(--color-inspector-muted)]">{formatPercent(percent)}</div> | |
| </div> | |
| </div> | |
| <div className="mt-2 h-1 overflow-hidden rounded-full bg-[var(--color-inspector-chip)]"> | |
| <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 ? 'var(--color-inspector-capacity)' : 'var(--color-inspector-accent)', | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function memoryContextFileLabel(path: string) { | |
| const normalized = path.replace(/\\/g, '/') | |
| return normalized.split('/').pop() || normalized | |
| } | |
| function MemoryFilesBreakdown({ files, t }: { files: ContextMemoryFile[]; t: Translate }) { | |
| const setPendingSettingsTab = useUIStore((state) => state.setPendingSettingsTab) | |
| const setPendingMemoryPath = useUIStore((state) => state.setPendingMemoryPath) | |
| const openSettings = (path?: string) => { | |
| if (path) setPendingMemoryPath(path) | |
| setPendingSettingsTab('memory') | |
| useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') | |
| } | |
| if (files.length === 0) { | |
| return ( | |
| <div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle> | |
| <button | |
| type="button" | |
| onClick={() => openSettings()} | |
| className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]" | |
| > | |
| {t('slash.inspector.context.openMemory')} | |
| </button> | |
| </div> | |
| <div className="mt-4 text-sm text-[var(--color-inspector-muted)]">{t('slash.inspector.context.noMemoryFiles')}</div> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle> | |
| <button | |
| type="button" | |
| onClick={() => openSettings(files[0]?.path)} | |
| className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]" | |
| > | |
| {t('slash.inspector.context.openMemory')} | |
| </button> | |
| </div> | |
| <div className="mt-4 grid gap-2"> | |
| {files.map((file) => ( | |
| <div | |
| key={`${file.type}:${file.path}`} | |
| className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-3 py-2" | |
| > | |
| <div className="min-w-0"> | |
| <div className="truncate font-mono text-sm font-semibold text-[var(--color-inspector-text)]" title={file.path}> | |
| {memoryContextFileLabel(file.path)} | |
| </div> | |
| <div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-inspector-muted)]" title={file.path}> | |
| {file.path} | |
| </div> | |
| </div> | |
| <div className="shrink-0 text-right font-mono"> | |
| <div className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-inspector-muted-strong)]">{file.type}</div> | |
| <div className="mt-0.5 text-[11px] text-[var(--color-inspector-muted)]">{formatNumber(file.tokens)} tokens</div> | |
| </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-[var(--color-inspector-muted)]">{label}</div> | |
| <div className="mt-2 truncate text-[16px] font-semibold text-[var(--color-inspector-text)]">{value}</div> | |
| {detail && <div className="mt-1 truncate text-[13px] text-[var(--color-inspector-muted)]">{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-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)]' | |
| : isFailed | |
| ? 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)]' | |
| : 'bg-[var(--color-inspector-chip)] text-[var(--color-inspector-muted-strong)]' | |
| const dotClass = isConnected ? 'bg-[var(--color-inspector-success)]' : isFailed ? 'bg-[var(--color-inspector-danger)]' : 'bg-[var(--color-inspector-muted)]' | |
| 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-[var(--color-inspector-danger)]' : 'text-[var(--color-inspector-success)]'}`}> | |
| {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-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] 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-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2 py-1 font-mono text-xs text-[var(--color-inspector-muted-strong)]">{context.model}</span> | |
| </div> | |
| <div className="font-mono text-[24px] font-semibold text-[var(--color-inspector-text)]"> | |
| {formatNumber(context.totalTokens)} | |
| <span className="mx-1.5 text-[var(--color-inspector-text)]">/</span> | |
| <span>{formatNumber(context.rawMaxTokens)}</span> | |
| <span className="ml-3 align-middle text-sm font-normal text-[var(--color-inspector-accent-secondary)]">[{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-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3"> | |
| <ContextStatPill label={t('slash.inspector.context.free')} value={formatNumber(freeTokens)} detail={formatPercent(freePercent)} /> | |
| </div> | |
| <div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] 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-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3"> | |
| <ContextStatPill label={t('slash.inspector.context.toolResults')} value={formatNumber(context.messageBreakdown?.toolResultTokens ?? 0)} /> | |
| </div> | |
| <div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] 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} /> | |
| <MemoryFilesBreakdown files={Array.isArray(context.memoryFiles) ? context.memoryFiles : []} 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 context = data.context ?? data.contextEstimate | |
| const model = data.status.model ?? context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown') | |
| const contextWindow = context?.rawMaxTokens ?? data.usage?.models?.find((entry) => entry.contextWindow > 0)?.contextWindow | |
| 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-[var(--color-inspector-success)]' : 'bg-[var(--color-inspector-danger)]'}`} /> | |
| {data.active ? t('slash.inspector.status.running') : t('slash.inspector.status.notRunning')} | |
| </span> | |
| )} | |
| /> | |
| <MetricCard | |
| label={t('slash.inspector.status.activeModel')} | |
| value={model} | |
| detail={contextWindow ? `${t('slash.inspector.status.contextWindow')}: ${formatNumber(contextWindow)}` : undefined} | |
| /> | |
| <MetricCard | |
| label={t('slash.inspector.status.mcpConnections')} | |
| value={( | |
| <span> | |
| <span className="text-[var(--color-inspector-success)]">{formatNumber(connectedMcp)}</span> | |
| <span className="mx-5 text-[var(--color-inspector-text)]">/</span> | |
| <span className="text-[var(--color-inspector-danger)]">{formatNumber(failedMcp)}</span> | |
| </span> | |
| )} | |
| detail={( | |
| <span> | |
| <span className="text-[var(--color-inspector-success)]">{t('slash.inspector.status.connected')}</span> | |
| <span className="mx-5 text-[var(--color-inspector-text)]" /> | |
| <span className="text-[var(--color-inspector-danger)]">{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-[var(--color-inspector-chip)] 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-[var(--color-inspector-accent)] hover:text-[var(--color-inspector-accent-hover)]">↻ {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 border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-3 font-mono" | |
| > | |
| <div className="flex min-w-0 items-center gap-3"> | |
| <McpServerIcon status={server.status} /> | |
| <span className="min-w-0 truncate text-[14px] text-[var(--color-inspector-text)]">{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 border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] text-[var(--color-inspector-text)] shadow-[var(--shadow-inspector)]" | |
| > | |
| <div className="grid min-h-[64px] grid-cols-[1fr_auto_1fr] items-center border-b border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-6"> | |
| <div className="font-mono text-[16px] font-semibold uppercase text-[var(--color-inspector-accent)]">{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-[var(--color-inspector-accent)]' : 'text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-accent)]' | |
| }`} | |
| > | |
| {tab.label} | |
| {selectedTab === tab.id && <span className="absolute bottom-1 left-0 right-0 h-[2px] bg-[var(--color-inspector-accent)]" />} | |
| </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-[var(--color-inspector-accent)] transition-colors hover:text-[var(--color-inspector-accent-hover)]" | |
| > | |
| <span className="material-symbols-outlined text-[24px]">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="max-h-[min(540px,58vh)] overflow-y-auto bg-[var(--color-inspector-surface)] 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, contextOnly: 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 ?? data.contextEstimate} 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="flex min-w-[120px] max-w-[45%] shrink-0 flex-wrap items-baseline gap-x-1.5 font-mono"> | |
| <span className="text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span> | |
| {command.argumentHint ? ( | |
| <span className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{command.argumentHint}</span> | |
| ) : null} | |
| </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} /> | |
| } | |