mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
1046 lines
43 KiB
TypeScript
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} />
|
|
}
|